diff --git a/.eslintrc.js b/.eslintrc.js
index 26865d55ec..9c88bdba9c 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,6 +1,11 @@
module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"],
- extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"],
+ extends: [
+ "plugin:matrix-org/babel",
+ "plugin:matrix-org/react",
+ "plugin:matrix-org/a11y",
+ "plugin:storybook/recommended",
+ ],
parserOptions: {
project: ["./tsconfig.json"],
},
diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml
index 75a00af7aa..9eca8c8636 100644
--- a/.github/workflows/docker.yaml
+++ b/.github/workflows/docker.yaml
@@ -25,14 +25,14 @@ jobs:
fetch-depth: 0 # needed for docker-package to be able to calculate the version
- name: Install Cosign
- uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3
+ uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3
if: github.event_name != 'pull_request'
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
+ uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
with:
install: true
@@ -53,7 +53,7 @@ jobs:
- name: Build and load
id: test-build
- uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
context: .
load: true
@@ -110,7 +110,7 @@ jobs:
- name: Build and push
id: build-and-push
- uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6
+ uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
if: github.event_name != 'pull_request'
with:
context: .
diff --git a/.github/workflows/end-to-end-tests.yaml b/.github/workflows/end-to-end-tests.yaml
index b2d49d7703..5901d7a700 100644
--- a/.github/workflows/end-to-end-tests.yaml
+++ b/.github/workflows/end-to-end-tests.yaml
@@ -227,7 +227,7 @@ jobs:
- name: Merge into HTML Report
if: inputs.skip != true
- run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,./playwright/stale-screenshot-reporter.ts ./all-blob-reports
+ run: yarn playwright merge-reports --reporter=html,./playwright/flaky-reporter.ts,@element-hq/element-web-playwright-common/lib/stale-screenshot-reporter.js ./all-blob-reports
env:
# Only pass creds to the flaky-reporter on main branch runs
GITHUB_TOKEN: ${{ github.ref_name == 'develop' && secrets.ELEMENT_BOT_TOKEN || '' }}
diff --git a/.github/workflows/shared-component-visual-tests-netlify.yaml b/.github/workflows/shared-component-visual-tests-netlify.yaml
new file mode 100644
index 0000000000..eadaffbea5
--- /dev/null
+++ b/.github/workflows/shared-component-visual-tests-netlify.yaml
@@ -0,0 +1,51 @@
+# Triggers after the shared component tests have finished,
+# It uploads the received images and diffs to netlify, printing the URLs to the console
+name: Upload Shared Component Visual Test Diffs
+on:
+ workflow_run:
+ workflows: ["Shared Component Visual Tests"]
+ types:
+ - completed
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
+ cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
+
+permissions: {}
+
+jobs:
+ report:
+ if: github.event.workflow_run.conclusion == 'failure'
+ name: Upload Diffs
+ runs-on: ubuntu-24.04
+ environment: Netlify
+ permissions:
+ actions: read
+ deployments: write
+ steps:
+ - name: Install tree
+ run: "sudo apt-get install -y tree"
+
+ - name: Download Diffs
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ run-id: ${{ github.event.workflow_run.id }}
+ name: received-images
+ path: received-images
+
+ - name: Generate Index
+ run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ."
+
+ - name: 📤 Deploy to Netlify
+ uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
+ with:
+ path: received-images
+ owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+ branch: ${{ github.event.workflow_run.head_branch }}
+ revision: ${{ github.event.workflow_run.head_sha }}
+ token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+ site_id: ${{ vars.NETLIFY_SITE_ID }}
+ desc: Shared Component Visual Diffs
+ deployment_env: SharedComponentDiffs
+ prefix: "diffs-"
diff --git a/.github/workflows/shared-component-visual-tests.yaml b/.github/workflows/shared-component-visual-tests.yaml
new file mode 100644
index 0000000000..98d258349e
--- /dev/null
+++ b/.github/workflows/shared-component-visual-tests.yaml
@@ -0,0 +1,70 @@
+name: Shared Component Visual Tests
+on:
+ pull_request: {}
+ merge_group:
+ types: [checks_requested]
+ push:
+ branches: [develop, master]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
+ cancel-in-progress: true
+
+permissions: {} # No permissions required
+
+jobs:
+ testStorybook:
+ name: "Run Visual Tests"
+ runs-on: ubuntu-24.04
+ permissions:
+ actions: read
+ issues: read
+ pull-requests: read
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ with:
+ persist-credentials: false
+ repository: element-hq/element-web
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ cache: "yarn"
+ node-version: "lts/*"
+
+ - name: Install dependencies
+ run: yarn install --frozen-lockfile
+
+ - name: Get installed Playwright version
+ id: playwright
+ run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
+
+ - name: Cache playwright binaries
+ uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
+
+ - name: Install Playwright browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: "yarn playwright install --with-deps --only-shell"
+
+ - name: Build Element Web resources
+ # Needed to prepare language files
+ run: "yarn build:res"
+
+ - name: Build storybook dependencies
+ # When the first test is ran, it will fail because the dependencies are not yet built.
+ # This step is to ensure that the dependencies are built before running the tests.
+ run: "yarn test:storybook:ci"
+ continue-on-error: true
+
+ - name: Run Visual tests
+ run: "yarn test:storybook:ci"
+
+ - name: Upload received images & diffs
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: received-images
+ path: playwright/shared-component-received
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 276c53c098..a17e457252 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
+ uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
diff --git a/.github/workflows/triage-stale.yml b/.github/workflows/triage-stale.yml
index f76cd299cc..51120c336e 100644
--- a/.github/workflows/triage-stale.yml
+++ b/.github/workflows/triage-stale.yml
@@ -15,12 +15,14 @@ jobs:
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
with:
operations-per-run: 100
+
# Flaky test issue closing
- only-issue-labels: "Z-Flaky-Test"
+ any-of-issue-labels: "Z-Flaky-Test-Chrome,Z-Flaky-Test-Firefox,Z-Flaky-Test-Webkit"
days-before-issue-stale: 14
days-before-issue-close: 0
close-issue-message: "This flaky test issue has not been updated in 14 days. It is being closed as presumed resolved."
exempt-issue-labels: "Z-Flaky-Test-Disabled"
+
# Stale PR closing
days-before-pr-stale: 180
days-before-pr-close: 0
diff --git a/.github/workflows/update-topics.yaml b/.github/workflows/update-topics.yaml
index 5ee9f2b608..7bf751384d 100644
--- a/.github/workflows/update-topics.yaml
+++ b/.github/workflows/update-topics.yaml
@@ -26,7 +26,7 @@ jobs:
env:
HS_URL: ${{ secrets.BETABOT_HS_URL }}
LOBBY_ROOM_ID: ${{ secrets.ROOM_ID }}
- PUBLIC_ROOM_ID: "!YTvKGNlinIzlkMTVRl:matrix.org"
+ PUBLIC_ROOM_ID: "!IemiTbwVankHTFiEoh:matrix.org"
ANNOUNCEMENT_ROOM_ID: "!bijaLdadorKgNGtHdA:matrix.org"
TOKEN: ${{ secrets.BETABOT_ACCESS_TOKEN }}
RELEASE_STATUS: "Release status: ${{ inputs.expected_status }} expected ${{ inputs.expected_date }}"
@@ -81,6 +81,11 @@ jobs:
d.body = d.body.replace(regex, releaseTopic);
});
}
+ if (data["m.topic"]) {
+ data["m.topic"].forEach(d => {
+ d.body = d.body.replace(regex, releaseTopic);
+ });
+ }
res = await fetch(apiUrl, {
method: "PUT",
diff --git a/.gitignore b/.gitignore
index 429b317a4f..efc96c5425 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@ electron/pub
/index.html
# version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt
+
+*storybook.log
+storybook-static
diff --git a/.storybook/ElementTheme.ts b/.storybook/ElementTheme.ts
new file mode 100644
index 0000000000..0967697621
--- /dev/null
+++ b/.storybook/ElementTheme.ts
@@ -0,0 +1,28 @@
+/*
+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 { create } from "storybook/theming";
+
+export default create({
+ base: "light",
+
+ // Colors
+ textColor: "#1b1d22",
+ colorSecondary: "#111111",
+
+ // UI
+ appBg: "#ffffff",
+ appContentBg: "#ffffff",
+
+ // Toolbar
+ barBg: "#ffffff",
+
+ brandTitle: "Element Web",
+ brandUrl: "https://github.com/element-hq/element-web",
+ brandImage: "https://element.io/images/logo-ele-secondary.svg",
+ brandTarget: "_self",
+});
diff --git a/.storybook/languageAddon.tsx b/.storybook/languageAddon.tsx
new file mode 100644
index 0000000000..0e46e9b25b
--- /dev/null
+++ b/.storybook/languageAddon.tsx
@@ -0,0 +1,61 @@
+/*
+ * 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 { Addon, types, useGlobals } from "storybook/manager-api";
+import { WithTooltip, IconButton, TooltipLinkList } from "storybook/internal/components";
+import React from "react";
+import { GlobeIcon } from "@storybook/icons";
+
+// We can't import `shared/i18n.tsx` directly here.
+// The storybook addon doesn't seem to benefit the vite config of storybook and we can't resolve the alias in i18n.tsx.
+import json from "../webapp/i18n/languages.json";
+const languages = Object.keys(json).filter((lang) => lang !== "default");
+
+/**
+ * Returns the title of a language in the user's locale.
+ */
+function languageTitle(language: string): string {
+ return new Intl.DisplayNames([language], { type: "language", style: "short" }).of(language) || language;
+}
+
+export const languageAddon: Addon = {
+ title: "Language Selector",
+ type: types.TOOL,
+ render: ({ active }) => {
+ const [globals, updateGlobals] = useGlobals();
+ const selectedLanguage = globals.language || "en";
+
+ return (
+ {
+ return (
+ ({
+ id: language,
+ title: languageTitle(language),
+ active: selectedLanguage === language,
+ onClick: async () => {
+ // Update the global state with the selected language
+ updateGlobals({ language });
+ onHide();
+ },
+ }))}
+ />
+ );
+ }}
+ >
+
+
+ {languageTitle(selectedLanguage)}
+
+
+ );
+ },
+};
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 0000000000..ef8b8cd19c
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,37 @@
+/*
+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 type { StorybookConfig } from "@storybook/react-vite";
+import path from "node:path";
+import { nodePolyfills } from "vite-plugin-node-polyfills";
+import { mergeConfig } from "vite";
+
+const config: StorybookConfig = {
+ stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
+ staticDirs: ["../webapp"],
+ addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
+ framework: "@storybook/react-vite",
+ core: {
+ disableTelemetry: true,
+ },
+ typescript: {
+ reactDocgen: "react-docgen-typescript",
+ },
+ async viteFinal(config) {
+ return mergeConfig(config, {
+ resolve: {
+ alias: {
+ // Alias used by i18n.tsx
+ $webapp: path.resolve("webapp"),
+ },
+ },
+ // Needed for counterpart to work
+ plugins: [nodePolyfills({ include: ["process", "util"] })],
+ });
+ },
+};
+export default config;
diff --git a/.storybook/manager.js b/.storybook/manager.js
new file mode 100644
index 0000000000..1b08ef7825
--- /dev/null
+++ b/.storybook/manager.js
@@ -0,0 +1,18 @@
+/*
+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 { addons } from "storybook/manager-api";
+import ElementTheme from "./ElementTheme";
+import { languageAddon } from "./languageAddon";
+
+addons.setConfig({
+ theme: ElementTheme,
+});
+
+addons.register("elementhq/language", () => addons.add("language", languageAddon));
diff --git a/.storybook/preview.css b/.storybook/preview.css
new file mode 100644
index 0000000000..9f49585937
--- /dev/null
+++ b/.storybook/preview.css
@@ -0,0 +1,10 @@
+/*
+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.
+*/
+
+.docs-story {
+ background: var(--cpd-color-bg-canvas-default);
+}
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 0000000000..8d7516680a
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,90 @@
+import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
+import { addons } from "storybook/preview-api";
+
+import "../res/css/shared.pcss";
+import "./preview.css";
+import React, { useLayoutEffect } from "react";
+import { FORCE_RE_RENDER } from "storybook/internal/core-events";
+import { setLanguage } from "../src/shared-components/i18n";
+
+export const globalTypes = {
+ theme: {
+ name: "Theme",
+ description: "Global theme for components",
+ toolbar: {
+ icon: "circlehollow",
+ title: "Theme",
+ items: [
+ { title: "System", value: "system", icon: "browser" },
+ { title: "Light", value: "light", icon: "sun" },
+ { title: "Light (high contrast)", value: "light-hc", icon: "sun" },
+ { title: "Dark", value: "dark", icon: "moon" },
+ { title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
+ ],
+ },
+ },
+ language: {
+ name: "Language",
+ description: "Global language for components",
+ },
+ initialGlobals: {
+ theme: "system",
+ language: "en",
+ },
+} satisfies ArgTypes;
+
+const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
+
+const ThemeSwitcher: React.FC<{
+ theme: string;
+}> = ({ theme }) => {
+ useLayoutEffect(() => {
+ document.documentElement.classList.remove(...allThemesClasses);
+ if (theme !== "system") {
+ document.documentElement.classList.add(`cpd-theme-${theme}`);
+ }
+ return () => document.documentElement.classList.remove(...allThemesClasses);
+ }, [theme]);
+
+ return null;
+};
+
+const withThemeProvider: Decorator = (Story, context) => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const LanguageSwitcher: React.FC<{
+ language: string;
+}> = ({ language }) => {
+ useLayoutEffect(() => {
+ const changeLanguage = async (language: string) => {
+ await setLanguage(language);
+ // Force the component to re-render to apply the new language
+ addons.getChannel().emit(FORCE_RE_RENDER);
+ };
+ changeLanguage(language);
+ }, [language]);
+
+ return null;
+};
+
+export const withLanguageProvider: Decorator = (Story, context) => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+const preview: Preview = {
+ tags: ["autodocs"],
+ decorators: [withThemeProvider, withLanguageProvider],
+};
+
+export default preview;
diff --git a/.storybook/test-runner.js b/.storybook/test-runner.js
new file mode 100644
index 0000000000..acfafe0286
--- /dev/null
+++ b/.storybook/test-runner.js
@@ -0,0 +1,37 @@
+/*
+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 { waitForPageReady } from "@storybook/test-runner";
+import { toMatchImageSnapshot } from "jest-image-snapshot";
+
+const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
+const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
+
+/**
+ * @type {import('@storybook/test-runner').TestRunnerConfig}
+ */
+const config = {
+ setup(page) {
+ expect.extend({ toMatchImageSnapshot });
+ },
+ async postVisit(page, context) {
+ await waitForPageReady(page);
+
+ // If you want to take screenshot of multiple browsers, use
+ // page.context().browser().browserType().name() to get the browser name to prefix the file name
+ const image = await page.screenshot();
+ expect(image).toMatchImageSnapshot({
+ customSnapshotsDir,
+ customSnapshotIdentifier: `${context.id}-${process.platform}`,
+ storeReceivedOnFailure: true,
+ customReceivedDir,
+ customDiffDir: customReceivedDir,
+ });
+ },
+};
+
+export default config;
diff --git a/AUTHORS.rst b/AUTHORS.rst
index d027b59c99..a3f3be6586 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -19,3 +19,6 @@ include:
* Thom Cleary (https://github.com/thomcatdotrocks)
Small update for tarball deployment
+
+* Alexander (https://github.com/ioalexander)
+ Save image on CTRL + S shortcut
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3a18c5396..b7f3c4febd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,71 @@
+Changes in [1.11.106](https://github.com/element-hq/element-web/releases/tag/v1.11.106) (2025-07-15)
+====================================================================================================
+## ✨ Features
+
+* [Backport staging] Fix e2e icon colour ([#30304](https://github.com/element-hq/element-web/pull/30304)). Contributed by @RiotRobot.
+* Add support for module message hint `allowDownloadingMedia` ([#30252](https://github.com/element-hq/element-web/pull/30252)). Contributed by @Half-Shot.
+* Update the mobile\_guide page to the new design and link out to Element X by default. ([#30172](https://github.com/element-hq/element-web/pull/30172)). Contributed by @pixlwave.
+* Filter settings exported when rageshaking ([#30236](https://github.com/element-hq/element-web/pull/30236)). Contributed by @Half-Shot.
+* Allow Element Call to learn the room name ([#30213](https://github.com/element-hq/element-web/pull/30213)). Contributed by @robintown.
+
+## 🐛 Bug Fixes
+
+* [Backport staging] Fix missing image download button ([#30322](https://github.com/element-hq/element-web/pull/30322)). Contributed by @RiotRobot.
+* Fix transparent verification checkmark in dark mode ([#30235](https://github.com/element-hq/element-web/pull/30235)). Contributed by @Banbuii.
+* Fix logic in DeviceListener ([#30230](https://github.com/element-hq/element-web/pull/30230)). Contributed by @uhoreg.
+* Disable file drag-and-drop if insufficient permissions ([#30186](https://github.com/element-hq/element-web/pull/30186)). Contributed by @t3chguy.
+
+
+Changes in [1.11.105](https://github.com/element-hq/element-web/releases/tag/v1.11.105) (2025-07-01)
+====================================================================================================
+## ✨ Features
+
+* New room list: add context menu to room list item ([#29952](https://github.com/element-hq/element-web/pull/29952)). Contributed by @florianduros.
+* Support for custom message components via Module API ([#30074](https://github.com/element-hq/element-web/pull/30074)). Contributed by @Half-Shot.
+* Prompt users to set up recovery ([#30075](https://github.com/element-hq/element-web/pull/30075)). Contributed by @uhoreg.
+* Update `IconButton` colors ([#30124](https://github.com/element-hq/element-web/pull/30124)). Contributed by @florianduros.
+* New room list: filter list can be collapsed ([#29992](https://github.com/element-hq/element-web/pull/29992)). Contributed by @florianduros.
+* Show `EmptyRoomListView` when low priority filter matches zero rooms ([#30122](https://github.com/element-hq/element-web/pull/30122)). Contributed by @MidhunSureshR.
+
+## 🐛 Bug Fixes
+
+* Fix untranslatable string "People" in notifications beta ([#30165](https://github.com/element-hq/element-web/pull/30165)). Contributed by @t3chguy.
+* Force verification even after logging in via delegate ([#30141](https://github.com/element-hq/element-web/pull/30141)). Contributed by @andybalaam.
+* Hide add integrations button based on UIComponent.AddIntegrations ([#30140](https://github.com/element-hq/element-web/pull/30140)). Contributed by @t3chguy.
+* Use nav for new room list and label sections ([#30134](https://github.com/element-hq/element-web/pull/30134)). Contributed by @dbkr.
+* Spacestore should emit event after rebuilding home space ([#30132](https://github.com/element-hq/element-web/pull/30132)). Contributed by @MidhunSureshR.
+* Handle m.room.pinned\_events being invalid ([#30129](https://github.com/element-hq/element-web/pull/30129)). Contributed by @t3chguy.
+
+
+Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
+====================================================================================================
+## ✨ Features
+
+* Update the mobile\_guide page to the new design. ([#30006](https://github.com/element-hq/element-web/pull/30006)). Contributed by @pixlwave.
+* Provide a devtool for manually verifying other devices ([#30094](https://github.com/element-hq/element-web/pull/30094)). Contributed by @andybalaam.
+* Implement MSC4155: Invite filtering ([#29603](https://github.com/element-hq/element-web/pull/29603)). Contributed by @Half-Shot.
+* Add low priority avatar decoration to room tile ([#30065](https://github.com/element-hq/element-web/pull/30065)). Contributed by @MidhunSureshR.
+* Add ability to prevent window content being captured by other apps (Desktop) ([#30098](https://github.com/element-hq/element-web/pull/30098)). Contributed by @t3chguy.
+* New room list: move message preview in user settings ([#30023](https://github.com/element-hq/element-web/pull/30023)). Contributed by @florianduros.
+* New room list: change room options icon ([#30029](https://github.com/element-hq/element-web/pull/30029)). Contributed by @florianduros.
+* RoomListStore: Sort low priority rooms to the bottom of the list ([#30070](https://github.com/element-hq/element-web/pull/30070)). Contributed by @MidhunSureshR.
+* Add low priority filter pill to the room list UI ([#30060](https://github.com/element-hq/element-web/pull/30060)). Contributed by @MidhunSureshR.
+* New room list: remove color gradient in space panel ([#29721](https://github.com/element-hq/element-web/pull/29721)). Contributed by @florianduros.
+* /share?msg=foo endpoint using forward message dialog ([#29874](https://github.com/element-hq/element-web/pull/29874)). Contributed by @ara4n.
+
+## 🐛 Bug Fixes
+
+* Do not send empty auth when setting up cross-signing keys ([#29914](https://github.com/element-hq/element-web/pull/29914)). Contributed by @gnieto.
+* Settings: flip local video feed by default ([#29501](https://github.com/element-hq/element-web/pull/29501)). Contributed by @jbtrystram.
+* AccessSecretStorageDialog: various fixes ([#30093](https://github.com/element-hq/element-web/pull/30093)). Contributed by @richvdh.
+* AccessSecretStorageDialog: fix inability to enter recovery key ([#30090](https://github.com/element-hq/element-web/pull/30090)). Contributed by @richvdh.
+* Fix failure to upload thumbnail causing image to send as file ([#30086](https://github.com/element-hq/element-web/pull/30086)). Contributed by @t3chguy.
+* Low priority menu item should be a toggle ([#30071](https://github.com/element-hq/element-web/pull/30071)). Contributed by @MidhunSureshR.
+* Add sanity checks to prevent users from ignoring themselves ([#30079](https://github.com/element-hq/element-web/pull/30079)). Contributed by @MidhunSureshR.
+* Fix issue with duplicate images ([#30073](https://github.com/element-hq/element-web/pull/30073)). Contributed by @fatlewis.
+* Handle errors returned from Seshat ([#30083](https://github.com/element-hq/element-web/pull/30083)). Contributed by @richvdh.
+
+
Changes in [1.11.103](https://github.com/element-hq/element-web/releases/tag/v1.11.103) (2025-06-10)
====================================================================================================
## 🐛 Bug Fixes
diff --git a/Dockerfile b/Dockerfile
index ceb5fd34b9..63dc31b4f3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
-# syntax=docker.io/docker/dockerfile:1.16-labs@sha256:bb5e2b225985193779991f3256d1901a0b3e6a0b284c7bffa0972064f4a6d458
+# syntax=docker.io/docker/dockerfile:1.17-labs@sha256:9187104f31e3a002a8a6a3209ea1f937fb7486c093cbbde1e14b0fa0d7e4f1b5
# Builder
-FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:f16d8e8af67bb6361231e932b8b3e7afa040cbfed181719a450b02c3821b26c1 AS builder
+FROM --platform=$BUILDPLATFORM node:22-bullseye@sha256:125996cb2451482467fc2aa4d7653075894b08e9b7711bcd761044ca270a083e AS builder
# Support custom branch of the js-sdk. This also helps us build images of element-web develop.
ARG USE_CUSTOM_SDKS=false
@@ -19,7 +19,7 @@ RUN /src/scripts/docker-package.sh
RUN cp /src/config.sample.json /src/webapp/config.json
# App
-FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:66e34aa81c2faf290ea4e4c28a490f2b35a07478265a2d5994c8637506045eee
+FROM nginxinc/nginx-unprivileged:alpine-slim@sha256:ef0100e39ffe377a42ad99e1f644b78097a84f1ac60a90eac3b888196b2eeb00
# Need root user to install packages & manipulate the usr directory
USER root
diff --git a/code_style.md b/code_style.md
index 9f0501ccd8..2481c22e1e 100644
--- a/code_style.md
+++ b/code_style.md
@@ -127,7 +127,6 @@ Unless otherwise specified, the following applies to all code:
2. "Conflicted" typically refers to a getter which wants the same name as the underlying variable.
20. Prefer readonly members over getters backed by a variable, unless an internal setter is required.
21. Prefer Interfaces for object definitions, and types for parameter-value-only declarations.
-
1. Note that an explicit type is optional if not expected to be used outside of the function call,
unlike in this example:
@@ -161,7 +160,6 @@ Unless otherwise specified, the following applies to all code:
28. Export only what can be reused.
29. Prefer a type like `Optional` (`type Optional = T | null | undefined`) instead
of truly optional parameters.
-
1. A notable exception is when the likelihood of a bug is minimal, such as when a function
takes an argument that is more often not required than required. An example where the
`?` operator is inappropriate is when taking a room ID: typically the caller should
@@ -260,7 +258,6 @@ Inheriting all the rules of TypeScript, the following additionally apply:
12. Interdependence between stores should be kept to a minimum. Break functions and constants out to utilities
if at all possible.
13. A component should only use CSS class names in line with the component name.
-
1. When knowingly using a class name from another component, document it with a [comment](#comments).
14. Curly braces within JSX should be padded with a space, however properties on those components should not.
@@ -388,7 +385,6 @@ Note: We use PostCSS + some plugins to process our styles. It looks like SCSS, b
properties should be clearly documented.
4. Inside a function, there is no need to comment every line, but consider:
-
- before a particular multiline section of code within the function, give an overview of what it does,
to make it easier for a reader to follow the flow through the function as a whole.
- if it is anything less than obvious, explain _why_ we are doing a particular operation, with particular emphasis
diff --git a/config.sample.json b/config.sample.json
index 5ddc34b3fc..3bc875cb7e 100644
--- a/config.sample.json
+++ b/config.sample.json
@@ -20,8 +20,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
- "https://scalar-staging.vector.im/api",
- "https://scalar-staging.riot.im/scalar/api"
+ "https://scalar-staging.vector.im/api"
],
"default_widget_container_height": 280,
"default_country_code": "GB",
diff --git a/declaration.d.ts b/declaration.d.ts
new file mode 100644
index 0000000000..928c567c31
--- /dev/null
+++ b/declaration.d.ts
@@ -0,0 +1,8 @@
+/*
+ * 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.
+ */
+
+declare module "*.module.css";
diff --git a/developer_guide.md b/developer_guide.md
index fa4bb9a239..2185a43a63 100644
--- a/developer_guide.md
+++ b/developer_guide.md
@@ -109,7 +109,7 @@ yarn test
### End-to-End tests
-See [matrix-react-sdk](https://github.com/matrix-org/matrix-react-sdk/#end-to-end-tests) for how to run the end-to-end tests.
+See [`docs/playwright.md`](./docs/playwright.md) for how to run the end-to-end tests.
## General github guidelines
diff --git a/docs/config.md b/docs/config.md
index f1ced14fd2..2bc36e206f 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -130,32 +130,37 @@ complete re-branding/private labeling, a more personalised experience can be ach
6. `mobile_builds`: Optional. Like `desktop_builds`, except for the mobile apps. Also described in more detail down below.
7. `mobile_guide_toast`: When `true` (default), users accessing the Element Web instance from a mobile device will be prompted to
download the app instead.
-8. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
+8. `mobile_guide_app_variant`: Optional. The mobile app that the user is prompted to download from the `/mobile_guide` page. When omitted
+ the mobile guide will be configured for the new Element X apps. Allowed values are as follows:
+ 1. `element`: Element X Android/iOS.
+ 2. `element-classic`: Element Classic Android/iOS.
+ 3. `element-pro`: Element Pro Android/iOS.
+9. `update_base_url`: For the desktop app only, the URL where to acquire update packages. If specified, must be a path to a directory
containing `macos` and `win32` directories, with the update packages within. Defaults to `https://packages.element.io/desktop/update/`
in production.
-9. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
- This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
- at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
- configuration found in the well-known location is used instead.
-10. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
-11. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
+10. `map_style_url`: Map tile server style URL for location sharing. e.g. `https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY_GOES_HERE`
+ This setting is ignored if your homeserver provides `/.well-known/matrix/client` in its well-known location, and the JSON file
+ at that location has a key `m.tile_server` (or the unstable version `org.matrix.msc3488.tile_server`). In this case, the
+ configuration found in the well-known location is used instead.
+11. `welcome_user_id`: **DEPRECATED** An optional user ID to start a DM with after creating an account. Defaults to nothing (no DM created).
+12. `custom_translations_url`: An optional URL to allow overriding of translatable strings. The JSON file must be in a format of
`{"affected|translation|key": {"languageCode": "new string"}}`. See https://github.com/matrix-org/matrix-react-sdk/pull/7886 for details.
-12. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
-13. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
-14. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
+13. `branding`: Options for configuring various assets used within the app. Described in more detail down below.
+14. `embedded_pages`: Further optional URLs for various assets used within the app. Described in more detail down below.
+15. `disable_3pid_login`: When `false` (default), **enables** the options to log in with email address or phone number. Set to
`true` to hide these options.
-15. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
+16. `disable_login_language_selector`: When `false` (default), **enables** the language selector on the login pages. Set to `true`
to hide this dropdown.
-16. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
+17. `disable_guests`: When `false` (default), **enable** guest-related functionality (peeking/previewing rooms, etc) for unregistered
users. Set to `true` to disable this functionality.
-17. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
+18. `user_notice`: Optional notice to show to the user, e.g. for sunsetting a deployment and pushing users to move in their own time.
Takes a configuration object as below:
1. `title`: Required. Title to show at the top of the notice.
2. `description`: Required. The description to use for the notice.
3. `show_once`: Optional. If true then the notice will only be shown once per device.
-18. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
-19. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
-20. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
+19. `help_url`: The URL to point users to for help with the app, defaults to `https://element.io/help`.
+20. `help_encryption_url`: The URL to point users to for help with encryption, defaults to `https://element.io/help#encryption`.
+21. `force_verification`: If true, users must verify new logins (eg. with another device / their recovery key)
### `desktop_builds` and `mobile_builds`
@@ -445,8 +450,7 @@ If you would like to use Scalar, the integration manager maintained by Element,
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
- "https://scalar-staging.vector.im/api",
- "https://scalar-staging.riot.im/scalar/api"
+ "https://scalar-staging.vector.im/api"
]
}
```
diff --git a/docs/kubernetes.md b/docs/kubernetes.md
index cae8526e9c..23c3fde611 100644
--- a/docs/kubernetes.md
+++ b/docs/kubernetes.md
@@ -55,8 +55,7 @@ Then you can deploy it to your cluster with something like `kubectl apply -f my-
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
- "https://scalar-staging.vector.im/api",
- "https://scalar-staging.riot.im/scalar/api"
+ "https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"defaultCountryCode": "GB",
diff --git a/element.io/app/config.json b/element.io/app/config.json
index 771df35091..4324ffc7fb 100644
--- a/element.io/app/config.json
+++ b/element.io/app/config.json
@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
- "https://scalar-staging.vector.im/api",
- "https://scalar-staging.riot.im/scalar/api"
+ "https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",
diff --git a/element.io/develop/config.json b/element.io/develop/config.json
index aaee51afd0..ce4a8a0407 100644
--- a/element.io/develop/config.json
+++ b/element.io/develop/config.json
@@ -15,8 +15,7 @@
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
- "https://scalar-staging.vector.im/api",
- "https://scalar-staging.riot.im/scalar/api"
+ "https://scalar-staging.vector.im/api"
],
"bug_report_endpoint_url": "https://element.io/bugreports/submit",
"uisi_autorageshake_app": "element-auto-uisi",
diff --git a/jest.config.ts b/jest.config.ts
index ad31f2fecc..f40ac487a2 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -17,7 +17,7 @@ const config: Config = {
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
customExportConditions: ["browser", "node"],
},
- testMatch: ["/test/**/*-test.[tj]s?(x)"],
+ testMatch: ["/test/**/*-test.[tj]s?(x)", "/src/shared-components/**/*.test.[t]s?(x)"],
globalSetup: "/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["/test/setupTests.ts"],
diff --git a/package.json b/package.json
index 4f85142a74..669f898848 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "element-web",
- "version": "1.11.103",
+ "version": "1.11.106",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -65,23 +65,27 @@
"coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
- "postinstall": "patch-package"
+ "postinstall": "patch-package",
+ "storybook": "storybook dev -p 6007",
+ "build-storybook": "storybook build",
+ "test:storybook": "test-storybook --url http://localhost:6007/",
+ "test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
},
"resolutions": {
"**/pretty-format/react-is": "19.1.0",
- "@playwright/test": "1.52.0",
- "@types/react": "19.1.6",
+ "@playwright/test": "1.53.2",
+ "@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
- "oidc-client-ts": "3.2.1",
+ "oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0",
- "caniuse-lite": "1.0.30001721",
+ "caniuse-lite": "1.0.30001724",
"testcontainers": "^11.0.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
"dependencies": {
"@babel/runtime": "^7.12.5",
- "@element-hq/element-web-module-api": "1.0.0",
+ "@element-hq/element-web-module-api": "1.3.0",
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
@@ -92,9 +96,9 @@
"@sentry/browser": "^9.0.0",
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
- "@vector-im/compound-design-tokens": "^4.0.0",
- "@vector-im/compound-web": "^8.0.0",
- "@vector-im/matrix-wysiwyg": "2.38.3",
+ "@vector-im/compound-design-tokens": "^5.0.0",
+ "@vector-im/compound-web": "^8.1.2",
+ "@vector-im/matrix-wysiwyg": "2.38.4",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
@@ -138,7 +142,7 @@
"opus-recorder": "^8.0.3",
"pako": "^2.0.3",
"png-chunks-extract": "^1.0.0",
- "posthog-js": "1.249.4",
+ "posthog-js": "1.256.2",
"qrcode": "1.5.4",
"re-resizable": "6.11.2",
"react": "^19.0.0",
@@ -181,14 +185,19 @@
"@babel/preset-typescript": "^7.12.7",
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
- "@element-hq/element-call-embedded": "0.12.2",
- "@element-hq/element-web-playwright-common": "^1.1.5",
+ "@element-hq/element-call-embedded": "0.13.1",
+ "@element-hq/element-web-playwright-common": "^1.4.2",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0",
- "@stylistic/eslint-plugin": "^4.0.0",
+ "@storybook/addon-designs": "^10.0.1",
+ "@storybook/addon-docs": "^9.0.12",
+ "@storybook/icons": "^1.4.0",
+ "@storybook/react-vite": "^9.0.15",
+ "@storybook/test-runner": "^0.23.0",
+ "@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.4.8",
@@ -213,7 +222,7 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
- "@types/react": "19.1.6",
+ "@types/react": "19.1.8",
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "19.1.6",
"@types/react-transition-group": "^4.4.0",
@@ -232,10 +241,10 @@
"concurrently": "^9.0.0",
"copy-webpack-plugin": "^13.0.0",
"core-js": "^3.38.1",
- "cronstrue": "^2.41.0",
+ "cronstrue": "^3.0.0",
"css-loader": "^7.0.0",
"css-minimizer-webpack-plugin": "^7.0.0",
- "dotenv": "^16.0.2",
+ "dotenv": "^17.0.0",
"eslint": "8.57.1",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
@@ -247,18 +256,19 @@
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0",
"fake-indexeddb": "^6.0.0",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
"file-loader": "^6.0.0",
- "glob": "^11.0.0",
"html-webpack-plugin": "^5.5.3",
"husky": "^9.0.0",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0",
+ "jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0",
@@ -276,17 +286,18 @@
"postcss-hexrgba": "2.1.0",
"postcss-import": "16.1.0",
"postcss-loader": "8.1.1",
- "postcss-mixins": "^11.0.0",
+ "postcss-mixins": "^12.0.0",
"postcss-nested": "^7.0.0",
"postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1",
- "prettier": "3.5.3",
+ "prettier": "3.6.2",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.0",
"semver": "^7.5.2",
"source-map-loader": "^5.0.0",
+ "storybook": "^9.0.12",
"stylelint": "^16.13.0",
"stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0",
@@ -296,6 +307,8 @@
"ts-node": "^10.9.1",
"typescript": "5.8.3",
"util": "^0.12.5",
+ "vite": "^7.0.1",
+ "vite-plugin-node-polyfills": "^0.24.0",
"web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0",
diff --git a/patches/@types+mdx+2.0.13.patch b/patches/@types+mdx+2.0.13.patch
new file mode 100644
index 0000000000..d3d02974f7
--- /dev/null
+++ b/patches/@types+mdx+2.0.13.patch
@@ -0,0 +1,46 @@
+diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
+index 498bb69..4e89216 100644
+--- a/node_modules/@types/mdx/types.d.ts
++++ b/node_modules/@types/mdx/types.d.ts
+@@ -5,7 +5,7 @@
+ */
+ // @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
+ // defined or not.
+-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
++type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
+
+ /**
+ * This matches any function component types that ar part of `ElementType`.
+@@ -20,12 +20,12 @@ type ClassElementType = Extract) =>
+ /**
+ * A valid JSX string component.
+ */
+-type StringComponent = Extract;
++type StringComponent = Extract;
+
+ /**
+ * A JSX element returned by MDX content.
+ */
+-export type Element = JSX.Element;
++export type Element = React.JSX.Element;
+
+ /**
+ * A valid JSX function component.
+@@ -44,7 +44,7 @@ type FunctionComponent = ElementType extends never
+ */
+ type ClassComponent = ElementType extends never
+ // If JSX.ElementType isn’t defined, the valid return type is a constructor that returns JSX.ElementClass
+- ? new(props: Props) => JSX.ElementClass
++ ? new(props: Props) => React.JSX.ElementClass
+ : ClassElementType extends never
+ // If JSX.ElementType is defined, but doesn’t allow constructors, function components are disallowed.
+ ? never
+@@ -70,7 +70,7 @@ interface NestedMDXComponents {
+ export type MDXComponents =
+ & NestedMDXComponents
+ & {
+- [Key in StringComponent]?: Component;
++ [Key in StringComponent]?: Component;
+ }
+ & {
+ /**
diff --git a/patches/@types+react+19.1.4.patch b/patches/@types+react+19.1.4.patch
deleted file mode 100644
index ceba85b000..0000000000
--- a/patches/@types+react+19.1.4.patch
+++ /dev/null
@@ -1,31 +0,0 @@
-diff --git a/node_modules/@types/react/index.d.ts b/node_modules/@types/react/index.d.ts
-index d3318dc..c2b2c77 100644
---- a/node_modules/@types/react/index.d.ts
-+++ b/node_modules/@types/react/index.d.ts
-@@ -134,7 +134,7 @@ declare namespace React {
- props: P,
- ) => ReactNode | Promise)
- // constructor signature must match React.Component
-- | (new(props: P) => Component);
-+ | (new(props: P, context?: any) => Component);
-
- /**
- * Created by {@link createRef}, or {@link useRef} when passed `null`.
-@@ -945,7 +945,7 @@ declare namespace React {
- context: unknown;
-
- // Keep in sync with constructor signature of JSXElementConstructor and ComponentClass.
-- constructor(props: P);
-+ constructor(props: P, context?: unknown);
-
- // We MUST keep setState() as a unified signature because it allows proper checking of the method return type.
- // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257
-@@ -1117,7 +1117,7 @@ declare namespace React {
- */
- interface ComponentClass
extends StaticLifecycle
{
- // constructor signature must match React.Component
-- new(props: P): Component
;
-+ new(props: P, context?: any): Component
;
- /**
- * Ignored by React.
- * @deprecated Only kept in types for backwards compatibility. Will be removed in a future major release.
diff --git a/playwright/e2e/crypto/crypto.spec.ts b/playwright/e2e/crypto/crypto.spec.ts
index bad9072f0c..d69af46a72 100644
--- a/playwright/e2e/crypto/crypto.spec.ts
+++ b/playwright/e2e/crypto/crypto.spec.ts
@@ -168,6 +168,7 @@ test.describe("Cryptography", function () {
// Take a snapshot of RoomSummaryCard with a verified E2EE icon
await expect(page.locator(".mx_RightPanel")).toMatchScreenshot("RoomSummaryCard-with-verified-e2ee.png");
+ await expect(page.locator(".mx_MessageComposer_e2eIcon")).toMatchScreenshot("composer-e2e-icon.png");
},
);
diff --git a/playwright/e2e/crypto/device-verification.spec.ts b/playwright/e2e/crypto/device-verification.spec.ts
index 5ae53f649e..f325754326 100644
--- a/playwright/e2e/crypto/device-verification.spec.ts
+++ b/playwright/e2e/crypto/device-verification.spec.ts
@@ -48,31 +48,38 @@ test.describe("Device verification", { tag: "@no-webkit" }, () => {
return promiseVerificationRequest;
}
- test("Verify device with SAS during login", async ({ page, app, credentials, homeserver }) => {
- await logIntoElement(page, credentials);
+ test(
+ "Verify device with SAS during login",
+ { tag: "@screenshot" },
+ async ({ page, app, credentials, homeserver }) => {
+ await logIntoElement(page, credentials);
- // Launch the verification request between alice and the bot
- const verificationRequest = await initiateAliceVerificationRequest(page);
+ // Launch the verification request between alice and the bot
+ const verificationRequest = await initiateAliceVerificationRequest(page);
- // Handle emoji SAS verification
- const infoDialog = page.locator(".mx_InfoDialog");
- // the bot chooses to do an emoji verification
- const verifier = await verificationRequest.evaluateHandle((request) => request.startVerification("m.sas.v1"));
+ // Handle emoji SAS verification
+ const infoDialog = page.locator(".mx_InfoDialog");
+ // the bot chooses to do an emoji verification
+ const verifier = await verificationRequest.evaluateHandle((request) =>
+ request.startVerification("m.sas.v1"),
+ );
- // Handle emoji request and check that emojis are matching
- await doTwoWaySasVerification(page, verifier);
+ // Handle emoji request and check that emojis are matching
+ await doTwoWaySasVerification(page, verifier);
- await infoDialog.getByRole("button", { name: "They match" }).click();
- await infoDialog.getByRole("button", { name: "Got it" }).click();
+ await infoDialog.getByRole("button", { name: "They match" }).click();
+ await expect(page.locator(".mx_E2EIcon_verified")).toMatchScreenshot("device-verified-e2eIcon.png");
+ await infoDialog.getByRole("button", { name: "Got it" }).click();
- // Check that our device is now cross-signed
- await checkDeviceIsCrossSigned(app);
+ // Check that our device is now cross-signed
+ await checkDeviceIsCrossSigned(app);
- // Check that the current device is connected to key backup
- // For now we don't check that the backup key is in cache because it's a bit flaky,
- // as we need to wait for the secret gossiping to happen.
- await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
- });
+ // Check that the current device is connected to key backup
+ // For now we don't check that the backup key is in cache because it's a bit flaky,
+ // as we need to wait for the secret gossiping to happen.
+ await checkDeviceIsConnectedKeyBackup(app, expectedBackupVersion, false);
+ },
+ );
// Regression test for https://github.com/element-hq/element-web/issues/29110
test("No toast after verification, even if the secrets take a while to arrive", async ({ page, credentials }) => {
diff --git a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts
index 26d27cc01c..0e92e734c5 100644
--- a/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts
+++ b/playwright/e2e/left-panel/room-list-panel/room-list-filter-sort.spec.ts
@@ -229,13 +229,13 @@ test.describe("Room list filters and sort", () => {
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(4);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(2);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(2);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
@@ -243,21 +243,21 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(5);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(5);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(1);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(1);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
- expect(await roomList.locator("role=gridcell").count()).toBe(1);
+ await expect.poll(() => roomList.locator("role=gridcell").count()).toBe(1);
await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
diff --git a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
index 0567b8a162..a91a0c38d0 100644
--- a/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
+++ b/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts
@@ -49,8 +49,7 @@ test.describe("Room list", () => {
// Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list
- await page.mouse.wheel(0, 1000);
- await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
+ await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
await expect(roomListView).toMatchScreenshot("room-list-scrolled.png");
});
@@ -60,6 +59,12 @@ test.describe("Room list", () => {
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
});
+ test("should open the context menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
+ const roomListView = getRoomList(page);
+ await roomListView.getByRole("gridcell", { name: "Open room room29" }).click({ button: "right" });
+ await expect(page.getByRole("menu", { name: "More Options" })).toBeVisible();
+ });
+
test("should open the more options menu", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomListView = getRoomList(page);
const roomItem = roomListView.getByRole("gridcell", { name: "Open room room29" });
@@ -109,10 +114,13 @@ test.describe("Room list", () => {
// It should make the room muted
await page.getByRole("menuitem", { name: "Mute room" }).click();
+ await expect(roomItem.getByTestId("notification-decoration")).not.toBeVisible();
+
// Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
+
// Scroll to the end of the room list
- await page.mouse.wheel(0, 1000);
+ await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
// The room decoration should have the muted icon
await expect(roomItem.getByTestId("notification-decoration")).toBeVisible();
@@ -133,8 +141,9 @@ test.describe("Room list", () => {
// Put focus on the room list
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Scroll to the end of the room list
- await page.mouse.wheel(0, 1000);
+ await app.scrollListToBottom(page.locator(".mx_RoomList_List"));
+ await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" });
@@ -223,17 +232,17 @@ test.describe("Room list", () => {
await expect(notificationButton).toBeFocused();
// Open the menu
- await notificationButton.click();
+ await page.keyboard.press("Enter");
// Wait for the menu to be open
await expect(page.getByRole("menuitem", { name: "Match default settings" })).toHaveAttribute(
"aria-selected",
"true",
);
- // Close the menu
+ await page.keyboard.press("ArrowDown");
await page.keyboard.press("Escape");
- // Focus should be back on the room list item
- await expect(room29).toBeFocused();
+ // Focus should be back on the notification button
+ await expect(notificationButton).toBeFocused();
});
});
});
diff --git a/playwright/e2e/mobile-guide/mobile-guide.spec.ts b/playwright/e2e/mobile-guide/mobile-guide.spec.ts
new file mode 100644
index 0000000000..ff2ad69bf8
--- /dev/null
+++ b/playwright/e2e/mobile-guide/mobile-guide.spec.ts
@@ -0,0 +1,36 @@
+/*
+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 { test, expect } from "../../element-web-test";
+import { MobileAppVariant } from "../../../src/vector/mobile_guide/mobile-apps";
+
+const variants = [MobileAppVariant.Classic, MobileAppVariant.X, MobileAppVariant.Pro];
+
+test.describe("Mobile Guide Screenshots", { tag: "@screenshot" }, () => {
+ for (const variant of variants) {
+ test.describe(`for variant ${variant}`, () => {
+ test.use({
+ config: {
+ default_server_config: {
+ "m.homeserver": {
+ base_url: "https://matrix.server.invalid",
+ server_name: "server.invalid",
+ },
+ },
+ mobile_guide_app_variant: variant,
+ },
+ viewport: { width: 390, height: 844 }, // iPhone 16e
+ });
+
+ test("should match the mobile_guide screenshot", async ({ page, axe }) => {
+ await page.goto("/mobile_guide/");
+ await expect(page).toMatchScreenshot(`mobile-guide-${variant}.png`);
+ await expect(axe).toHaveNoViolations();
+ });
+ });
+ }
+});
diff --git a/playwright/e2e/modules/custom-component.spec.ts b/playwright/e2e/modules/custom-component.spec.ts
new file mode 100644
index 0000000000..f263ac8b9c
--- /dev/null
+++ b/playwright/e2e/modules/custom-component.spec.ts
@@ -0,0 +1,160 @@
+/*
+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 { type Page } from "@playwright/test";
+import fs from "node:fs";
+
+import { test, expect } from "../../element-web-test";
+
+const screenshotOptions = (page: Page) => ({
+ mask: [page.locator(".mx_MessageTimestamp")],
+ // Hide the jump to bottom button in the timeline to avoid flakiness
+ // Exclude timestamp and read marker from snapshot
+ css: `
+ .mx_JumpToBottomButton {
+ display: none !important;
+ }
+ .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
+ display: none !important;
+ }
+ `,
+});
+
+const IMAGE_FILE = fs.readFileSync("playwright/sample-files/element.png");
+
+test.describe("Custom Component API", () => {
+ test.use({
+ displayName: "Manny",
+ config: {
+ modules: ["/modules/custom-component-module.js"],
+ },
+ page: async ({ page }, use) => {
+ await page.route("/modules/custom-component-module.js", async (route) => {
+ await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
+ });
+ await use(page);
+ },
+ room: async ({ page, app, user, bot }, use) => {
+ const roomId = await app.client.createRoom({ name: "TestRoom" });
+ await use({ roomId });
+ },
+ });
+ test.describe("basic functionality", () => {
+ test(
+ "should replace the render method of a textual event",
+ { tag: "@screenshot" },
+ async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Simple message");
+ await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "custom-component-tile.png",
+ screenshotOptions(page),
+ );
+ },
+ );
+ test(
+ "should fall through if one module does not render a component",
+ { tag: "@screenshot" },
+ async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Fall through here");
+ await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "custom-component-tile-fall-through.png",
+ screenshotOptions(page),
+ );
+ },
+ );
+ test(
+ "should render the original content of a textual event conditionally",
+ { tag: "@screenshot" },
+ async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Do not replace me");
+ await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "custom-component-tile-original.png",
+ screenshotOptions(page),
+ );
+ },
+ );
+ test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Do not show edits");
+ await page.getByText("Do not show edits").hover();
+ await expect(
+ await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
+ ).not.toBeVisible();
+ });
+ test("should disallow downloading media when the allowDownloading hint is set to false", async ({
+ page,
+ room,
+ app,
+ }) => {
+ await app.viewRoomById(room.roomId);
+ await app.viewRoomById(room.roomId);
+ const upload = await app.client.uploadContent(IMAGE_FILE, { name: "bad.png", type: "image/png" });
+ await app.client.sendEvent(room.roomId, null, "m.room.message", {
+ msgtype: "m.image",
+ body: "bad.png",
+ url: upload.content_uri,
+ });
+
+ await app.timeline.scrollToBottom();
+ const imgTile = page.locator(".mx_MImageBody").first();
+ await expect(imgTile).toBeVisible();
+ await imgTile.hover();
+ await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
+ await imgTile.click();
+ await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
+ });
+ test("should allow downloading media when the allowDownloading hint is set to true", async ({
+ page,
+ room,
+ app,
+ }) => {
+ await app.viewRoomById(room.roomId);
+ await app.viewRoomById(room.roomId);
+ const upload = await app.client.uploadContent(IMAGE_FILE, { name: "good.png", type: "image/png" });
+ await app.client.sendEvent(room.roomId, null, "m.room.message", {
+ msgtype: "m.image",
+ body: "good.png",
+ url: upload.content_uri,
+ });
+
+ await app.timeline.scrollToBottom();
+ const imgTile = page.locator(".mx_MImageBody").first();
+ await expect(imgTile).toBeVisible();
+ await imgTile.hover();
+ await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
+ await imgTile.click();
+ await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
+ });
+ test(
+ "should render the next registered component if the filter function throws",
+ { tag: "@screenshot" },
+ async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Crash the filter!");
+ await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "custom-component-crash-handle-filter.png",
+ screenshotOptions(page),
+ );
+ },
+ );
+ test(
+ "should render original component if the render function throws",
+ { tag: "@screenshot" },
+ async ({ page, room, app }) => {
+ await app.viewRoomById(room.roomId);
+ await app.client.sendMessage(room.roomId, "Crash the renderer!");
+ await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
+ "custom-component-crash-handle-renderer.png",
+ screenshotOptions(page),
+ );
+ },
+ );
+ });
+});
diff --git a/playwright/e2e/oidc/index.ts b/playwright/e2e/oidc/index.ts
index 02de6e2f03..602a4368c7 100644
--- a/playwright/e2e/oidc/index.ts
+++ b/playwright/e2e/oidc/index.ts
@@ -11,6 +11,9 @@ import { type Page } from "@playwright/test";
import { expect } from "../../element-web-test";
+/**
+ * Click through registering a new user in the MAS UI.
+ */
export async function registerAccountMas(
page: Page,
mailpit: MailpitClient,
@@ -42,3 +45,17 @@ export async function registerAccountMas(
await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
}
+
+/**
+ * Click through entering username and password into the MAS login prompt.
+ */
+export async function logInAccountMas(page: Page, username: string, password: string): Promise {
+ await expect(page.getByText("Please sign in to continue:")).toBeVisible();
+
+ await page.getByRole("textbox", { name: "Username" }).fill(username);
+ await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ await expect(page.getByText("Allow access to your account?")).toBeVisible();
+ 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 a6fbf231ce..8b49942dd3 100644
--- a/playwright/e2e/oidc/oidc-native.spec.ts
+++ b/playwright/e2e/oidc/oidc-native.spec.ts
@@ -6,8 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
+import { type Config, CONFIG_JSON } from "@element-hq/element-web-playwright-common";
+import { type Browser, type Page } from "@playwright/test";
+import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
+
import { test, expect } from "../../element-web-test.ts";
-import { registerAccountMas } from ".";
+import { logInAccountMas, registerAccountMas } from ".";
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
@@ -77,7 +81,7 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
test(
"it should log out the user & wipe data when logging out via MAS",
{ tag: "@screenshot" },
- async ({ mas, page, mailpitClient }, testInfo) => {
+ async ({ mas, page, mailpitClient, homeserver }, testInfo) => {
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
@@ -91,14 +95,168 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
+ // Workaround for Synapse's 2 minute cache on MAS token validity
+ // (https://github.com/element-hq/synapse/pull/18231)
+ await homeserver.restart();
+
await page.goto("http://localhost:8080");
await expect(
page.getByText("For security, this session has been signed out. Please sign in again."),
).toBeVisible();
- await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
+ //await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
expect(localStorageKeys).toHaveLength(0);
},
);
+
+ test("can log in to an existing MAS account", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
+ // Register an account with MAS
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ const userId = `alice_${testInfo.testId}`;
+ await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
+ await expect(page.getByText("Welcome")).toBeVisible();
+
+ // Log out
+ await page.getByRole("button", { name: "User menu" }).click();
+ await expect(page.getByText(userId, { exact: true })).toBeVisible();
+
+ // Allow the outstanding requests queue to settle before logging out
+ await page.waitForTimeout(2000);
+ await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
+ await expect(page).toHaveURL(/\/#\/login$/);
+
+ // Log in again
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ // We should be in (we see an error because we have no recovery key).
+ await expect(page.getByText("Unable to verify this device")).toBeVisible();
+ });
+
+ test.describe("with force_verification on", () => {
+ test.use({
+ config: {
+ force_verification: true,
+ },
+ });
+
+ test("verify dialog cannot be dismissed", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
+ // Register an account with MAS
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ const userId = `alice_${testInfo.testId}`;
+ await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
+ await expect(page.getByText("Welcome")).toBeVisible();
+
+ // Log out
+ await page.getByRole("button", { name: "User menu" }).click();
+ await expect(page.getByText(userId, { exact: true })).toBeVisible();
+ await page.waitForTimeout(2000);
+ await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
+ await expect(page).toHaveURL(/\/#\/login$/);
+
+ // Log in again
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ // We should be being warned that we need to verify (but we can't)
+ await expect(page.getByText("Unable to verify this device")).toBeVisible();
+
+ // And there should be no way to close this prompt
+ await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
+ });
+
+ test(
+ "continues to show verification prompt after cancelling device verification",
+ { tag: "@screenshot" },
+ async ({ browser, config, homeserver, page, mailpitClient }, testInfo) => {
+ // Register an account with MAS
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ const userId = `alice_${testInfo.testId}`;
+ const password = "Pa$sW0rD!";
+ await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, password);
+ await expect(page.getByText("Welcome")).toBeVisible();
+
+ // Log in an additional account, and verify it.
+ //
+ // This means that when we log out and in again, we are offered
+ // to verify using another device.
+ const otherContext = await newContext(browser, config, homeserver);
+ const otherDevicePage = await otherContext.newPage();
+ await otherDevicePage.goto("/#/login");
+ await otherDevicePage.getByRole("button", { name: "Continue" }).click();
+ await logInAccountMas(otherDevicePage, userId, password);
+ await verifyUsingOtherDevice(otherDevicePage, page);
+ await otherDevicePage.close();
+
+ // Log out
+ await page.getByRole("button", { name: "User menu" }).click();
+ await expect(page.getByText(userId, { exact: true })).toBeVisible();
+ await page.waitForTimeout(2000);
+ await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
+ await expect(page).toHaveURL(/\/#\/login$/);
+
+ // Log in again
+ await page.goto("/#/login");
+ await page.getByRole("button", { name: "Continue" }).click();
+ await page.getByRole("button", { name: "Continue" }).click();
+
+ // We should be in, and not able to dismiss the verify dialog
+ await expect(page.getByText("Verify this device")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
+
+ // When we start verifying with another device
+ await page.getByRole("button", { name: "Verify with another device" }).click();
+
+ // And then cancel it
+ await page.getByRole("button", { name: "Close dialog" }).click();
+
+ // Then we should still be at the unskippable verify prompt
+ await expect(page.getByText("Verify this device")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
+ },
+ );
+ });
});
+
+/**
+ * Perform interactive emoji verification for a new device.
+ */
+async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
+ await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
+ await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
+ await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
+ await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();
+ await deviceToVerifyPage.getByRole("button", { name: "They match" }).click();
+ await alreadyVerifiedDevicePage.getByRole("button", { name: "Got it" }).click();
+ await deviceToVerifyPage.getByRole("button", { name: "Got it" }).click();
+}
+
+/**
+ * Create a new browser context which serves up the default config plus what you supplied, and sets m.homeserver to the
+ * supplied homeserver's URL.
+ */
+async function newContext(browser: Browser, config: Partial>, homeserver: StartedHomeserverContainer) {
+ const otherContext = await browser.newContext();
+ await otherContext.route(`http://localhost:8080/config.json*`, async (route) => {
+ const json = {
+ ...CONFIG_JSON,
+ ...config,
+ default_server_config: {
+ "m.homeserver": {
+ base_url: homeserver.baseUrl,
+ },
+ },
+ };
+ await route.fulfill({ json });
+ });
+ return otherContext;
+}
diff --git a/playwright/e2e/release-announcement/index.ts b/playwright/e2e/release-announcement/index.ts
index 3b6c2dd38a..2a2e704d74 100644
--- a/playwright/e2e/release-announcement/index.ts
+++ b/playwright/e2e/release-announcement/index.ts
@@ -42,7 +42,10 @@ export class Helpers {
*/
async assertReleaseAnnouncementIsVisible(name: string) {
await expect(this.getReleaseAnnouncement(name)).toBeVisible();
- await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, { showTooltips: true });
+ await expect(this.page).toMatchScreenshot(`release-announcement-${name}.png`, {
+ showTooltips: true,
+ hideJumpToBottomButton: true,
+ });
}
/**
diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts
index 8520533461..fee9f070d8 100644
--- a/playwright/element-web-test.ts
+++ b/playwright/element-web-test.ts
@@ -107,6 +107,7 @@ interface ExtendedToMatchScreenshotOptions extends ToMatchScreenshotOptions {
includeDialogBackground?: boolean;
showTooltips?: boolean;
timeout?: number;
+ hideJumpToBottomButton?: boolean;
}
type Expectations = {
@@ -165,6 +166,14 @@ export const expect = baseExpect.extend({
`;
}
+ if (options?.hideJumpToBottomButton) {
+ css += `
+ .mx_JumpToBottomButton {
+ display: none !important;
+ }
+ `;
+ }
+
if (options?.css) {
css += options.css;
}
diff --git a/playwright/pages/ElementAppPage.ts b/playwright/pages/ElementAppPage.ts
index afc814b3e1..abddc9c644 100644
--- a/playwright/pages/ElementAppPage.ts
+++ b/playwright/pages/ElementAppPage.ts
@@ -213,4 +213,26 @@ export class ElementAppPage {
.getByRole("button", { name: "Dismiss" })
.click();
}
+
+ /**
+ * Scroll an infinite list to the bottom.
+ * @param list The element to scroll
+ */
+ public async scrollListToBottom(list: Locator): Promise {
+ // First hover the mouse over the element that we want to scroll
+ await list.hover();
+
+ const needsScroll = async () => {
+ // From https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
+ const fullyScrolled = await list.evaluate(
+ (e) => Math.abs(e.scrollHeight - e.clientHeight - e.scrollTop) <= 1,
+ );
+ return !fullyScrolled;
+ };
+
+ // Scroll the element until we detect that it is fully scrolled
+ do {
+ await this.page.mouse.wheel(0, 1000);
+ } while (await needsScroll());
+ }
}
diff --git a/playwright/sample-files/custom-component-module.js b/playwright/sample-files/custom-component-module.js
new file mode 100644
index 0000000000..be2ab5928d
--- /dev/null
+++ b/playwright/sample-files/custom-component-module.js
@@ -0,0 +1,74 @@
+/*
+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.
+*/
+
+// Note: eslint-plugin-jsdoc doesn't like import types as parameters, so we
+// get around it with @typedef
+/**
+ * @typedef {import("@element-hq/element-web-module-api").Api} Api
+ */
+
+export default class CustomComponentModule {
+ static moduleApiVersion = "^1.2.0";
+ /**
+ * Basic module for testing.
+ * @param {Api} api API object
+ */
+ constructor(api) {
+ this.api = api;
+ this.api.customComponents.registerMessageRenderer(
+ (evt) => evt.content.body === "Do not show edits",
+ (_props, originalComponent) => {
+ return originalComponent();
+ },
+ { allowEditingEvent: false },
+ );
+ this.api.customComponents.registerMessageRenderer(
+ (evt) => evt.content.body === "Fall through here",
+ (props) => {
+ const body = props.mxEvent.content.body;
+ return `Fallthrough text for ${body}`;
+ },
+ );
+ this.api.customComponents.registerMessageRenderer(
+ (evt) => {
+ if (evt.content.body === "Crash the filter!") {
+ throw new Error("Fail test!");
+ }
+ return false;
+ },
+ () => {
+ return `Should not render!`;
+ },
+ );
+ this.api.customComponents.registerMessageRenderer(
+ (evt) => evt.content.body === "Crash the renderer!",
+ () => {
+ throw new Error("Fail test!");
+ },
+ );
+
+ this.api.customComponents.registerMessageRenderer(
+ (mxEvent) => mxEvent.type === "m.room.message" && mxEvent.content.msgtype === "m.image",
+ (_props, originalComponent) => {
+ return originalComponent();
+ },
+ { allowDownloadingMedia: async (mxEvent) => mxEvent.content.body !== "bad.png" },
+ );
+
+ // Order is specific here to avoid this overriding the other renderers
+ this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
+ const body = props.mxEvent.content.body;
+ if (body === "Do not replace me") {
+ return originalComponent();
+ } else if (body === "Fall through here") {
+ return null;
+ }
+ return `Custom text for ${body}`;
+ });
+ }
+ async load() {}
+}
diff --git a/playwright/shared-component-snapshots/event-textualevent--default-linux.png b/playwright/shared-component-snapshots/event-textualevent--default-linux.png
new file mode 100644
index 0000000000..16855e8448
Binary files /dev/null and b/playwright/shared-component-snapshots/event-textualevent--default-linux.png differ
diff --git a/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png
new file mode 100644
index 0000000000..05f5af8507
Binary files /dev/null and b/playwright/snapshots/crypto/crypto.spec.ts/composer-e2e-icon-linux.png differ
diff --git a/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png b/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png
new file mode 100644
index 0000000000..479a8c17bb
Binary files /dev/null and b/playwright/snapshots/crypto/device-verification.spec.ts/device-verified-e2eIcon-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png
index 35523b7db8..c1f4477d66 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-ltr-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png
index 9f31f518fc..59b9fc41e8 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-ltrdisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png
index 40a096409e..07f007545c 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/basic-message-rtl-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png
index 557613e7e6..e694dde7c6 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-ltr-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png
index 85a3c69c0e..8c2e2d153b 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-ltrdisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png
index bd26e84628..4bb04ba279 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/edited-message-rtl-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png
index 055ad23a81..c711b21a58 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-ltr-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png
index cfb905b689..b92c4691d3 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-ltr-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png
index 537f1dd2c4..c727b98745 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-ltrdisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png
index ea29e98a75..bd1baed9aa 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rich-rtl-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png
index 27357dc503..b55925218d 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-ltrdisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png
index d84780f530..cb18ce68c7 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/emote-rtl-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png
index b59d960f4f..e3d3e68c64 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-ltr-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png
index a3acc741f3..d6964445b1 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-ltrdisplayname-linux.png differ
diff --git a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png
index f6eaea241a..8830c22d92 100644
Binary files a/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png and b/playwright/snapshots/messages/messages.spec.ts/reply-message-trl-rtldisplayname-linux.png differ
diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png
new file mode 100644
index 0000000000..f091eeed74
Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-classic-linux.png differ
diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png
new file mode 100644
index 0000000000..9c32731ab7
Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-linux.png differ
diff --git a/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png
new file mode 100644
index 0000000000..ff1bb69a4f
Binary files /dev/null and b/playwright/snapshots/mobile-guide/mobile-guide.spec.ts/mobile-guide-element-pro-linux.png differ
diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png
new file mode 100644
index 0000000000..b144ca6a5e
Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-filter-linux.png differ
diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png
new file mode 100644
index 0000000000..b3fa5e0f57
Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-crash-handle-renderer-linux.png differ
diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png
new file mode 100644
index 0000000000..0fe98072a0
Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-fall-through-linux.png differ
diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png
new file mode 100644
index 0000000000..7c5d6b66e6
Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-linux.png differ
diff --git a/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png
new file mode 100644
index 0000000000..9a00a3b04b
Binary files /dev/null and b/playwright/snapshots/modules/custom-component.spec.ts/custom-component-tile-original-linux.png differ
diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png
index 3b4031063c..677473dded 100644
Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ
diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png
index e0e46682a3..da4d594e23 100644
Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-smallscreen-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png
index 20518942b0..67e047eb50 100644
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ
diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png
index a847075a4d..65258303c9 100644
Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ
diff --git a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png
index e26d001a90..39e74833b6 100644
Binary files a/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png and b/playwright/snapshots/settings/encryption-user-tab/recovery.spec.ts/set-up-recovery-linux.png differ
diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png
index c484a47fc9..dca96a1f4f 100644
Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ
diff --git a/playwright/stale-screenshot-reporter.ts b/playwright/stale-screenshot-reporter.ts
deleted file mode 100644
index 36aba56a07..0000000000
--- a/playwright/stale-screenshot-reporter.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2024 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.
-*/
-
-/**
- * Test reporter which compares the reported screenshots vs those on disk to find stale screenshots
- * Only intended to run from within GitHub Actions
- */
-
-import path from "node:path";
-import { glob } from "glob";
-
-import type { Reporter, TestCase } from "@playwright/test/reporter";
-
-const snapshotRoot = path.join(__dirname, "snapshots");
-
-class StaleScreenshotReporter implements Reporter {
- private screenshots = new Set();
- private failing = false;
- private success = true;
-
- public onTestEnd(test: TestCase): void {
- if (!test.ok()) {
- this.failing = true;
- }
- for (const annotation of test.annotations) {
- if (annotation.type === "_screenshot") {
- this.screenshots.add(annotation.description);
- }
- }
- }
-
- private error(msg: string, file: string) {
- if (process.env.GITHUB_ACTIONS) {
- console.log(`::error file=${file}::${msg}`);
- }
- console.error(msg, file);
- this.success = false;
- }
-
- public async onExit(): Promise {
- if (this.failing) return;
- const screenshotFiles = new Set(await glob(`**/*.png`, { cwd: snapshotRoot }));
- for (const screenshot of screenshotFiles) {
- if (screenshot.split("-").at(-1) !== "linux.png") {
- this.error(
- "Found screenshot belonging to different platform, this should not be checked in",
- screenshot,
- );
- }
- }
- for (const screenshot of this.screenshots) {
- screenshotFiles.delete(screenshot);
- }
- if (screenshotFiles.size > 0) {
- for (const screenshot of screenshotFiles) {
- this.error("Stale screenshot file", screenshot);
- }
- }
-
- if (!this.success) {
- process.exit(1);
- }
- }
-}
-
-export default StaleScreenshotReporter;
diff --git a/playwright/testcontainers/synapse.ts b/playwright/testcontainers/synapse.ts
index 19d1544196..aec3b1b570 100644
--- a/playwright/testcontainers/synapse.ts
+++ b/playwright/testcontainers/synapse.ts
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
-const TAG = "develop@sha256:66955f34a593cfc3b6e77b8d5510c60c6094f5bade8a17d2feaefbb8662ccf09";
+const TAG = "develop@sha256:b38e55f06543f83f5a13f1d843489eb7aeaf7370a5c17a51897b462eeca315f5";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index b219550204..7a07fc8d12 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -177,7 +177,6 @@
@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss";
@import "./views/dialogs/security/_AccessSecretStorageDialog.pcss";
@import "./views/dialogs/security/_CreateCrossSigningDialog.pcss";
-@import "./views/dialogs/security/_CreateKeyBackupDialog.pcss";
@import "./views/dialogs/security/_CreateSecretStorageDialog.pcss";
@import "./views/dialogs/security/_KeyBackupFailedDialog.pcss";
@import "./views/dialogs/security/_RestoreKeyBackupDialog.pcss";
diff --git a/res/css/shared.pcss b/res/css/shared.pcss
new file mode 100644
index 0000000000..42f8393666
--- /dev/null
+++ b/res/css/shared.pcss
@@ -0,0 +1,9 @@
+/*
+ * 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 url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
+@import url("@vector-im/compound-web/dist/style.css");
diff --git a/res/css/structures/_LeftPanel.pcss b/res/css/structures/_LeftPanel.pcss
index c1886b6b80..6aede8e98e 100644
--- a/res/css/structures/_LeftPanel.pcss
+++ b/res/css/structures/_LeftPanel.pcss
@@ -28,12 +28,6 @@ Please see LICENSE files in the repository root for full details.
--collapsedWidth: 68px;
}
-.mx_LeftPanel_newRoomList {
- /* Thew new rooms list is not designed to be collapsed to just icons. */
- /* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
- --collapsedWidth: 224px;
-}
-
.mx_LeftPanel_wrapper {
display: flex;
flex-direction: row;
@@ -246,3 +240,11 @@ Please see LICENSE files in the repository root for full details.
}
}
}
+
+.mx_LeftPanel_newRoomList {
+ /* Thew new rooms list is not designed to be collapsed to just icons. */
+ /* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
+ --collapsedWidth: 224px;
+ /* Important to force the color on ED titlebar until we remove the old room list */
+ background-color: var(--cpd-color-bg-canvas-default) !important;
+}
diff --git a/res/css/views/dialogs/_SettingsDialog.pcss b/res/css/views/dialogs/_SettingsDialog.pcss
index 186a82c0f5..2b65bff63b 100644
--- a/res/css/views/dialogs/_SettingsDialog.pcss
+++ b/res/css/views/dialogs/_SettingsDialog.pcss
@@ -30,4 +30,28 @@ Please see LICENSE files in the repository root for full details.
/* colliding harshly with the dialog when scrolled down. */
padding-bottom: 100px;
}
+
+ .mx_SettingsDialog_tabLabelsAlert::after {
+ display: inline-block;
+ content: "";
+ width: 8px;
+ height: 8px;
+ background-color: var(--cpd-color-icon-critical-primary);
+ clip-path: circle(4px);
+ position: absolute;
+ right: var(--cpd-space-4x);
+ }
+}
+
+/* On narrow viewports, the tab labels are hidden, so we need to shift the indicator so it isn't over the tab icon. */
+@media (max-width: 1024px) {
+ .mx_UserSettingsDialog,
+ .mx_RoomSettingsDialog,
+ .mx_SpaceSettingsDialog,
+ .mx_SpacePreferencesDialog {
+ .mx_SettingsDialog_tabLabelsAlert::after {
+ right: var(--cpd-space-1x);
+ top: var(--cpd-space-1x);
+ }
+ }
}
diff --git a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss b/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss
deleted file mode 100644
index 9bd8539881..0000000000
--- a/res/css/views/dialogs/security/_CreateKeyBackupDialog.pcss
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
-Copyright 2018-2024 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.
-*/
-
-.mx_CreateKeyBackupDialog .mx_Dialog_title {
- /* TODO: Consider setting this for all dialog titles. */
- margin-bottom: 1em;
-}
-
-.mx_CreateKeyBackupDialog_primaryContainer {
- /* FIXME: plinth colour in new theme(s). background-color: $accent; */
- padding: 20px;
-}
-
-.mx_CreateKeyBackupDialog_primaryContainer::after {
- content: "";
- clear: both;
- display: block;
-}
-
-.mx_CreateKeyBackupDialog_passPhraseContainer {
- display: flex;
- align-items: flex-start;
-}
-
-.mx_CreateKeyBackupDialog_passPhraseInput {
- flex: none;
- width: 250px;
- border: 1px solid $accent;
- border-radius: 5px;
- padding: 10px;
- margin-bottom: 1em;
-}
-
-.mx_CreateKeyBackupDialog_passPhraseMatch {
- margin-left: 20px;
-}
-
-.mx_CreateKeyBackupDialog_recoveryKeyHeader {
- margin-bottom: 1em;
-}
-
-.mx_CreateKeyBackupDialog_recoveryKeyContainer {
- display: flex;
-}
-
-.mx_CreateKeyBackupDialog_recoveryKey {
- width: 262px;
- padding: 20px;
- color: $info-plinth-fg-color;
- background-color: $info-plinth-bg-color;
- margin-right: 12px;
-}
-
-.mx_CreateKeyBackupDialog_recoveryKeyButtons {
- flex: 1;
- display: flex;
- align-items: center;
-}
-
-.mx_CreateKeyBackupDialog_recoveryKeyButtons button {
- flex: 1;
- white-space: nowrap;
-}
-
-.mx_CreateKeyBackupDialog {
- details .mx_AccessibleButton {
- margin: 1em 0; /* emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules */
- }
-}
diff --git a/res/css/views/rooms/_E2EIcon.pcss b/res/css/views/rooms/_E2EIcon.pcss
index f3aaf8a883..aea1b942d1 100644
--- a/res/css/views/rooms/_E2EIcon.pcss
+++ b/res/css/views/rooms/_E2EIcon.pcss
@@ -52,7 +52,7 @@ Please see LICENSE files in the repository root for full details.
.mx_E2EIcon_normal::after {
mask-image: url("$(res)/img/e2e/normal.svg");
- background-color: var(--cpd-color-icon-tertiary);
+ background-color: white;
}
.mx_E2EIcon_verified::after {
diff --git a/res/css/views/settings/_SettingsHeader.pcss b/res/css/views/settings/_SettingsHeader.pcss
index a705deda6c..e1c470214f 100644
--- a/res/css/views/settings/_SettingsHeader.pcss
+++ b/res/css/views/settings/_SettingsHeader.pcss
@@ -16,4 +16,13 @@
font: var(--cpd-font-body-sm-medium);
color: var(--cpd-color-text-action-accent);
}
+
+ &.mx_SettingsHeader_recommended::after {
+ display: inline-block;
+ content: "";
+ width: 8px;
+ height: 8px;
+ background-color: var(--cpd-color-icon-critical-primary);
+ clip-path: circle(4px);
+ }
}
diff --git a/sonar-project.properties b/sonar-project.properties
index 2d87d32efc..23333a43cc 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -5,7 +5,8 @@ sonar.organization=element-hq
#sonar.sourceEncoding=UTF-8
sonar.sources=src,res
-sonar.tests=test,playwright
+sonar.tests=test,playwright,src
+sonar.test.inclusions=test/*,playwright/*,src/**/*.test.tsx
sonar.exclusions=__mocks__,docs,element.io,nginx
sonar.cpd.exclusions=src/i18n/strings/*.json
@@ -19,5 +20,6 @@ sonar.coverage.exclusions=\
src/vector/modernizr.js,\
src/components/views/dialogs/devtools/**/*,\
src/utils/SessionLock.ts,\
- src/**/*.d.ts
+ src/**/*.d.ts,\
+ src/vector/mobile_guide/**/*
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index d4ae6c897b..77e57e1434 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -135,6 +135,7 @@ declare global {
initialise(): Promise<{
protocol: string;
sessionId: string;
+ supportsBadgeOverlay: boolean;
config: IConfigOptions;
supportedSettings: Record;
}>;
diff --git a/src/@types/matrix-js-sdk.d.ts b/src/@types/matrix-js-sdk.d.ts
index e1f8c4562e..ad75ca95f0 100644
--- a/src/@types/matrix-js-sdk.d.ts
+++ b/src/@types/matrix-js-sdk.d.ts
@@ -92,6 +92,9 @@ declare module "matrix-js-sdk/src/types" {
// MSC4155: Invite filtering
[INVITE_RULES_ACCOUNT_DATA_TYPE]: InviteConfigAccountData;
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
+
+ // Indicate whether recovery is enabled or disabled
+ "io.element.recovery": { enabled: boolean };
}
export interface AudioContent {
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index 87635a421c..c1a64848d2 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -494,15 +494,12 @@ export default abstract class BasePlatform {
}
private updateFavicon(): void {
- let bgColor = "#d00";
- let notif: string | number = this.notificationCount;
+ const notif: string | number = this.notificationCount;
if (this.errorDidOccur) {
- notif = notif || "×";
- bgColor = "#f00";
+ this.favicon.badge(notif || "×", { bgColor: "#f00" });
}
-
- this.favicon.badge(notif, { bgColor });
+ this.favicon.badge(notif);
}
/**
diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts
index e13d296bc1..4fc4e9cf20 100644
--- a/src/DeviceListener.ts
+++ b/src/DeviceListener.ts
@@ -15,7 +15,7 @@ import {
type SyncState,
ClientStoppedError,
} from "matrix-js-sdk/src/matrix";
-import { logger as baseLogger, LogSpan } from "matrix-js-sdk/src/logger";
+import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger";
import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { type CryptoSessionStateChange } from "@matrix-org/analytics-events/types/typescript/CryptoSessionStateChange";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
@@ -57,6 +57,11 @@ const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000;
*/
export const BACKUP_DISABLED_ACCOUNT_DATA_KEY = "m.org.matrix.custom.backup_disabled";
+/**
+ * Account data key to indicate whether the user has chosen to enable or disable recovery.
+ */
+export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery";
+
const logger = baseLogger.getChild("DeviceListener:");
export default class DeviceListener {
@@ -165,6 +170,13 @@ export default class DeviceListener {
await this.client?.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true });
}
+ /**
+ * Set the account data to indicate that recovery is disabled
+ */
+ public async recordRecoveryDisabled(): Promise {
+ await this.client?.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: false });
+ }
+
private async ensureDeviceIdsAtStartPopulated(): Promise {
if (this.ourDeviceIdsAtStart === null) {
this.ourDeviceIdsAtStart = await this.getDeviceIds();
@@ -201,6 +213,7 @@ export default class DeviceListener {
};
private onKeyBackupStatusChanged = (): void => {
+ logger.info("Backup status changed");
this.cachedKeyBackupUploadActive = undefined;
this.recheck();
};
@@ -220,7 +233,8 @@ export default class DeviceListener {
ev.getType().startsWith("m.secret_storage.") ||
ev.getType().startsWith("m.cross_signing.") ||
ev.getType() === "m.megolm_backup.v1" ||
- ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY
+ ev.getType() === BACKUP_DISABLED_ACCOUNT_DATA_KEY ||
+ ev.getType() === RECOVERY_ACCOUNT_DATA_KEY
) {
this.recheck();
}
@@ -300,6 +314,7 @@ export default class DeviceListener {
private async doRecheck(): Promise {
if (!this.running || !this.client) return; // we have been stopped
const logSpan = new LogSpan(logger, "check_" + secureRandomString(4));
+ logSpan.debug("starting recheck...");
const cli = this.client;
@@ -332,6 +347,9 @@ export default class DeviceListener {
crossSigningStatus.privateKeysCachedLocally.userSigningKey;
const defaultKeyId = await cli.secretStorage.getDefaultKeyId();
+ const recoveryDisabled = await this.recheckRecoveryDisabled(cli);
+
+ const recoveryIsOk = secretStorageReady || recoveryDisabled;
const isCurrentDeviceTrusted =
crossSigningReady &&
@@ -339,15 +357,14 @@ export default class DeviceListener {
(await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified,
);
- const keyBackupUploadActive = await this.isKeyBackupUploadActive();
+ const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan);
const backupDisabled = await this.recheckBackupDisabled(cli);
// We warn if key backup upload is turned off and we have not explicitly
// said we are OK with that.
const keyBackupIsOk = keyBackupUploadActive || backupDisabled;
- const allSystemsReady =
- crossSigningReady && keyBackupIsOk && secretStorageReady && allCrossSigningSecretsCached;
+ const allSystemsReady = isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk;
await this.reportCryptoSessionStateToAnalytics(cli);
@@ -360,13 +377,8 @@ export default class DeviceListener {
// make sure our keys are finished downloading
await crypto.getUserDeviceInfo([cli.getSafeUserId()]);
- if (!crossSigningReady) {
- // This account is legacy and doesn't have cross-signing set up at all.
- // Prompt the user to set it up.
- logSpan.info("Cross-signing not ready: showing SET_UP_ENCRYPTION toast");
- showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
- } else if (!isCurrentDeviceTrusted) {
- // cross signing is ready but the current device is not trusted: prompt the user to verify
+ if (!isCurrentDeviceTrusted) {
+ // the current device is not trusted: prompt the user to verify
logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast");
showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION);
} else if (!allCrossSigningSecretsCached) {
@@ -384,7 +396,10 @@ export default class DeviceListener {
// The user just hasn't set up 4S yet: if they have key
// backup, prompt them to turn on recovery too. (If not, they
// have explicitly opted out, so don't hassle them.)
- if (keyBackupUploadActive) {
+ if (recoveryDisabled) {
+ logSpan.info("Recovery disabled: no toast needed");
+ hideSetupEncryptionToast();
+ } else if (keyBackupUploadActive) {
logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast");
showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY);
} else {
@@ -392,16 +407,17 @@ export default class DeviceListener {
hideSetupEncryptionToast();
}
} else {
- // some other condition... yikes! Show the 'set up encryption' toast: this is what we previously did
- // in 'other' situations. Possibly we should consider prompting for a full reset in this case?
- logSpan.warn("Couldn't match encryption state to a known case: showing 'setup encryption' prompt", {
+ // If we get here, then we are verified, have key backup, and
+ // 4S, but crypto.isSecretStorageReady returned false, which
+ // means that 4S doesn't have all the secrets.
+ logSpan.warn("4S is missing secrets", {
crossSigningReady,
secretStorageReady,
allCrossSigningSecretsCached,
isCurrentDeviceTrusted,
defaultKeyId,
});
- showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION);
+ showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC_STORE);
}
} else {
logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false");
@@ -482,6 +498,20 @@ export default class DeviceListener {
return !!backupDisabled?.disabled;
}
+ /**
+ * Check whether the user has disabled recovery. If this is the first time,
+ * fetch it from the server (in case the initial sync has not finished).
+ * Otherwise, fetch it from the store as normal.
+ */
+ private async recheckRecoveryDisabled(cli: MatrixClient): Promise {
+ const recoveryStatus = await cli.getAccountDataFromServer(RECOVERY_ACCOUNT_DATA_KEY);
+ // Recovery is disabled only if the `enabled` flag is set to `false`.
+ // If it is missing, or set to any other value, we consider it as
+ // not-disabled, and will prompt the user to create recovery (if
+ // missing).
+ return recoveryStatus?.enabled === false;
+ }
+
/**
* Reports current recovery state to analytics.
* Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S).
@@ -551,7 +581,7 @@ export default class DeviceListener {
* trigger an auto-rageshake).
*/
private checkKeyBackupStatus = async (): Promise => {
- if (!(await this.isKeyBackupUploadActive())) {
+ if (!(await this.isKeyBackupUploadActive(logger))) {
dis.dispatch({ action: Action.ReportKeyBackupNotEnabled });
}
};
@@ -559,7 +589,7 @@ export default class DeviceListener {
/**
* Is key backup enabled? Use a cached answer if we have one.
*/
- private isKeyBackupUploadActive = async (): Promise => {
+ private isKeyBackupUploadActive = async (logger: BaseLogger): Promise => {
if (!this.client) {
// To preserve existing behaviour, if there is no client, we
// pretend key backup upload is on.
@@ -583,6 +613,7 @@ export default class DeviceListener {
// Fetch the answer and cache it
const activeKeyBackupVersion = await crypto.getActiveSessionBackupVersion();
this.cachedKeyBackupUploadActive = !!activeKeyBackupVersion;
+ logger.debug(`Key backup upload is ${this.cachedKeyBackupUploadActive ? "active" : "inactive"}`);
return this.cachedKeyBackupUploadActive;
};
diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index 8b88a18075..952d35e88d 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -81,6 +81,7 @@ export interface IConfigOptions {
};
mobile_guide_toast?: boolean;
+ mobile_guide_app_variant?: "element" | "element-classic" | "element-pro";
default_theme?: "light" | "dark" | string; // custom themes are strings
default_country_code?: string; // ISO 3166 alpha2 country code
diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts
index 5641f936ae..20e7435599 100644
--- a/src/Lifecycle.ts
+++ b/src/Lifecycle.ts
@@ -657,7 +657,7 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean }
freshLogin: freshLogin,
},
false,
- false,
+ freshLogin,
);
return true;
} else {
diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts
index bfd0eb237d..ecf895fd79 100644
--- a/src/SecurityManager.ts
+++ b/src/SecurityManager.ts
@@ -176,10 +176,11 @@ export async function withSecretStorageKeyCache(func: () => Promise): Prom
}
export interface AccessSecretStorageOpts {
- /** Reset secret storage even if it's already set up. */
+ /**
+ * Reset secret storage even if it's already set up.
+ * @deprecated send the user to the Encryption settings tab to reset secret storage
+ */
forceReset?: boolean;
- /** Create new cross-signing keys. Only applicable if `forceReset` is `true`. */
- resetCrossSigning?: boolean;
}
/**
@@ -189,8 +190,8 @@ export interface AccessSecretStorageOpts {
* provided function.
*
* Bootstrapping secret storage may take one of these paths:
- * 1. Create secret storage from a passphrase and store cross-signing keys
- * in secret storage.
+ * 1. (Only if `opts.forceReset` is set) create secret storage from a passphrase
+ * and store cross-signing keys in secret storage.
* 2. Access existing secret storage by requesting passphrase and accessing
* cross-signing keys as needed.
* 3. All keys are loaded and there's nothing to do.
@@ -199,6 +200,8 @@ export interface AccessSecretStorageOpts {
* to ensure the user is prompted only once for their secret storage
* passphrase. The cache is then cleared once the provided function completes.
*
+ * Throws an error if secret storage is not set up (and `opts.forceReset` is not set)
+ *
* @param {Function} [func] An operation to perform once secret storage has been
* bootstrapped. Optional.
* @param [opts] The options to use when accessing secret storage.
@@ -219,16 +222,8 @@ async function doAccessSecretStorage(func: () => Promise, opts: AccessSecr
throw new Error("End-to-end encryption is disabled - unable to access secret storage.");
}
- let createNew = false;
if (opts.forceReset) {
logger.debug("accessSecretStorage: resetting 4S");
- createNew = true;
- } else if (!(await cli.secretStorage.hasKey())) {
- logger.debug("accessSecretStorage: no 4S key configured, creating a new one");
- createNew = true;
- }
-
- if (createNew) {
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createDialog(
@@ -251,6 +246,9 @@ async function doAccessSecretStorage(func: () => Promise, opts: AccessSecr
if (!confirmed) {
throw new Error("Secret storage creation canceled");
}
+ } else if (!(await cli.secretStorage.hasKey())) {
+ logger.debug("accessSecretStorage: no 4S key configured");
+ throw new Error("Secret storage has not been created yet.");
} else {
logger.debug("accessSecretStorage: bootstrapCrossSigning");
await crypto.bootstrapCrossSigning({
diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts
index ec5b8312b9..a1b18a106c 100644
--- a/src/accessibility/KeyboardShortcuts.ts
+++ b/src/accessibility/KeyboardShortcuts.ts
@@ -8,7 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
-import { _td, type TranslationKey } from "../languageHandler";
+// Import i18n.tsx instead of languageHandler to avoid circular deps
+import { _td, type TranslationKey } from "../shared-components/i18n";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { type IBaseSetting } from "../settings/Settings";
import { type KeyCombo } from "../KeyBindingsManager";
@@ -145,6 +146,7 @@ export enum KeyBindingAction {
ArrowDown = "KeyBinding.arrowDown",
Tab = "KeyBinding.tab",
Comma = "KeyBinding.comma",
+ Save = "KeyBinding.save",
/** Toggle visibility of hidden events */
ToggleHiddenEventVisibility = "KeyBinding.toggleHiddenEventVisibility",
@@ -268,6 +270,7 @@ export const CATEGORIES: Record = {
KeyBindingAction.ArrowRight,
KeyBindingAction.ArrowDown,
KeyBindingAction.Comma,
+ KeyBindingAction.Save,
],
},
[CategoryName.NAVIGATION]: {
@@ -620,6 +623,13 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
displayName: _td("keyboard|composer_redo"),
},
+ [KeyBindingAction.Save]: {
+ default: {
+ key: Key.S,
+ ctrlOrCmdKey: true,
+ },
+ displayName: _td("keyboard|save"),
+ },
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
default: {
metaKey: IS_MAC,
diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx
deleted file mode 100644
index 0bc6fea219..0000000000
--- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
-Copyright 2018, 2019 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, { type JSX } from "react";
-import { logger } from "matrix-js-sdk/src/logger";
-
-import { MatrixClientPeg } from "../../../../MatrixClientPeg";
-import { _t } from "../../../../languageHandler";
-import { accessSecretStorage, withSecretStorageKeyCache } from "../../../../SecurityManager";
-import Spinner from "../../../../components/views/elements/Spinner";
-import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
-import DialogButtons from "../../../../components/views/elements/DialogButtons";
-
-enum Phase {
- BackingUp = "backing_up",
- Done = "done",
-}
-
-interface IProps {
- onFinished(done?: boolean): void;
-}
-
-interface IState {
- phase: Phase;
- passPhrase: string;
- passPhraseValid: boolean;
- passPhraseConfirm: string;
- copied: boolean;
- downloaded: boolean;
- error?: boolean;
-}
-
-/**
- * Walks the user through the process of setting up e2e key backups to a new backup, and storing the decryption key in
- * SSSS.
- *
- * Uses {@link accessSecretStorage}, which means that if 4S is not already configured, it will be bootstrapped (which
- * involves displaying an {@link CreateSecretStorageDialog} so the user can enter a passphrase and/or download the 4S
- * key).
- */
-export default class CreateKeyBackupDialog extends React.PureComponent {
- public constructor(props: IProps) {
- super(props);
-
- this.state = {
- phase: Phase.BackingUp,
- passPhrase: "",
- passPhraseValid: false,
- passPhraseConfirm: "",
- copied: false,
- downloaded: false,
- };
- }
-
- public componentDidMount(): void {
- this.createBackup();
- }
-
- private createBackup = async (): Promise => {
- this.setState({
- error: undefined,
- });
- const cli = MatrixClientPeg.safeGet();
- try {
- // Check if 4S already set up
- const secretStorageAlreadySetup = await cli.secretStorage.hasKey();
-
- if (!secretStorageAlreadySetup) {
- // bootstrap secret storage; that will also create a backup version
- await accessSecretStorage(async (): Promise => {
- // do nothing, all is now set up correctly
- });
- } else {
- await withSecretStorageKeyCache(async () => {
- const crypto = cli.getCrypto();
- if (!crypto) {
- throw new Error("End-to-end encryption is disabled - unable to create backup.");
- }
-
- // Before we reset the backup, let's make sure we can access secret storage, to
- // reduce the chance of us getting into a broken state where we have an outdated
- // secret in secret storage.
- // `SecretStorage.get` will ask the user to enter their passphrase/key if necessary;
- // it will then be cached for the actual backup reset operation.
- await cli.secretStorage.get("m.megolm_backup.v1");
-
- // We now know we can store the new backup key in secret storage, so it is safe to
- // go ahead with the reset.
- await crypto.resetKeyBackup();
- });
- }
-
- this.setState({
- phase: Phase.Done,
- });
- } catch (e) {
- logger.error("Error creating key backup", e);
- // TODO: If creating a version succeeds, but backup fails, should we
- // delete the version, disable backup, or do nothing? If we just
- // disable without deleting, we'll enable on next app reload since
- // it is trusted.
- this.setState({
- error: true,
- });
- }
- };
-
- private onCancel = (): void => {
- this.props.onFinished(false);
- };
-
- private onDone = (): void => {
- this.props.onFinished(true);
- };
-
- private renderBusyPhase(): JSX.Element {
- return (
-
+ ),
+ button: _t("action|continue"),
+ });
+
+ const [confirmed] = await finished;
+ if (!confirmed) return;
+ } else if (myUserId === target && myPower && parseInt(myPower) > powerLevel) {
+ // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
+ try {
+ if (!(await warnSelfDemote(room?.isSpaceRoom()))) return;
+ } catch (e) {
+ logger.error("Failed to warn about self demotion: " + e);
+ }
+ }
+
+ await applyPowerChange(roomId, target, powerLevel);
+ },
+ [user.roomId, user.userId, cli, room],
+ );
+
+ const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
+ const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
+
+ return {
+ powerLevelUsersDefault,
+ onPowerChange,
+ selectedPowerLevel,
+ };
+};
diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx
new file mode 100644
index 0000000000..401c536525
--- /dev/null
+++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderVerificationViewModel.tsx
@@ -0,0 +1,87 @@
+/*
+Copyright 2025 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 { type MatrixClient, type RoomMember, type User } from "matrix-js-sdk/src/matrix";
+import { useContext } from "react";
+import { type UserVerificationStatus } from "matrix-js-sdk/src/crypto-api";
+
+import MatrixClientContext from "../../../../contexts/MatrixClientContext";
+import { type IDevice } from "../../../views/right_panel/UserInfo";
+import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
+import { verifyUser } from "../../../../verification";
+
+export interface UserInfoVerificationSectionState {
+ /**
+ * variables used to check if we can verify the user and display the verify button
+ */
+ canVerify: boolean;
+ hasCrossSigningKeys: boolean | undefined;
+ /**
+ * used to display correct badge value
+ */
+ isUserVerified: boolean;
+ /**
+ * callback function when verifyUser button is clicked
+ */
+ verifySelectedUser: () => Promise;
+}
+
+const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
+ return useAsyncMemo(
+ async () => {
+ return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
+ },
+ [cli],
+ false,
+ );
+};
+
+const useHasCrossSigningKeys = (cli: MatrixClient, member: User, canVerify: boolean): boolean | undefined => {
+ return useAsyncMemo(async () => {
+ if (!canVerify) return undefined;
+ return cli.getCrypto()?.userHasCrossSigningKeys(member.userId, true);
+ }, [cli, member, canVerify]);
+};
+
+/**
+ * View model for the userInfoVerificationHeaderView
+ * @see {@link UserInfoVerificationSectionState} for more information about what this view model returns.
+ */
+export const useUserInfoVerificationViewModel = (
+ member: User | RoomMember,
+ devices: IDevice[],
+): UserInfoVerificationSectionState => {
+ const cli = useContext(MatrixClientContext);
+
+ const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli);
+
+ const userTrust = useAsyncMemo(
+ async () => cli.getCrypto()?.getUserVerificationStatus(member.userId),
+ [member.userId],
+ // the user verification status is not initialized
+ undefined,
+ );
+ const hasUserVerificationStatus = Boolean(userTrust);
+ const isUserVerified = Boolean(userTrust?.isVerified());
+ const isMe = member.userId === cli.getUserId();
+ const canVerify =
+ hasUserVerificationStatus &&
+ homeserverSupportsCrossSigning &&
+ !isUserVerified &&
+ !isMe &&
+ devices &&
+ devices.length > 0;
+
+ const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify);
+ const verifySelectedUser = (): Promise => verifyUser(cli, member as User);
+
+ return {
+ canVerify,
+ hasCrossSigningKeys,
+ isUserVerified,
+ verifySelectedUser,
+ };
+};
diff --git a/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx
new file mode 100644
index 0000000000..12899f0bc1
--- /dev/null
+++ b/src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel.tsx
@@ -0,0 +1,115 @@
+/*
+Copyright 2025 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 { RoomMember, type User } from "matrix-js-sdk/src/matrix";
+import { useCallback, useContext } from "react";
+
+import { mediaFromMxc } from "../../../../customisations/Media";
+import Modal from "../../../../Modal";
+import ImageView from "../../../views/elements/ImageView";
+import SdkConfig from "../../../../SdkConfig";
+import MatrixClientContext from "../../../../contexts/MatrixClientContext";
+import { type Member } from "../../../views/right_panel/UserInfo";
+import { useUserTimezone } from "../../../../hooks/useUserTimezone";
+import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
+
+export interface PresenceInfo {
+ lastActiveAgo: number | undefined;
+ currentlyActive: boolean | undefined;
+ state: string | undefined;
+}
+
+export interface TimezoneInfo {
+ timezone: string;
+ friendly: string;
+}
+
+export interface UserInfoHeaderState {
+ /**
+ * callback function when selected user avatar is clicked in user info
+ */
+ onMemberAvatarClick: () => void;
+ /**
+ * Object containing information about the precense of the selected user
+ */
+ precenseInfo: PresenceInfo;
+ /**
+ * Boolean that show or hide the precense information
+ */
+ showPresence: boolean;
+ /**
+ * Timezone object
+ */
+ timezoneInfo: TimezoneInfo | null;
+ /**
+ * Displayed identifier for the selected user
+ */
+ userIdentifier: string | null;
+}
+interface UserInfoHeaderViewModelProps {
+ member: Member;
+ roomId?: string;
+}
+
+/**
+ * View model for the userInfoHeaderView
+ * props
+ * @see {@link UserInfoHeaderState} for more information about what this view model returns.
+ */
+export function useUserfoHeaderViewModel({ member, roomId }: UserInfoHeaderViewModelProps): UserInfoHeaderState {
+ const cli = useContext(MatrixClientContext);
+
+ let showPresence = true;
+
+ const precenseInfo: PresenceInfo = {
+ lastActiveAgo: undefined,
+ currentlyActive: undefined,
+ state: undefined,
+ };
+
+ const enablePresenceByHsUrl = SdkConfig.get("enable_presence_by_hs_url");
+
+ const timezoneInfo = useUserTimezone(cli, member.userId);
+
+ const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
+ roomId,
+ withDisplayName: true,
+ });
+
+ const onMemberAvatarClick = useCallback(() => {
+ const avatarUrl = (member as RoomMember).getMxcAvatarUrl
+ ? (member as RoomMember).getMxcAvatarUrl()
+ : (member as User).avatarUrl;
+
+ const httpUrl = mediaFromMxc(avatarUrl).srcHttp;
+ if (!httpUrl) return;
+
+ const params = {
+ src: httpUrl,
+ name: (member as RoomMember).name || (member as User).displayName,
+ };
+
+ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
+ }, [member]);
+
+ if (member instanceof RoomMember && member.user) {
+ precenseInfo.state = member.user.presence;
+ precenseInfo.lastActiveAgo = member.user.lastActiveAgo;
+ precenseInfo.currentlyActive = member.user.currentlyActive;
+ }
+
+ if (enablePresenceByHsUrl && enablePresenceByHsUrl[cli.baseUrl] !== undefined) {
+ showPresence = enablePresenceByHsUrl[cli.baseUrl];
+ }
+
+ return {
+ onMemberAvatarClick,
+ showPresence,
+ precenseInfo,
+ timezoneInfo,
+ userIdentifier,
+ };
+}
diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx
index e009033875..107d1a4c60 100644
--- a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx
+++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx
@@ -30,6 +30,10 @@ export interface RoomListItemViewState {
* The name of the room.
*/
name: string;
+ /**
+ * Whether the context menu should be shown.
+ */
+ showContextMenu: boolean;
/**
* Whether the hover menu should be shown.
*/
@@ -105,12 +109,12 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
setNotificationValues(getNotificationValues(notificationState));
}, [notificationState]);
- // We don't want to show the hover menu if
+ // We don't want to show the menus if
// - there is an invitation for this room
- // - the user doesn't have access to both notification and more options menus
+ // - the user doesn't have access to notification and more options menus
+ const showContextMenu = !invited && hasAccessToOptionsMenu(room);
const showHoverMenu =
- !invited &&
- (hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
+ !invited && (showContextMenu || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
const messagePreview = useRoomMessagePreview(room);
@@ -137,6 +141,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
return {
name,
notificationState,
+ showContextMenu,
showHoverMenu,
openRoom,
a11yLabel,
diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx
index 76f6b31989..8d6f6cc6eb 100644
--- a/src/components/views/context_menus/MessageContextMenu.tsx
+++ b/src/components/views/context_menus/MessageContextMenu.tsx
@@ -183,6 +183,30 @@ export default class MessageContextMenu extends React.Component
);
}
+ /**
+ * Returns true if the current selection is entirely within a single "mx_MTextBody" element.
+ */
+ private isSelectionWithinSingleTextBody(): boolean {
+ const selection = window.getSelection();
+ if (!selection || selection.rangeCount === 0) return false;
+ const range = selection.getRangeAt(0);
+
+ function getParentByClass(node: Node | null, className: string): HTMLElement | null {
+ while (node) {
+ if (node instanceof HTMLElement && node.classList.contains(className)) {
+ return node;
+ }
+ node = node.parentNode;
+ }
+ return null;
+ }
+
+ const startTextBody = getParentByClass(range.startContainer, "mx_MTextBody");
+ const endTextBody = getParentByClass(range.endContainer, "mx_MTextBody");
+
+ return !!startTextBody && startTextBody === endTextBody;
+ }
+
private onResendReactionsClick = (): void => {
for (const reaction of this.getUnsentReactions()) {
Resend.resend(MatrixClientPeg.safeGet(), reaction);
@@ -279,6 +303,24 @@ export default class MessageContextMenu extends React.Component
this.closeMenu();
};
+ private onQuoteClick = (): void => {
+ const selectedText = getSelectedText();
+ if (selectedText) {
+ // Format as markdown quote
+ const quotedText = selectedText
+ .trim()
+ .split(/\r?\n/)
+ .map((line) => `> ${line}`)
+ .join("\n");
+ dis.dispatch({
+ action: Action.ComposerInsert,
+ text: "\n" + quotedText + "\n\n ",
+ timelineRenderingType: this.context.timelineRenderingType,
+ });
+ }
+ this.closeMenu();
+ };
+
private onEditClick = (): void => {
editEvent(
MatrixClientPeg.safeGet(),
@@ -549,8 +591,10 @@ export default class MessageContextMenu extends React.Component
);
}
+ const selectedText = getSelectedText();
+
let copyButton: JSX.Element | undefined;
- if (rightClick && getSelectedText()) {
+ if (rightClick && selectedText) {
copyButton = (
);
}
+ let quoteButton: JSX.Element | undefined;
+ if (rightClick && selectedText && selectedText.trim().length > 0 && this.isSelectionWithinSingleTextBody()) {
+ quoteButton = (
+
+ );
+ }
+
let editButton: JSX.Element | undefined;
if (rightClick && canEditContent(cli, mxEvent)) {
editButton = (
@@ -630,10 +686,11 @@ export default class MessageContextMenu extends React.Component
}
let nativeItemsList: JSX.Element | undefined;
- if (copyButton || copyLinkButton) {
+ if (copyButton || quoteButton || copyLinkButton) {
nativeItemsList = (
{copyButton}
+ {quoteButton}
{copyLinkButton}
);
diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx
index 6e000ef631..830e5fb589 100644
--- a/src/components/views/dialogs/LogoutDialog.tsx
+++ b/src/components/views/dialogs/LogoutDialog.tsx
@@ -13,9 +13,11 @@ import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
+import { type OpenToTabPayload } from "../../../dispatcher/payloads/OpenToTabPayload";
+import { Action } from "../../../dispatcher/actions";
+import { UserTab } from "../../../components/views/dialogs/UserTab";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import RestoreKeyBackupDialog from "./security/RestoreKeyBackupDialog";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
@@ -138,26 +140,12 @@ export default class LogoutDialog extends React.Component {
};
private onSetRecoveryMethodClick = (): void => {
- if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
- // A key backup exists for this account, but the creating device is not
- // verified, so restore the backup which will give us the keys from it and
- // allow us to trust it (ie. upload keys to it)
- Modal.createDialog(
- RestoreKeyBackupDialog,
- undefined,
- undefined,
- /* priority = */ false,
- /* static = */ true,
- );
- } else {
- Modal.createDialog(
- lazy(() => import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog")),
- undefined,
- undefined,
- /* priority = */ false,
- /* static = */ true,
- );
- }
+ // Open the user settings dialog to the encryption tab and start the flow to reset encryption
+ const payload: OpenToTabPayload = {
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ };
+ dis.dispatch(payload);
// close dialog
this.props.onFinished(true);
@@ -190,22 +178,13 @@ export default class LogoutDialog extends React.Component {
);
- let setupButtonCaption;
- if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
- setupButtonCaption = _t("settings|security|key_backup_connect");
- } else {
- // if there's an error fetching the backup info, we'll just assume there's
- // no backup for the purpose of the button caption
- setupButtonCaption = _t("auth|logout_dialog|use_key_backup");
- }
-
const dialogContent = (
{description}
=> {
+ if (event === undefined || event.getType() === "m.secret_storage.default_key") {
+ const client = props.sdkContext.client;
+ if (!client) {
+ return false;
+ }
+
+ return !(await client.secretStorage.getDefaultKeyId());
+ }
+ return new NoChange();
+ },
+ [],
+ false,
+ );
+
const getTabs = (): NonEmptyArray> => {
const tabs: Tab[] = [];
@@ -196,6 +218,7 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
,
,
"UserSettingsEncryption",
+ showSetupRecoveryIndicator ? "mx_SettingsDialog_tabLabelsAlert" : undefined,
),
);
diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx
index e3ee6f20d5..0ee15efa20 100644
--- a/src/components/views/elements/ImageView.tsx
+++ b/src/components/views/elements/ImageView.tsx
@@ -8,9 +8,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
-import React, { type JSX, createRef, type CSSProperties, useRef, useState, useMemo } from "react";
+import React, { type JSX, createRef, type CSSProperties, useEffect } from "react";
import FocusLock from "react-focus-lock";
-import { type MatrixEvent, parseErrorResponse } from "matrix-js-sdk/src/matrix";
+import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
@@ -30,10 +30,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton";
-import Modal from "../../../Modal";
-import ErrorDialog from "../dialogs/ErrorDialog";
-import { FileDownloader } from "../../../utils/FileDownloader";
-import { MediaEventHelper } from "../../../utils/MediaEventHelper.ts";
+import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@@ -121,6 +118,8 @@ export default class ImageView extends React.Component {
private imageWrapper = createRef();
private image = createRef();
+ private downloadFunction?: () => Promise;
+
private initX = 0;
private initY = 0;
private previousX = 0;
@@ -300,6 +299,13 @@ export default class ImageView extends React.Component {
ev.preventDefault();
this.props.onFinished();
break;
+ case KeyBindingAction.Save:
+ ev.preventDefault();
+ ev.stopPropagation();
+ if (this.downloadFunction) {
+ this.downloadFunction();
+ }
+ break;
}
};
@@ -325,6 +331,10 @@ export default class ImageView extends React.Component {
});
};
+ private onDownloadFunctionReady = (download: () => Promise): void => {
+ this.downloadFunction = download;
+ };
+
private onPermalinkClicked = (ev: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Element when clicked.
@@ -550,7 +560,12 @@ export default class ImageView extends React.Component {
title={_t("lightbox|rotate_right")}
onClick={this.onRotateClockwiseClick}
/>
-
+
{contextMenuButton}
{
}
}
-function DownloadButton({
- url,
- fileName,
- mxEvent,
-}: {
+interface DownloadButtonProps {
url: string;
fileName?: string;
mxEvent?: MatrixEvent;
-}): JSX.Element {
- const downloader = useRef(new FileDownloader()).current;
- const [loading, setLoading] = useState(false);
- const blobRef = useRef(undefined);
- const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
+ onDownloadReady?: (download: () => Promise) => void;
+}
- function showError(e: unknown): void {
- Modal.createDialog(ErrorDialog, {
- title: _t("timeline|download_failed"),
- description: (
- <>
-
{_t("timeline|download_failed_description")}
-
{e instanceof Error ? e.toString() : ""}
- >
- ),
- });
- setLoading(false);
- }
+export const DownloadButton: React.FC = ({ url, fileName, mxEvent, onDownloadReady }) => {
+ const { download, loading, canDownload } = useDownloadMedia(url, fileName, mxEvent);
- const onDownloadClick = async (): Promise => {
- try {
- if (loading) return;
- setLoading(true);
+ useEffect(() => {
+ if (onDownloadReady) onDownloadReady(download);
+ }, [download, onDownloadReady]);
- if (blobRef.current) {
- // Cheat and trigger a download, again.
- return downloadBlob(blobRef.current);
- }
-
- const res = await fetch(url);
- if (!res.ok) {
- throw parseErrorResponse(res, await res.text());
- }
- const blob = await res.blob();
- blobRef.current = blob;
- await downloadBlob(blob);
- } catch (e) {
- showError(e);
- }
- };
-
- async function downloadBlob(blob: Blob): Promise {
- await downloader.download({
- blob,
- name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
- });
- setLoading(false);
- }
+ if (!canDownload) return null;
return (
);
-}
+};
diff --git a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx
index 88a1ab1bd6..ffcbf972d7 100644
--- a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx
+++ b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx
@@ -52,8 +52,7 @@ export default class SpellCheckLanguagesDropdown extends React.Component<
const plaf = PlatformPeg.get();
if (plaf) {
const languageNames = new Intl.DisplayNames([getUserLanguage()], { type: "language", style: "short" });
- plaf
- .getAvailableSpellCheckLanguages()
+ plaf.getAvailableSpellCheckLanguages()
?.then((languages) => {
languages.sort(function (a, b) {
if (a < b) return -1;
diff --git a/src/components/views/messages/DownloadActionButton.tsx b/src/components/views/messages/DownloadActionButton.tsx
index 340bdffa54..479e792fca 100644
--- a/src/components/views/messages/DownloadActionButton.tsx
+++ b/src/components/views/messages/DownloadActionButton.tsx
@@ -7,17 +7,15 @@ Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
-import React, { type JSX } from "react";
+import React, { type ReactElement, useMemo } from "react";
import classNames from "classnames";
import { DownloadIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MediaEventHelper } from "../../../utils/MediaEventHelper";
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner";
-import { _t, _td, type TranslationKey } from "../../../languageHandler";
-import { FileDownloader } from "../../../utils/FileDownloader";
-import Modal from "../../../Modal";
-import ErrorDialog from "../dialogs/ErrorDialog";
+import { _t } from "../../../languageHandler";
+import { useDownloadMedia } from "../../../hooks/useDownloadMedia";
interface IProps {
mxEvent: MatrixEvent;
@@ -28,92 +26,32 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined;
}
-interface IState {
- loading: boolean;
- blob?: Blob;
- tooltip: TranslationKey;
-}
-
-export default class DownloadActionButton extends React.PureComponent {
- private downloader = new FileDownloader();
-
- public constructor(props: IProps) {
- super(props);
-
- this.state = {
- loading: false,
- tooltip: _td("timeline|download_action_downloading"),
- };
- }
-
- private onDownloadClick = async (): Promise => {
- try {
- await this.doDownload();
- } catch (e) {
- Modal.createDialog(ErrorDialog, {
- title: _t("timeline|download_failed"),
- description: (
- <>
-
{_t("timeline|download_failed_description")}
-
{e instanceof Error ? e.toString() : ""}
- >
- ),
- });
- this.setState({ loading: false });
- }
- };
-
- private async doDownload(): Promise {
- const mediaEventHelper = this.props.mediaEventHelperGet();
- if (this.state.loading || !mediaEventHelper) return;
-
- if (mediaEventHelper.media.isEncrypted) {
- this.setState({ tooltip: _td("timeline|download_action_decrypting") });
- }
-
- this.setState({ loading: true });
-
- if (this.state.blob) {
- // Cheat and trigger a download, again.
- return this.downloadBlob(this.state.blob);
- }
-
- const blob = await mediaEventHelper.sourceBlob.value;
- this.setState({ blob });
- await this.downloadBlob(blob);
- }
-
- private async downloadBlob(blob: Blob): Promise {
- await this.downloader.download({
- blob,
- name: this.props.mediaEventHelperGet()!.fileName,
- });
- this.setState({ loading: false });
- }
-
- public render(): React.ReactNode {
- let spinner: JSX.Element | undefined;
- if (this.state.loading) {
- spinner = ;
- }
-
- const classes = classNames({
- mx_MessageActionBar_iconButton: true,
- mx_MessageActionBar_downloadButton: true,
- mx_MessageActionBar_downloadSpinnerButton: !!spinner,
- });
-
- return (
-
-
- {spinner}
-
- );
- }
+export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
+ const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
+ const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
+ const fileName = mediaEventHelper?.fileName;
+
+ const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
+
+ if (!canDownload) return null;
+
+ const spinner = loading ? : undefined;
+ const classes = classNames({
+ mx_MessageActionBar_iconButton: true,
+ mx_MessageActionBar_downloadButton: true,
+ mx_MessageActionBar_downloadSpinnerButton: !!spinner,
+ });
+
+ return (
+
+
+ {spinner}
+
+ );
}
diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx
deleted file mode 100644
index 445913bfc6..0000000000
--- a/src/components/views/messages/TextualEvent.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
-Copyright 2024 New Vector Ltd.
-Copyright 2015-2021 The Matrix.org Foundation C.I.C.
-
-SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
-Please see LICENSE files in the repository root for full details.
-*/
-
-import React from "react";
-import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
-
-import RoomContext from "../../../contexts/RoomContext";
-import * as TextForEvent from "../../../TextForEvent";
-import { MatrixClientPeg } from "../../../MatrixClientPeg";
-
-interface IProps {
- mxEvent: MatrixEvent;
-}
-
-export default class TextualEvent extends React.Component {
- public static contextType = RoomContext;
- declare public context: React.ContextType;
-
- public componentDidMount(): void {
- this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
- }
- public componentWillUnmount(): void {
- this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
- }
-
- private onEventSentinelUpdated = (): void => {
- // XXX: this is crap, but we don't have a better way to force a re-render
- // Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated
- this.forceUpdate();
- };
-
- public render(): React.ReactNode {
- const text = TextForEvent.textForEvent(
- this.props.mxEvent,
- MatrixClientPeg.safeGet(),
- true,
- this.context?.showHiddenEvents,
- );
- if (!text) return null;
- return
{text}
;
- }
-}
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx
index 542e6421de..4556063303 100644
--- a/src/components/views/right_panel/UserInfo.tsx
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -25,8 +25,7 @@ import {
import { KnownMembership } from "matrix-js-sdk/src/types";
import { type UserVerificationStatus, type VerificationRequest, CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
-import { Badge, Button, Heading, InlineSpinner, MenuItem, Text, Tooltip } from "@vector-im/compound-web";
-import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
+import { MenuItem } from "@vector-im/compound-web";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import ShareIcon from "@vector-im/compound-design-tokens/assets/web/icons/share";
@@ -40,42 +39,32 @@ import Modal from "../../../Modal";
import { _t, UserFriendlyError } from "../../../languageHandler";
import DMRoomMap from "../../../utils/DMRoomMap";
import { type ButtonEvent } from "../elements/AccessibleButton";
-import SdkConfig from "../../../SdkConfig";
import MultiInviter from "../../../utils/MultiInviter";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
-import { textualPowerLevel } from "../../../Roles";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
-import { verifyUser } from "../../../verification";
import { Action } from "../../../dispatcher/actions";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard";
-import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner";
-import PowerSelector from "../elements/PowerSelector";
-import MemberAvatar from "../avatars/MemberAvatar";
-import PresenceLabel from "../rooms/PresenceLabel";
import { ShareDialog } from "../dialogs/ShareDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
-import { mediaFromMxc } from "../../../customisations/Media";
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { type IRightPanelCardState } from "../../../stores/right-panel/RightPanelStoreIPanelState";
-import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { DirectoryMember, startDmOnFirstMessage } from "../../../utils/direct-messages";
import { SdkContextClass } from "../../../contexts/SDKContext";
-import { Flex } from "../../utils/Flex";
-import CopyableText from "../elements/CopyableText";
-import { useUserTimezone } from "../../../hooks/useUserTimezone";
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
+import { PowerLevelSection } from "./user_info/UserInfoPowerLevels";
+import { UserInfoHeaderView } from "./user_info/UserInfoHeaderView";
export interface IDevice extends Device {
ambiguous?: boolean;
@@ -299,7 +288,7 @@ export const warnSelfDemote = async (isSpace: boolean): Promise => {
return !!confirmed;
};
-const Container: React.FC<{
+export const Container: React.FC<{
children: ReactNode;
className?: string;
}> = ({ children, className }) => {
@@ -427,17 +416,7 @@ const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};
-const useHomeserverSupportsCrossSigning = (cli: MatrixClient): boolean => {
- return useAsyncMemo(
- async () => {
- return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
- },
- [cli],
- false,
- );
-};
-
-interface IRoomPermissions {
+export interface IRoomPermissions {
modifyLevelMax: number;
canEdit: boolean;
canInvite: boolean;
@@ -492,112 +471,6 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
return roomPermissions;
}
-const PowerLevelSection: React.FC<{
- user: RoomMember;
- room: Room;
- roomPermissions: IRoomPermissions;
- powerLevels: IPowerLevelsContent;
-}> = ({ user, room, roomPermissions, powerLevels }) => {
- if (roomPermissions.canEdit) {
- return ;
- } else {
- const powerLevelUsersDefault = powerLevels.users_default || 0;
- const powerLevel = user.powerLevel;
- const role = textualPowerLevel(powerLevel, powerLevelUsersDefault);
- return (
-
+ );
+};
diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx
index e4f19762ff..7cc76066ee 100644
--- a/src/components/views/rooms/E2EIcon.tsx
+++ b/src/components/views/rooms/E2EIcon.tsx
@@ -76,7 +76,17 @@ const E2EIcon: React.FC = ({
if (onClick) {
content = ;
} else {
- content = ;
+ // Verified and warning icon have a transparent cutout, so add a white background.
+ // The normal icon already has the correct shape and size, so reuse that.
+ if (status === E2EStatus.Verified || status === E2EStatus.Warning) {
+ content = (
+
+
+
+ );
+ } else {
+ content = ;
+ }
}
if (!e2eTitle || hideTooltip) {
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 2c10d0afd9..27afc4debb 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -64,7 +64,7 @@ import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type ButtonEvent } from "../elements/AccessibleButton";
-import { copyPlaintext, getSelectedText } from "../../../utils/strings";
+import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import RedactedBody from "../messages/RedactedBody";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -840,10 +840,8 @@ export class UnwrappedEventTile extends React.Component
// Electron layer (webcontents-handler.ts)
if (clickTarget instanceof HTMLImageElement) return;
- // Return if we're in a browser and click either an a tag or we have
- // selected text, as in those cases we want to use the native browser
- // menu
- if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && (getSelectedText() || anchorElement)) return;
+ // Return if we're in a browser and click either an a tag, as in those cases we want to use the native browser menu
+ if (!PlatformPeg.get()?.allowOverridingNativeContextMenus() && anchorElement) return;
// We don't want to show the menu when editing a message
if (this.props.editState) return;
@@ -1237,22 +1235,19 @@ export class UnwrappedEventTile extends React.Component
,
],
);
@@ -1433,23 +1425,20 @@ export class UnwrappedEventTile extends React.Component
{groupTimestamp}
{groupPadlock}
{replyChain}
- {renderTile(
- this.context.timelineRenderingType,
- {
- ...this.props,
+ {renderTile(this.context.timelineRenderingType, {
+ ...this.props,
- // overrides
- ref: this.tile,
- isSeeingThroughMessageHiddenForModeration,
- timestamp: bubbleTimestamp,
+ // overrides
+ ref: this.tile,
+ isSeeingThroughMessageHiddenForModeration,
+ timestamp: bubbleTimestamp,
- // appease TS
- highlights: this.props.highlights,
- highlightLink: this.props.highlightLink,
- permalinkCreator: this.props.permalinkCreator,
- },
- this.context.showHiddenEvents,
- )}
+ // appease TS
+ highlights: this.props.highlights,
+ highlightLink: this.props.highlightLink,
+ permalinkCreator: this.props.permalinkCreator,
+ showHiddenEvents: this.context.showHiddenEvents,
+ })}
{actionBar}
{this.props.layout === Layout.IRC && (
<>
diff --git a/src/components/views/rooms/MemberList/MemberListView.tsx b/src/components/views/rooms/MemberList/MemberListView.tsx
index 0b5629685c..8901a77b95 100644
--- a/src/components/views/rooms/MemberList/MemberListView.tsx
+++ b/src/components/views/rooms/MemberList/MemberListView.tsx
@@ -95,7 +95,7 @@ const MemberListView: React.FC = (props: IProps) => {
className="mx_MemberListView_container"
onKeyDown={onKeyDownHandler}
>
-
+ e.preventDefault()}>
diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx
index 2db56e397e..cf80b38272 100644
--- a/src/components/views/rooms/ReplyTile.tsx
+++ b/src/components/views/rooms/ReplyTile.tsx
@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent {
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator,
+ showHiddenEvents: false,
},
false /* showHiddenEvents shouldn't be relevant */,
)}
diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx
new file mode 100644
index 0000000000..0769d9e40a
--- /dev/null
+++ b/src/components/views/rooms/RoomListPanel/RoomListItemContextMenuView.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+import { type Room } from "matrix-js-sdk/src/matrix";
+import { type JSX, type PropsWithChildren } from "react";
+import { ContextMenu } from "@vector-im/compound-web";
+import React from "react";
+
+import { _t } from "../../../../languageHandler";
+import { MoreOptionContent } from "./RoomListItemMenuView";
+import { useRoomListItemMenuViewModel } from "../../../viewmodels/roomlist/RoomListItemMenuViewModel";
+
+interface RoomListItemContextMenuViewProps {
+ /**
+ * The room to display the menu for.
+ */
+ room: Room;
+ /**
+ * Set the menu open state.
+ */
+ setMenuOpen: (isOpen: boolean) => void;
+}
+
+/**
+ * A view for the room list item context menu.
+ */
+export function RoomListItemContextMenuView({
+ room,
+ setMenuOpen,
+ children,
+}: PropsWithChildren): JSX.Element {
+ const vm = useRoomListItemMenuViewModel(room);
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx
index fa7a85b54f..a901003342 100644
--- a/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx
+++ b/src/components/views/rooms/RoomListPanel/RoomListItemMenuView.tsx
@@ -35,7 +35,6 @@ interface RoomListItemMenuViewProps {
room: Room;
/**
* Set the menu open state.
- * @param isOpen
*/
setMenuOpen: (isOpen: boolean) => void;
}
@@ -84,6 +83,21 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
align="start"
trigger={}
>
+
+
+ );
+}
+
+interface MoreOptionContentProps {
+ /**
+ * The view model state for the menu.
+ */
+ vm: RoomListItemMenuViewState;
+}
+
+export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
+ return (
+ <>
{vm.canMarkAsRead && (
-
{vm.messagePreview}
+ {vm.messagePreview && (
+
+ {vm.messagePreview}
+
+ )}
{showHoverMenu ? (
{
- if (isOpen) {
- setIsMenuOpen(isOpen);
- } else {
- // To avoid icon blinking when closing the menu, we delay the state update
- setTimeout(() => setIsMenuOpen(isOpen), 0);
- // After closing the menu, we need to set the focus back to the button
- // 10ms because the focus moves to the body and we put back the focus on the button
- setTimeout(() => buttonRef.current?.focus(), 10);
- }
- }}
+ setMenuOpen={(isOpen) => (isOpen ? setIsMenuOpen(true) : closeMenu())}
/>
) : (
<>
@@ -120,6 +121,24 @@ export const RoomListItemView = memo(function RoomListItemView({
);
+
+ if (!vm.showContextMenu) return content;
+
+ return (
+ {
+ if (isOpen) {
+ // To avoid icon blinking when the context menu is re-opened
+ setTimeout(() => setIsMenuOpen(true), 0);
+ } else {
+ closeMenu();
+ }
+ }}
+ >
+ {content}
+
+ );
});
/**
diff --git a/src/components/views/settings/SettingsHeader.tsx b/src/components/views/settings/SettingsHeader.tsx
index 10534958f4..1db7fc9027 100644
--- a/src/components/views/settings/SettingsHeader.tsx
+++ b/src/components/views/settings/SettingsHeader.tsx
@@ -6,10 +6,9 @@
*/
import React, { type JSX } from "react";
+import classNames from "classnames";
import { Heading } from "@vector-im/compound-web";
-import { _t } from "../../../languageHandler";
-
/**
* The heading for a settings section.
*/
@@ -25,9 +24,12 @@ interface SettingsHeaderProps {
}
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
+ const classes = classNames("mx_SettingsHeader", {
+ mx_SettingsHeader_recommended: hasRecommendedTag,
+ });
return (
-
- {label} {hasRecommendedTag && {_t("common|recommended")}}
+
+ {label}
);
}
diff --git a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
index 58efc80afb..b096708947 100644
--- a/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
+++ b/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
@@ -29,6 +29,7 @@ import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydra
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
+import { RECOVERY_ACCOUNT_DATA_KEY } from "../../../../DeviceListener";
/**
* The possible states of the component.
@@ -131,6 +132,10 @@ export function ChangeRecoveryKey({
});
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
});
+
+ // Record the fact that the user explicitly enabled recovery.
+ await matrixClient.setAccountData(RECOVERY_ACCOUNT_DATA_KEY, { enabled: true });
+
onFinish();
} catch (e) {
logErrorAndShowErrorDialog("Failed to set up secret storage", e);
diff --git a/src/components/views/settings/notifications/NotificationSettings2.tsx b/src/components/views/settings/notifications/NotificationSettings2.tsx
index 4e91bebf7b..405d789257 100644
--- a/src/components/views/settings/notifications/NotificationSettings2.tsx
+++ b/src/components/views/settings/notifications/NotificationSettings2.tsx
@@ -182,7 +182,7 @@ export default function NotificationSettings2(): JSX.Element {
description={_t("settings|notifications|play_sound_for_description")}
>
{
diff --git a/src/events/EventTileFactory.tsx b/src/events/EventTileFactory.tsx
index f1fc224471..9d0daadb80 100644
--- a/src/events/EventTileFactory.tsx
+++ b/src/events/EventTileFactory.tsx
@@ -26,7 +26,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent";
-import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
@@ -43,6 +42,9 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
+import ModuleApi from "../modules/Api";
+import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
+import { TextualEvent } from "../shared-components/event-tiles/TextualEvent";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -66,6 +68,7 @@ export interface EventTileTypeProps
maxImageHeight?: number; // pixels
overrideBodyTypes?: Record>;
overrideEventTypes?: Record>;
+ showHiddenEvents: boolean;
}
type FactoryProps = Omit;
@@ -76,7 +79,10 @@ const LegacyCallEventFactory: Factory
);
const CallEventFactory: Factory = (ref, props) => ;
-export const TextualEventFactory: Factory = (ref, props) => ;
+export const TextualEventFactory: Factory = (ref, props) => {
+ const vm = new TextualEventViewModel(props);
+ return ;
+};
const VerificationReqFactory: Factory = (_ref, props) => ;
const HiddenEventFactory: Factory = (ref, props) => ;
@@ -251,13 +257,19 @@ export function pickFactory(
export function renderTile(
renderType: TimelineRenderingType,
props: EventTileTypeProps,
- showHiddenEvents: boolean,
cli?: MatrixClient,
): Optional {
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
- const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
- if (!factory) return undefined;
+ const factory = pickFactory(props.mxEvent, cli, props.showHiddenEvents);
+ if (!factory) {
+ // If we don't have a factory for this event, attempt
+ // to find a custom component that can render it.
+ // Will return null if no custom component can render it.
+ return ModuleApi.customComponents.renderMessage({
+ mxEvent: props.mxEvent,
+ });
+ }
// Note that we split off the ones we actually care about here just to be sure that we're
// not going to accidentally send things we shouldn't from lazy callers. Eg: EventTile's
@@ -278,42 +290,57 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
+ showHiddenEvents,
} = props;
switch (renderType) {
case TimelineRenderingType.File:
case TimelineRenderingType.Notification:
case TimelineRenderingType.Thread:
- // We only want a subset of props, so we don't end up causing issues for downstream components.
- return factory(props.ref, {
- mxEvent,
- highlights,
- highlightLink,
- showUrlPreview,
- editState,
- replacingEventId,
- getRelationsForEvent,
- isSeeingThroughMessageHiddenForModeration,
- permalinkCreator,
- inhibitInteraction,
- });
+ return ModuleApi.customComponents.renderMessage(
+ {
+ mxEvent: props.mxEvent,
+ },
+ (origProps) =>
+ factory(props.ref, {
+ // We only want a subset of props, so we don't end up causing issues for downstream components.
+ mxEvent,
+ highlights,
+ highlightLink,
+ showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
+ editState,
+ replacingEventId,
+ getRelationsForEvent,
+ isSeeingThroughMessageHiddenForModeration,
+ permalinkCreator,
+ inhibitInteraction,
+ showHiddenEvents,
+ }),
+ );
default:
- // NEARLY ALL THE OPTIONS!
- return factory(ref, {
- mxEvent,
- forExport,
- replacingEventId,
- editState,
- highlights,
- highlightLink,
- showUrlPreview,
- permalinkCreator,
- callEventGrouper,
- getRelationsForEvent,
- isSeeingThroughMessageHiddenForModeration,
- timestamp,
- inhibitInteraction,
- });
+ return ModuleApi.customComponents.renderMessage(
+ {
+ mxEvent: props.mxEvent,
+ },
+ (origProps) =>
+ factory(ref, {
+ // NEARLY ALL THE OPTIONS!
+ mxEvent,
+ forExport,
+ replacingEventId,
+ editState,
+ highlights,
+ highlightLink,
+ showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
+ permalinkCreator,
+ callEventGrouper,
+ getRelationsForEvent,
+ isSeeingThroughMessageHiddenForModeration,
+ timestamp,
+ inhibitInteraction,
+ showHiddenEvents,
+ }),
+ );
}
}
@@ -332,7 +359,14 @@ export function renderReplyTile(
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
- if (!factory) return undefined;
+ if (!factory) {
+ // If we don't have a factory for this event, attempt
+ // to find a custom component that can render it.
+ // Will return null if no custom component can render it.
+ return ModuleApi.customComponents.renderMessage({
+ mxEvent: props.mxEvent,
+ });
+ }
// See renderTile() for why we split off so much
const {
@@ -350,19 +384,26 @@ export function renderReplyTile(
permalinkCreator,
} = props;
- return factory(ref, {
- mxEvent,
- highlights,
- highlightLink,
- showUrlPreview,
- overrideBodyTypes,
- overrideEventTypes,
- replacingEventId,
- maxImageHeight,
- getRelationsForEvent,
- isSeeingThroughMessageHiddenForModeration,
- permalinkCreator,
- });
+ return ModuleApi.customComponents.renderMessage(
+ {
+ mxEvent: props.mxEvent,
+ },
+ (origProps) =>
+ factory(ref, {
+ mxEvent,
+ highlights,
+ highlightLink,
+ showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
+ overrideBodyTypes,
+ overrideEventTypes,
+ replacingEventId,
+ maxImageHeight,
+ getRelationsForEvent,
+ isSeeingThroughMessageHiddenForModeration,
+ permalinkCreator,
+ showHiddenEvents,
+ }),
+ );
}
// XXX: this'll eventually be dynamic based on the fields once we have extensible event types
@@ -386,6 +427,12 @@ export function haveRendererForEvent(
return false;
}
+ // Check to see if we have any hints for this message, which indicates
+ // there is a custom renderer for the event.
+ if (ModuleApi.customComponents.getHintsForMessage(mxEvent)) {
+ return true;
+ }
+
// No tile for replacement events since they update the original tile
if (mxEvent.isRelation(RelationType.Replace)) return false;
diff --git a/src/favicon.ts b/src/favicon.ts
index e109c73460..7ff95e2f50 100644
--- a/src/favicon.ts
+++ b/src/favicon.ts
@@ -1,5 +1,5 @@
/*
-Copyright 2020-2024 New Vector Ltd.
+Copyright 2020-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.
@@ -28,56 +28,19 @@ const defaults: IParams = {
isLeft: false,
};
-// Allows dynamic rendering of a circular badge atop the loaded favicon
-// supports colour, font and basic positioning parameters.
-// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
-export default class Favicon {
- private readonly browser = {
- ff: typeof window.InstallTrigger !== "undefined",
- opera: !!window.opera || navigator.userAgent.includes("Opera"),
- };
-
- private readonly params: IParams;
- private readonly canvas: HTMLCanvasElement;
- private readonly baseImage: HTMLImageElement;
- private context!: CanvasRenderingContext2D;
- private icons: HTMLLinkElement[];
-
- private isReady = false;
- // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
- private readyCb?: () => void;
-
- public constructor(params: Partial = {}) {
- this.params = { ...defaults, ...params };
-
- this.icons = Favicon.getIcons();
- // create work canvas
+abstract class IconRenderer {
+ protected readonly canvas: HTMLCanvasElement;
+ protected readonly context: CanvasRenderingContext2D;
+ public constructor(
+ protected readonly params: IParams = defaults,
+ protected readonly baseImage?: HTMLImageElement,
+ ) {
this.canvas = document.createElement("canvas");
- // create clone of favicon as a base
- this.baseImage = document.createElement("img");
-
- const lastIcon = this.icons[this.icons.length - 1];
- if (lastIcon.hasAttribute("href")) {
- this.baseImage.setAttribute("crossOrigin", "anonymous");
- this.baseImage.onload = (): void => {
- // get height and width of the favicon
- this.canvas.height = this.baseImage.height > 0 ? this.baseImage.height : 32;
- this.canvas.width = this.baseImage.width > 0 ? this.baseImage.width : 32;
- this.context = this.canvas.getContext("2d")!;
- this.ready();
- };
- this.baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
- } else {
- this.canvas.height = this.baseImage.height = 32;
- this.canvas.width = this.baseImage.width = 32;
- this.context = this.canvas.getContext("2d")!;
- this.ready();
+ const context = this.canvas.getContext("2d");
+ if (!context) {
+ throw Error("Could not get canvas context");
}
- }
-
- private reset(): void {
- this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
- this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
+ this.context = context;
}
private options(
@@ -125,11 +88,23 @@ export default class Favicon {
return opt;
}
- private circle(n: number | string, opts?: Partial): void {
+ /**
+ * Draws a circualr status icon, usually over the top of the application icon.
+ * @param n The content of the circle. Should be a number or a single character.
+ * @param opts Options to adjust.
+ */
+ protected circle(n: number | string, opts?: Partial): void {
const params = { ...this.params, ...opts };
const opt = this.options(n, params);
let more = false;
+ if (!this.baseImage) {
+ // If we omit the background, assume the entire canvas is our target.
+ opt.x = 0;
+ opt.y = 0;
+ opt.w = this.canvas.width;
+ opt.h = this.canvas.height;
+ }
if (opt.len === 2) {
opt.x = opt.x - opt.w * 0.4;
opt.w = opt.w * 1.4;
@@ -141,7 +116,9 @@ export default class Favicon {
}
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
- this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
+ if (this.baseImage) {
+ this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
+ }
this.context.beginPath();
const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px";
this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`;
@@ -177,6 +154,86 @@ export default class Favicon {
this.context.closePath();
}
+}
+
+export class BadgeOverlayRenderer extends IconRenderer {
+ public constructor() {
+ super();
+ // Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
+ this.canvas.width = 16;
+ this.canvas.height = 16;
+ }
+
+ /**
+ * Generate an overlay badge without the application icon, and export
+ * as an ArrayBuffer
+ * @param contents The content of the circle. Should be a number or a single character.
+ * @param bgColor Optional alternative background colo.r
+ * @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn.
+ */
+ public async render(contents: number | string, bgColor?: string): Promise {
+ if (contents === 0) {
+ return null;
+ }
+
+ this.circle(contents, { ...(bgColor ? { bgColor } : undefined) });
+ return new Promise((resolve, reject) => {
+ this.canvas.toBlob(
+ (blob) => {
+ if (blob) {
+ resolve(blob.arrayBuffer());
+ }
+ reject(new Error("Could not render badge overlay as blob"));
+ },
+ "image/png",
+ 1,
+ );
+ });
+ }
+}
+
+// Allows dynamic rendering of a circular badge atop the loaded favicon
+// supports colour, font and basic positioning parameters.
+// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
+export default class Favicon extends IconRenderer {
+ private readonly browser = {
+ ff: typeof window.InstallTrigger !== "undefined",
+ opera: !!window.opera || navigator.userAgent.includes("Opera"),
+ };
+
+ private icons: HTMLLinkElement[];
+
+ private isReady = false;
+ // callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
+ private readyCb?: () => void;
+
+ public constructor() {
+ const baseImage = document.createElement("img");
+ super(defaults, baseImage);
+
+ this.icons = Favicon.getIcons();
+
+ const lastIcon = this.icons[this.icons.length - 1];
+ if (lastIcon.hasAttribute("href")) {
+ baseImage.setAttribute("crossOrigin", "anonymous");
+ baseImage.onload = (): void => {
+ // get height and width of the favicon
+ this.canvas.height = baseImage.height > 0 ? baseImage.height : 32;
+ this.canvas.width = baseImage.width > 0 ? baseImage.width : 32;
+ this.ready();
+ };
+ baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
+ } else {
+ this.canvas.height = baseImage.height = 32;
+ this.canvas.width = baseImage.width = 32;
+ this.ready();
+ }
+ }
+
+ private reset(): void {
+ this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
+ this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height);
+ }
private ready(): void {
if (this.isReady) return;
diff --git a/src/hooks/useDownloadMedia.ts b/src/hooks/useDownloadMedia.ts
new file mode 100644
index 0000000000..74328ac7ca
--- /dev/null
+++ b/src/hooks/useDownloadMedia.ts
@@ -0,0 +1,93 @@
+/*
+Copyright 2024 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 { parseErrorResponse } from "matrix-js-sdk/src/matrix";
+import { useRef, useState, useMemo, useEffect } from "react";
+import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
+import { logger } from "matrix-js-sdk/src/logger";
+
+import ErrorDialog from "../components/views/dialogs/ErrorDialog";
+import { _t } from "../languageHandler";
+import Modal from "../Modal";
+import { FileDownloader } from "../utils/FileDownloader";
+import { MediaEventHelper } from "../utils/MediaEventHelper";
+import ModuleApi from "../modules/Api";
+
+export interface UseDownloadMediaReturn {
+ download: () => Promise;
+ loading: boolean;
+ canDownload: boolean;
+}
+
+export function useDownloadMedia(url: string, fileName?: string, mxEvent?: MatrixEvent): UseDownloadMediaReturn {
+ const downloader = useRef(new FileDownloader()).current;
+ const blobRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const [canDownload, setCanDownload] = useState(true);
+
+ const mediaEventHelper = useMemo(() => (mxEvent ? new MediaEventHelper(mxEvent) : undefined), [mxEvent]);
+
+ useEffect(() => {
+ if (!mxEvent) return;
+
+ const hints = ModuleApi.customComponents.getHintsForMessage(mxEvent);
+ if (hints?.allowDownloadingMedia) {
+ setCanDownload(false);
+ hints
+ .allowDownloadingMedia()
+ .then(setCanDownload)
+ .catch((err: any) => {
+ logger.error(`Failed to check media download permission for ${mxEvent.event.event_id}`, err);
+
+ setCanDownload(false);
+ });
+ } else {
+ setCanDownload(true);
+ }
+ }, [mxEvent]);
+
+ const download = async (): Promise => {
+ if (loading) return;
+ try {
+ setLoading(true);
+
+ if (blobRef.current) {
+ return downloadBlob(blobRef.current);
+ }
+
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw parseErrorResponse(res, await res.text());
+ }
+
+ const blob = await res.blob();
+ blobRef.current = blob;
+
+ await downloadBlob(blob);
+ } catch (e) {
+ showError(e);
+ }
+ };
+
+ const downloadBlob = async (blob: Blob): Promise => {
+ await downloader.download({
+ blob,
+ name: mediaEventHelper?.fileName ?? fileName ?? _t("common|image"),
+ });
+ setLoading(false);
+ };
+
+ const showError = (e: unknown): void => {
+ Modal.createDialog(ErrorDialog, {
+ title: _t("timeline|download_failed"),
+ description: `${_t("timeline|download_failed_description")}\n\n${String(e)}`,
+ });
+ setLoading(false);
+ };
+
+ return { download, loading, canDownload };
+}
diff --git a/src/hooks/useEventEmitter.ts b/src/hooks/useEventEmitter.ts
index 67049195a0..12bb57a1ee 100644
--- a/src/hooks/useEventEmitter.ts
+++ b/src/hooks/useEventEmitter.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 { useRef, useEffect, useState, useCallback } from "react";
+import { useRef, useEffect, useState, useCallback, type DependencyList } from "react";
import { type ListenerMap, type TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import type { EventEmitter } from "events";
@@ -93,3 +93,100 @@ export function useEventEmitterState(
useEventEmitter(emitter, eventName, handler);
return value;
}
+
+/**
+ * The return value of the callback function for `useEventEmitterAsyncState`.
+ */
+export type AsyncStateCallbackResult = Promise;
+
+/**
+ * Creates a state, which is computed asynchronously, and can be updated by events.
+ *
+ * Similar to `useEventEmitterState`, but the callback is `async`.
+ *
+ * If the event is emitted while the callback is running, it will wait until
+ * after the callback completes before calling the callback again. If the event
+ * is emitted multiple times while the callback is running, the callback will be
+ * called once for each time the event was emitted, in the order that the events
+ * were emitted.
+ *
+ * @param emitter The emitter sending the event
+ * @param eventName Event name to listen for
+ * @param fn The callback function, that should return the state value.
+ * It should have the signature of the event callback, except that all
+ * parameters are optional. If the params are not set, a default value
+ * for the state should be returned. If the state value should not
+ * change from its previous value, the function can return a `NoChange`
+ * object.
+ * @param deps The dependencies of the callback function.
+ * @param initialValue The initial value of the state, before the callback finishes its initial run.
+ * @returns State
+ */
+export function useEventEmitterAsyncState>(
+ emitter: TypedEventEmitter | undefined,
+ eventName: string | symbol,
+ fn: Mapper>,
+ deps: DependencyList,
+ initialValue: T,
+): T;
+export function useEventEmitterAsyncState>(
+ emitter: TypedEventEmitter | undefined,
+ eventName: string | symbol,
+ fn: Mapper>,
+ deps: DependencyList,
+ initialValue?: T,
+): T | undefined;
+export function useEventEmitterAsyncState>(
+ emitter: TypedEventEmitter | undefined,
+ eventName: string | symbol,
+ fn: Mapper>,
+ deps: DependencyList,
+ initialValue?: T,
+): T | undefined {
+ const [value, setValue] = useState(initialValue);
+
+ let running = false;
+ // If the handler is called while it's already running, we remember the
+ // arguments that it was called with, and call the handler again when the
+ // first call is done.
+ const rerunArgs: any[] = [];
+
+ const handler = useCallback(
+ (...args: any[]) => {
+ if (running) {
+ // We're already running, so remember the arguments we were
+ // called with, so that we can call the handler again when we're
+ // done.
+ rerunArgs.push(args);
+ return;
+ }
+ running = true; // eslint-disable-line react-hooks/exhaustive-deps
+ // Note: We need to use .then notation instead of async/await,
+ // because async/await would cause this function to return a
+ // promise, which `useEffect` doesn't like.
+ fn(...args)
+ .then((v) => {
+ if (!(v instanceof NoChange)) {
+ setValue(v);
+ }
+ })
+ .finally(() => {
+ running = false;
+ if (rerunArgs.length != 0) {
+ handler(...rerunArgs.shift());
+ }
+ });
+ },
+ [fn, ...deps], // eslint-disable-line react-compiler/react-compiler
+ );
+
+ // re-run when the emitter changes
+ useEffect(handler, [emitter, handler, ...deps]);
+ useEventEmitter(emitter, eventName, handler);
+ return value;
+}
+
+/**
+ * Indicates that the callback for `useEventEmitterAsyncState` is not changing the value of the state.
+ */
+export class NoChange {}
diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json
index d736aa3d6f..1fb8217dfd 100644
--- a/src/i18n/strings/cs.json
+++ b/src/i18n/strings/cs.json
@@ -242,8 +242,7 @@
"setup_key_backup_title": "Přijdete o přístup k šifrovaným zprávám",
"setup_secure_backup_description_1": "Šifrované zprávy jsou zabezpečené koncovým šifrováním. Klíče pro jejich dešifrování máte jen vy a příjemci zpráv.",
"setup_secure_backup_description_2": "Po odhlášení budou tyto klíče z tohoto zařízení odstraněny, což znamená, že nebudete moci číst zašifrované zprávy, pokud k nim nemáte klíče v jiných zařízeních nebo je nemáte zálohované na serveru.",
- "skip_key_backup": "Už své zašifrované zprávy nechci",
- "use_key_backup": "Začít používat zálohu klíčů"
+ "skip_key_backup": "Už své zašifrované zprávy nechci"
},
"misconfigured_body": "Požádejte správce vašeho %(brand)su, aby zkontroloval vaši konfiguraci. Pravděpodobně obsahuje chyby nebo duplicity.",
"misconfigured_title": "%(brand)s je špatně nakonfigurován",
@@ -788,6 +787,7 @@
"cross_signing_status": "Stav křížového podepisování:",
"cross_signing_untrusted": "Váš účet má v bezpečném úložišti identitu pro křížový podpis, ale v této relaci jí zatím nevěříte.",
"crypto_not_available": "Kryptografický modul není k dispozici",
+ "device_id": "ID zařízení",
"key_backup_active_version": "Verze aktivní zálohy:",
"key_backup_active_version_none": "Žádné",
"key_backup_inactive_warning": "Vaše klíče nejsou z této relace zálohovány.",
@@ -800,6 +800,8 @@
"secret_storage_ready": "připraveno",
"secret_storage_status": "Bezpečné úložiště:",
"self_signing_private_key_cached_status": "Soukromý klíč s vlastním podpisem:",
+ "session": "Relace",
+ "session_fingerprint": "Otisk prstu (klíč relace)",
"title": "Koncové šifrování",
"user_signing_private_key_cached_status": "Podpisový soukromý klíč uživatele:"
},
@@ -825,6 +827,7 @@
"low_bandwidth_mode": "Režim malé šířky pásma",
"low_bandwidth_mode_description": "Vyžaduje kompatibilní domovský server.",
"main_timeline": "Hlavní časová osa",
+ "manual_device_verification": "Ruční ověření zařízení",
"no_receipt_found": "Žádné potvrzení o přečtení",
"notification_state": "Stav oznámení je %(notificationState)s",
"notifications_debug": "Ladění oznámení",
@@ -966,9 +969,7 @@
},
"reset_all_button": "Zapomněli nebo ztratili jste všechny metody obnovy? Resetovat vše",
"set_up_recovery": "Nastavení obnovení",
- "set_up_recovery_later": "Teď ne",
"set_up_recovery_toast_description": "Vygenerujte klíč pro obnovení, který lze použít k obnovení historie šifrovaných zpráv v případě, že ztratíte přístup k zařízením.",
- "set_up_toast_description": "Zabezpečení proti ztrátě přístupu k šifrovaným zprávám a datům",
"set_up_toast_title": "Nastavení zabezpečené zálohy",
"setup_secure_backup": {
"explainer": "Před odhlášením si zazálohujte klíče abyste o ně nepřišli."
@@ -1009,6 +1010,21 @@
"incoming_sas_dialog_waiting": "Čekání na potvrzení partnerem…",
"incoming_sas_user_dialog_text_1": "Po ověření bude uživatel označen jako důvěryhodný. Ověřování uživatelů vám dává větší jistotu, že je komunikace důvěrná.",
"incoming_sas_user_dialog_text_2": "Ověření uživatele označí jeho relace za důvěryhodné a vaše relace budou důvěryhodné pro něj.",
+ "manual": {
+ "already_verified": "Toto zařízení je již ověřeno",
+ "already_verified_and_wrong_fingerprint": "Zadaný otisk prstu se neshoduje, ale zařízení je již ověřené!",
+ "device_id": "ID zařízení",
+ "failure_description": "Nepodařilo se ověřit '%(deviceId)s': %(error)s",
+ "failure_title": "Ověření se nezdařilo",
+ "fingerprint": "Otisk prstu (klíč relace)",
+ "no_crypto": "Nelze ověřit zařízení - šifrování není povoleno",
+ "no_device": "Nelze ověřit zařízení - zařízení '%(deviceId)s' nebylo nalezeno",
+ "no_userid": "Nelze ověřit zařízení - nelze najít naše ID uživatele",
+ "success_description": "Zařízení (%(deviceId)s) je nyní křížově podepsáno",
+ "success_title": "Ověření proběhlo úspěšně",
+ "text": "Zadejte ID a otisk prstu jednoho ze svých vlastních zařízení a ověřte jej. POZNÁMKA to umožňuje druhému zařízení odesílat a přijímat zprávy jako vy. POKUD VÁM NĚKDO ŘEKL, ABYSTE SEM NĚCO VLOŽILI, JE PRAVDĚPODOBNÉ, ŽE JSTE PODVEDENI!",
+ "wrong_fingerprint": "Nelze ověřit zařízení '%(deviceId)s' - zadaný otisk prstu '%(fingerprint)s' neodpovídá otisku prstu zařízení, '%(fprint)s'"
+ },
"no_key_or_device": "Vypadá to, že nemáte klíč pro obnovení ani žádné jiné zařízení, které byste mohli ověřit. Toto zařízení nebude mít přístup ke starým zašifrovaným zprávám. Abyste mohli na tomto zařízení ověřit svou identitu, budete muset obnovit ověřovací klíče.",
"no_support_qr_emoji": "Zařízení, které se snažíte ověřit, neumožňuje ověření QR kódem ani pomocí emotikonů, které %(brand)s podporuje. Zkuste použít jiného klienta.",
"other_party_cancelled": "Druhá strana ověření zrušila.",
@@ -1958,6 +1974,7 @@
},
"face_pile_tooltip_shortcut": "Včetně %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Včetně vás, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Nelze určit, kterého uživatele ignorovat, protože se změnila událost člena.",
"failed_reject_invite": "Nepodařilo se odmítnout pozvánku",
"forget_room": "Zapomenout na tuto místnost",
"forget_space": "Zapomenout tento prostor",
@@ -2050,6 +2067,8 @@
"read_topic": "Klikněte pro přečtení tématu",
"rejecting": "Odmítání pozvánky…",
"rejoin_button": "Znovu vstoupit",
+ "room_content": "Obsah místnosti",
+ "room_is_low_priority": "Toto je místnost s nízkou prioritou",
"search": {
"all_rooms_button": "Vyhledávat ve všech místnostech",
"placeholder": "Hledat zprávy…",
@@ -2100,6 +2119,7 @@
"add_space_label": "Přidat prostor",
"breadcrumbs_empty": "Žádné nedávno navštívené místnosti",
"breadcrumbs_label": "Nedávno navštívené místnosti",
+ "collapse_filters": "Sbalit seznam filtrů",
"empty": {
"no_chats": "Zatím žádné chaty",
"no_chats_description": "Začněte tím, že někomu pošlete zprávu nebo vytvoříte místnost",
@@ -2107,6 +2127,7 @@
"no_favourites": "Zatím nemáte oblíbený chat",
"no_favourites_description": "Chat si můžete přidat do oblíbených v nastavení chatu",
"no_invites": "Nemáte žádné nepřečtené pozvánky",
+ "no_lowpriority": "Nemáte žádné místnosti s nízkou prioritou",
"no_mentions": "Nemáte žádné nepřečtené zmínky",
"no_people": "Zatím s nikým nemáte přímé chaty",
"no_people_description": "Můžete zrušit výběr filtrů, abyste viděli ostatní chaty",
@@ -2116,6 +2137,7 @@
"show_activity": "Zobrazit veškerou aktivitu",
"show_chats": "Zobrazit všechny chaty"
},
+ "expand_filters": "Rozbalit seznam filtrů",
"failed_add_tag": "Nepodařilo se přidat štítek %(tagName)s k místnosti",
"failed_remove_tag": "Nepodařilo se odstranit štítek %(tagName)s z místnosti",
"failed_set_dm_tag": "Nepodařilo se nastavit značku přímé zprávy",
@@ -2690,13 +2712,11 @@
"inline_url_previews_room": "Povolit náhledy URL adres pro členy této místnosti jako výchozí",
"inline_url_previews_room_account": "Povolit náhledy URL adres pro tuto místnost (ovlivňuje pouze vás)",
"insert_trailing_colon_mentions": "Vložit dvojtečku za zmínku o uživateli na začátku zprávy",
+ "invite_controls": {
+ "default_label": "Povolit uživatelům pozvat vás do místností"
+ },
"jump_to_bottom_on_send": "Po odeslání zprávy přejít na konec časové osy",
"key_backup": {
- "backup_in_progress": "Klíče se zálohují (první záloha může trvat pár minut).",
- "backup_starting": "Zahájení zálohování…",
- "backup_success": "Úspěch!",
- "cannot_create_backup": "Nepovedlo se vyrobit zálohů klíčů",
- "create_title": "Vytvořit zálohu klíčů",
"setup_secure_backup": {
"backup_setup_success_description": "Vaše klíče jsou nyní zálohovány z tohoto zařízení.",
"backup_setup_success_title": "Bezpečné zálohování bylo úspěšné",
@@ -2756,6 +2776,7 @@
"show_in_private": "V soukromých místnostech",
"show_media": "Vždy zobrazit"
},
+ "not_supported": "Váš server tuto funkci neimplementuje.",
"notifications": {
"default_setting_description": "Toto nastavení se ve výchozím stavu použije pro všechny vaše místnosti.",
"default_setting_section": "Chci být upozorňován na (Výchozí nastavení)",
@@ -2813,6 +2834,7 @@
"voip": "Hlasové a video hovory"
},
"preferences": {
+ "Electron.enableContentProtection": "Zabraňte zachycení obsahu okna jinými aplikacemi",
"Electron.enableHardwareAcceleration": "Povolit hardwarovou akceleraci (restaurtujte %(appName)s, aby se změna projevila)",
"always_show_menu_bar": "Vždy zobrazovat horní lištu okna",
"autocomplete_delay": "Zpožnění našeptávače (ms)",
@@ -2854,7 +2876,6 @@
"ignore_users_empty": "Nemáte žádné ignorované uživatele.",
"ignore_users_section": "Ignorovaní uživatelé",
"key_backup_algorithm": "Algoritmus:",
- "key_backup_connect": "Připojit k zálohování klíčů",
"message_search_disable_warning": "Když je to zakázané, zprávy v šifrovaných místnostech se nebudou objevovat ve výsledcích vyhledávání.",
"message_search_disabled": "Bezpečně uchovávat zprávy na tomto zařízení aby se v nich dalo vyhledávat.",
"message_search_enabled": {
@@ -2986,6 +3007,7 @@
"show_chat_effects": "Zobrazit efekty chatu (animace např. při přijetí konfet)",
"show_displayname_changes": "Zobrazovat změny zobrazovaného jména",
"show_join_leave": "Zobrazit zprávy o vstupu/odchodu (pozvánky/odebrání/vykázání nejsou ovlivněny)",
+ "show_message_previews": "Zobrazit náhledy zpráv",
"show_nsfw_content": "Zobrazit NSFW obsah",
"show_read_receipts": "Zobrazovat potvrzení o přečtení",
"show_redaction_placeholder": "Zobrazovat smazané zprávy",
@@ -3092,6 +3114,8 @@
"jumptodate": "Přejít na zadané datum na časové ose",
"jumptodate_invalid_input": "Nebyli jsme schopni porozumět zadanému datu (%(inputDate)s). Zkuste použít formát RRRR-MM-DD.",
"lenny": "Vloží ( ͡° ͜ʖ ͡°) na začátek zprávy",
+ "manual_device_verification_confirm_description": "To umožní jinému zařízení odesílat a přijímat zprávy jako vy. POKUD VÁM NĚKDO ŘEKL, ABYSTE SEM NĚCO VLOŽILI, JE PRAVDĚPODOBNÉ, ŽE JSTE PODVEDENI! Opravdu chcete ověřit toto další zařízení?",
+ "manual_device_verification_confirm_title": "Upozornění: ruční ověření zařízení",
"me": "Zobrazí akci",
"msg": "Pošle zprávu danému uživateli",
"myavatar": "Změní váš profilový obrázek ve všech místnostech",
@@ -3132,7 +3156,7 @@
"upgraderoom": "Aktualizuje místnost na novou verzi",
"upgraderoom_permission_error": "Na provedení tohoto příkazu nemáte dostatečná oprávnění.",
"usage": "Použití",
- "verify": "Ověří uživatele, relaci a veřejné klíče",
+ "verify": "Ruční ověření jednoho ze svých vlastních zařízení",
"view": "Zobrazí místnost s danou adresou",
"whois": "Zobrazuje informace o uživateli"
},
diff --git a/src/i18n/strings/cy.json b/src/i18n/strings/cy.json
index 2252975325..459318f64b 100644
--- a/src/i18n/strings/cy.json
+++ b/src/i18n/strings/cy.json
@@ -244,8 +244,7 @@
"setup_key_backup_title": "Byddwch yn colli mynediad i'ch negeseuon wedi'u hamgryptio",
"setup_secure_backup_description_1": "Mae negeseuon wedi'u hamgryptio yn cael eu diogelu gydag amgryptio o'r dechrau i'r diwedd. Dim ond chi a'r derbynwyr sydd â'r allweddi i ddarllen y negeseuon hyn.",
"setup_secure_backup_description_2": "Pan fyddwch yn allgofnodi, bydd yr allweddi hyn yn cael eu dileu o'r ddyfais hon, sy'n golygu na fyddwch yn gallu darllen negeseuon wedi'u hamgryptio oni bai bod gennych yr allweddi ar eu cyfer ar eich dyfeisiau eraill, neu eu bod wedi'u gwneud wrth gefn i'r gweinydd.",
- "skip_key_backup": "Dydw i ddim eisiau fy negeseuon wedi'u hamgryptio",
- "use_key_backup": "Dechreuwch ddefnyddio Key Backup"
+ "skip_key_backup": "Dydw i ddim eisiau fy negeseuon wedi'u hamgryptio"
},
"misconfigured_body": "Gofynnwch i'ch gweinyddwr %(brand)s wirio'ch ffurfweddiad am gofnodion anghywir neu ddyblyg.",
"misconfigured_title": "Mae eich %(brand)s wedi'i gam ffurfweddu",
@@ -476,6 +475,14 @@
"advanced": "Uwch",
"all_chats": "Pob Sgwrs",
"analytics": "Dadansoddi Gwe",
+ "and_n_others": {
+ "zero": "a dim arall...",
+ "one": "ac un arall...",
+ "two": "a %(count)s arall...",
+ "few": "a %(count)s arall...",
+ "many": "a %(count)s arall...",
+ "other": "a %(count)s arall..."
+ },
"appearance": "Gwedd",
"application": "Rhaglen",
"are_you_sure": "Ydych chi'n siŵr?",
@@ -530,6 +537,22 @@
"moderation_and_safety": "Cymedroli a diogelwch",
"modern": "Modern",
"mute": "Tewi",
+ "n_members": {
+ "zero": "%(count)s aelodau",
+ "one": "%(count)s aelod",
+ "two": "%(count)s aelod",
+ "few": "%(count)s aelod",
+ "many": "%(count)s aelod",
+ "other": "%(count)s aelod"
+ },
+ "n_rooms": {
+ "zero": "%(count)s ystafelloedd",
+ "one": "%(count)s ystafell",
+ "two": "%(count)s ystafell",
+ "few": "%(count)s ystafell",
+ "many": "%(count)s ystafell",
+ "other": "%(count)s ystafell"
+ },
"name": "Enw",
"no_results": "Dim canlyniadau",
"no_results_found": "Heb ganfod canlyniad",
@@ -778,6 +801,7 @@
"cross_signing_status": "Statws traws-arwyddo:",
"cross_signing_untrusted": "Mae gan eich cyfrif hunaniaeth traws-lofnodi mewn storfa gyfrinachol, ond nid yw'r sesiwn hon yn ymddiried ynddo eto.",
"crypto_not_available": "Nid yw'r modiwl cryptograffig ar gael",
+ "device_id": "ID dyfais",
"key_backup_active_version": "Fersiwn wrth gefn gweithredol:",
"key_backup_active_version_none": "Dim",
"key_backup_inactive_warning": "Nid yw'ch allweddi'n cael eu gwneud wrth gefn o'r sesiwn hon.",
@@ -790,6 +814,8 @@
"secret_storage_ready": "barod",
"secret_storage_status": "Storfa cyfrinachol:",
"self_signing_private_key_cached_status": "Allwedd breifat hunan-lofnodi:",
+ "session": "Sesiwn",
+ "session_fingerprint": "Bysbrint (allwedd sesiwn)",
"title": "Amgryptio o ben i ben",
"user_signing_private_key_cached_status": "Allwedd breifat llofnodi'r defnyddiwr:"
},
@@ -815,6 +841,7 @@
"low_bandwidth_mode": "Modd lled band isel",
"low_bandwidth_mode_description": "Angen gweinydd cartref cydnaws.",
"main_timeline": "Prif linell amser",
+ "manual_device_verification": "Dilysu dyfais â llaw",
"no_receipt_found": "Heb ganfod derbynneb",
"notification_state": "Y cyflwr hysbysu yw %(notificationState)s",
"notifications_debug": "Dadfygio hysbysiadau",
@@ -832,12 +859,8 @@
"room_notifications_type": "Math: ",
"room_status": "Statws ystafell",
"room_unread_status_count": {
- "zero": "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s",
- "one": "Statws heb ei ddarllen yn yr ystafell:%(status)s , cyfrif:%(count)s",
- "two": "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s",
- "few": "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s",
- "many": "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s",
- "other": "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s"
+ "Statws heb eu darllen yn yr ystafell:%(status)s , cyfrif:%(count)s": "other",
+ "Statws heb ei ddarllen yn yr ystafell:%(status)s , cyfrif:%(count)s": "one"
},
"save_setting_values": "Cadw gwerthoedd gosod",
"see_history": "Gweld hanes",
@@ -858,12 +881,10 @@
"settings_explorer": "Archwiliwr gosodiadau",
"show_hidden_events": "Dangos digwyddiadau cudd yn y llinell amser",
"spaces": {
- "zero": "<%(count) s gofodau>",
- "one": "",
- "two": "<%(count) s ofod>",
- "few": "<%(count) s gofod>",
- "many": "<%(count) s gofod>",
- "other": "<%(count) s gofod>"
+ "<%(count) s gofodau>": "zero",
+ "": "one",
+ "<%(count) s ofod>": "two",
+ "<%(count) s gofod>": "other"
},
"state_key": "Allwedd Cyflwr",
"thread_root_id": "ID Gwraidd Edefyn: %(threadRootId)s",
@@ -911,11 +932,13 @@
"empty_room_was_name": "Ystafell wag (roedd yn %(oldName)s)",
"encryption": {
"access_secret_storage_dialog": {
+ "alternatives": "Os oes gennych allwedd ddiogelwch neu ymadrodd diogelwch, bydd hyn yn gweithio hefyd.",
"key_validation_text": {
- "wrong_security_key": "Allwedd Adfer Anghywir"
+ "wrong_security_key": "Nid yw'r allwedd adfer rydych wedi'i roi yn gywir."
},
+ "privacy_warning": "Gwnewch yn siŵr nad oes neb yn gallu gweld y sgrin hon!",
"restoring": "Adfer allweddi o'r copi wrth gefn",
- "security_key_title": "Allwedd Adfer"
+ "security_key_title": "Allwedd adfer"
},
"bootstrap_title": "Gosod allweddi",
"confirm_encryption_setup_body": "Clicio'r botwm isod i gadarnhau gosod amgryptio.",
@@ -962,9 +985,7 @@
},
"reset_all_button": "Wedi anghofio neu golli pob dull adfer? Ailosod y cyfan",
"set_up_recovery": "Gosod adfer",
- "set_up_recovery_later": "Nid nawr",
"set_up_recovery_toast_description": "Cynhyrchwch allwedd adfer y mae modd ei defnyddio i adfer hanes eich neges wedi'i hamgryptio rhag ofn i chi golli mynediad i'ch dyfeisiau.",
- "set_up_toast_description": "Diogelu rhag colli mynediad i negeseuon a data wedi'u hamgryptio",
"set_up_toast_title": "Gosod Copi Wrth Gefn Diogel",
"setup_secure_backup": {
"explainer": "Gwnewch gopi wrth gefn o'ch allweddi cyn allgofnodi er mwyn osgoi eu colli."
@@ -1005,6 +1026,21 @@
"incoming_sas_dialog_waiting": "Yn aros i bartner gadarnhau…",
"incoming_sas_user_dialog_text_1": "Dilyswch y defnyddiwr hwn i'w nodi fel un y mae modd ymddiried ynddo. Mae ymddiried mewn defnyddwyr yn rhoi tawelwch meddwl ychwanegol i chi wrth ddefnyddio negeseuon wedi'u hamgryptio o'r dechrau i'r diwedd.",
"incoming_sas_user_dialog_text_2": "Bydd dilysu'r defnyddiwr hwn yn nodi ei sesiwn fel un y mae modd ymddiried ynddi, a hefyd yn nodi bod eich sesiwn yn ymddiried ynddo.",
+ "manual": {
+ "already_verified": "Mae'r ddyfais hon eisoes wedi'i gwirio",
+ "already_verified_and_wrong_fingerprint": "Nid yw'r bysbrintiau a gyflenwyd yn cyfateb, ond mae'r ddyfais eisoes wedi'i gwirio!",
+ "device_id": "ID dyfais",
+ "failure_description": "Methwyd gwirio '%(deviceId)s': %(error)s",
+ "failure_title": "Methodd y dilysu",
+ "fingerprint": "Bysbrint (allwedd sesiwn)",
+ "no_crypto": "Methu gwirio dyfais - nid yw crypto wedi'i alluogi",
+ "no_device": "Methu gwirio dyfais - heb ganfod dyfais '%(deviceId)s'",
+ "no_userid": "Methu gwirio dyfais - methu canfod ein ID Defnyddiwr",
+ "success_description": "Mae'r ddyfais (%(deviceId)s) bellach wedi'i chroes-lofnodi",
+ "success_title": "Dilysu llwyddiannus",
+ "text": "Rhowch ID ac olion bysedd un o'ch dyfeisiau eich hun i'w wirio. NODER bod hyn yn caniatáu i'r ddyfais arall anfon a derbyn negeseuon fel chi. OS DYWEDODD RHYWUN WRTHYCH CHI AM GLUDO RHYWBETH YMA, MAE'N DEBYGOL EICH BOD CHI'N CAEL EICH SGAMIO!",
+ "wrong_fingerprint": "Methu gwirio dyfais '%(deviceId)s' - nid yw'r bysbrintiau a ddarparwyd '%(fingerprint)s' yn cyfateb i fysbrintiau'r ddyfais, '%(fprint)s'"
+ },
"no_key_or_device": "Mae'n ymddangos nad oes gennych Allwedd Adfer nac unrhyw ddyfeisiau eraill y gallwch wirio yn eu herbyn. Ni fydd y ddyfais hon yn gallu cyrchu hen negeseuon wedi'u hamgryptio. Er mwyn gwirio pwy ydych ar y ddyfais hon, bydd angen i chi ailosod eich allweddi dilysu.",
"no_support_qr_emoji": "Nid yw'r ddyfais rydych chi'n ceisio'i dilysu yn cefnogi sganio cod QR na dilysiad emoji, sef yr hyn y mae %(brand)s yn ei gefnogi. Ceisiwch gyda chleient gwahanol.",
"other_party_cancelled": "Mae'r parti arall wedi diddymu'r dilysiad.",
@@ -1170,37 +1206,26 @@
"export_info": "Dyma ddechrau allforio o. Wedi'i allforio gan yn %(exportDate)s.",
"export_successful": "Allforio yn llwyddiannus!",
"exported_n_events_in_time": {
- "zero": "Wedi nôl %(count)s digwyddiadau o fewn %(seconds)s e",
- "one": "Wedi allforio %(count)s digwyddiad o fewn %(seconds)s e",
- "two": "Wedi nôl %(count)s ddigwyddiad o fewn %(seconds)s e",
- "few": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e",
- "many": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e",
- "other": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e"
+ "Wedi nôl %(count)s digwyddiadau o fewn %(seconds)s e": "zero",
+ "Wedi allforio %(count)s digwyddiad o fewn %(seconds)s e": "one",
+ "Wedi nôl %(count)s ddigwyddiad o fewn %(seconds)s e": "two",
+ "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e": "other"
},
"exporting_your_data": "Allforio eich data",
"fetched_n_events": {
- "zero": "Wedi nôl %(count)s digwyddiadau hyd yn hyn",
- "one": "Wedi nôl %(count)s digwyddiad hyd yn hyn",
- "two": "Wedi nôl %(count)s ddigwyddiad hyd yn hyn",
- "few": "Wedi nôl %(count)s digwyddiad hyd yn hyn",
- "many": "Wedi nôl %(count)s digwyddiad hyd yn hyn",
- "other": "Wedi nôl %(count)s digwyddiad hyd yn hyn"
+ "Wedi nôl %(count)s digwyddiadau hyd yn hyn": "zero",
+ "Wedi nôl %(count)s digwyddiad hyd yn hyn": "other",
+ "Wedi nôl %(count)s ddigwyddiad hyd yn hyn": "two"
},
"fetched_n_events_in_time": {
- "zero": "Wedi nôl %(count)s digwyddiadau o fewn %(seconds)s e",
- "one": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e",
- "two": "Wedi nôl %(count)s ddigwyddiad o fewn %(seconds)s e",
- "few": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e",
- "many": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e",
- "other": "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e"
+ "Wedi nôl %(count)s digwyddiadau o fewn %(seconds)s e": "zero",
+ "Wedi nôl %(count)s digwyddiad o fewn %(seconds)s e": "other",
+ "Wedi nôl %(count)s ddigwyddiad o fewn %(seconds)s e": "two"
},
"fetched_n_events_with_total": {
- "zero": "Wedi nôl %(count)s digwyddiadau allan o %(total)s",
- "one": "Wedi nôl %(count)s digwyddiad allan o %(total)s",
- "two": "Wedi nôl %(count)s ddigwyddiad allan o %(total)s",
- "few": "Wedi nôl %(count)s digwyddiad allan o %(total)s",
- "many": "Wedi nôl %(count)s digwyddiad allan o %(total)s",
- "other": "Wedi nôl %(count)s digwyddiad allan o %(total)s"
+ "Wedi nôl %(count)s digwyddiadau allan o %(total)s": "zero",
+ "Wedi nôl %(count)s digwyddiad allan o %(total)s": "other",
+ "Wedi nôl %(count)s ddigwyddiad allan o %(total)s": "two"
},
"fetching_events": "Wrthi'n nôl digwyddiadau…",
"file_attached": "Ffeil wedi'i Atodi",
@@ -1290,6 +1315,14 @@
},
"in_space": "Yn %(spaceName)s.",
"in_space1_and_space2": "Mewn gofodau %(space1Name)s a %(space2Name)s.",
+ "in_space_and_n_other_spaces": {
+ "zero": "Yn %(spaceName)s a %(count)s gofodau eraill.",
+ "one": "Yn %(spaceName)s ac un gofod arall",
+ "two": "Yn %(spaceName)s a %(count)s gofod arall.",
+ "few": "Yn %(spaceName)s a %(count)s gofod arall.",
+ "many": "Yn %(spaceName)s a %(count)s gofod arall.",
+ "other": "Yn %(spaceName)s a %(count)s gofod arall."
+ },
"incompatible_browser": {
"continue": "Parhau beth bynnag",
"description": "Mae %(brand)s yn defnyddio rhai nodweddion porwr nad ydyn nhw ar gael yn eich porwr presennol. %(detail)s",
@@ -1379,6 +1412,22 @@
"unban_first_title": "Nid oes modd gwahodd defnyddiwr nes ei fod heb ei ddad-wahardd"
},
"inviting_user1_and_user2": "Yn gwahodd %(user1)s a %(user2)s",
+ "inviting_user_and_n_others": {
+ "zero": "Yn gwahodd %(user)s ac un arall",
+ "one": "Yn gwahodd %(user)s ac un arall",
+ "two": "Yn gwahodd %(user)s a %(count)s arall",
+ "few": "Yn gwahodd %(user)s a %(count)s arall",
+ "many": "Yn gwahodd %(user)s a %(count)s arall",
+ "other": "Yn gwahodd %(user)s a %(count)s arall"
+ },
+ "items_and_n_others": {
+ "zero": " a dim arall",
+ "one": " ac un arall",
+ "two": " a %(count)s arall",
+ "few": " a %(count)s arall",
+ "many": " a %(count)s arall",
+ "other": " a %(count)s arall"
+ },
"keyboard": {
"activate_button": "Agor y botwm hwn",
"alt": "Alt",
@@ -1631,6 +1680,14 @@
"toggle_attribution": "Toglo priodoli"
},
"member_list": {
+ "count": {
+ "zero": "%(count)s Aelodau",
+ "one": "%(count)s Aelod",
+ "two": "%(count)s Aelod",
+ "few": "%(count)s Aelod",
+ "many": "%(count)s Aelod",
+ "other": "%(count)s Aelod"
+ },
"filter_placeholder": "Hidlo aelodau'r ystafell",
"invite_button_no_perms_tooltip": "Nid oes gennych ganiatâd i wahodd defnyddwyr",
"invited_label": "Gwahoddwyd",
@@ -1721,6 +1778,22 @@
"topic_label": "Cwestiwn neu bwnc",
"topic_placeholder": "Ysgrifennu rhywbeth…",
"total_decryption_errors": "Oherwydd gwallau dadgryptio, efallai na fydd rhai pleidleisiau'n cael eu cyfrif",
+ "total_n_votes": {
+ "zero": "%(count)s pleidlais wedi'u bwrw. Pleidleisiwch i weld y canlyniadau.",
+ "one": "%(count)s pleidlais wedi'i bwrw. Pleidleisiwch i weld y canlyniadau.",
+ "two": "%(count)s pleidlais wedi'u bwrw. Pleidleisiwch i weld y canlyniadau.",
+ "few": "%(count)s pleidlais wedi'u bwrw. Pleidleisiwch i weld y canlyniadau.",
+ "many": "%(count)s pleidlais wedi'u bwrw. Pleidleisiwch i weld y canlyniadau.",
+ "other": "%(count)s pleidlais wedi'u bwrw. Pleidleisiwch i weld y canlyniadau."
+ },
+ "total_n_votes_voted": {
+ "zero": "Ar sail %(count)s pleidlais",
+ "one": "Ar sail %(count)s pleidlais",
+ "two": "Ar sail %(count)s pleidlais",
+ "few": "Ar sail %(count)s pleidlais",
+ "many": "Ar sail %(count)s pleidlais",
+ "other": "Ar sail %(count)s pleidlais"
+ },
"total_no_votes": "Dim pleidleisiau wedi'u bwrw",
"total_not_ended": "Bydd y canlyniadau i'w gweld pan ddaw'r bleidlais i ben",
"type_closed": "Arolwg wedi'i chau",
@@ -1830,6 +1903,22 @@
"pinned_messages": {
"empty_description": "Dewiswch neges a dewiswch “%(pinAction)s” i'w chynnwys yma.",
"empty_title": "Pinio negeseuon pwysig fel y mae modd eu darganfod yn hawdd",
+ "header": {
+ "zero": "%(count)s Neges wedi'u pinio",
+ "one": "1 Neges wedi'i binio",
+ "two": "%(count)s Neges wedi'u pinio",
+ "few": "%(count)s Neges wedi'u pinio",
+ "many": "%(count)s Neges wedi'u pinio",
+ "other": "%(count)s Neges wedi'u pinio"
+ },
+ "limits": {
+ "zero": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio",
+ "one": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio",
+ "two": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio",
+ "few": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio",
+ "many": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio",
+ "other": "Dim ond hyd at %(count)s teclyn y gallwch eu pinio"
+ },
"menu": "Agor dewislen",
"release_announcement": {
"close": "Iawn",
@@ -1849,8 +1938,32 @@
"active_heading": "Arolygon gweithredol",
"empty_active": "Nid oes unrhyw arolygon gweithredol yn yr ystafell hon",
"empty_active_load_more": "Nid oes unrhyw arolygon gweithredol. Llwythwch fwy o arolygon barn y misoedd blaenorol",
+ "empty_active_load_more_n_days": {
+ "zero": "Does dim polau gweithredol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "one": "Does dim polau gweithredol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "two": "Does dim polau gweithredol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "few": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "many": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "other": "Does dim polau gweithredol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
+ },
"empty_past": "Nid oes arolygon o'r gorffennol yn yr ystafell hon",
"empty_past_load_more": "Nid oes unrhyw arolygon o'r gorffennol. Llwythwch fwy o arolygon barn ar gyfer y misoedd blaenorol",
+ "empty_past_load_more_n_days": {
+ "zero": "Does dim polau'r gorffennol ar gyfer y dyddiau diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "one": "Does dim polau'r gorffennol ar gyfer y diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "two": "Does dim polau'r gorffennol ar gyfer y %(count)s ddiwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "few": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "many": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol",
+ "other": "Does dim polau'r gorffennol ar gyfer y %(count)s diwrnod diwethaf. Llwythwch ragor o bolau i weld polau'r misoedd blaenorol"
+ },
+ "final_result": {
+ "zero": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidleisiau",
+ "one": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
+ "two": "Canlyniadau terfynol yn seiliedig ar %(count)s bleidlais",
+ "few": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais",
+ "many": "Canlyniadau terfynol yn seiliedig ar %(count)s phleidlais",
+ "other": "Canlyniadau terfynol yn seiliedig ar %(count)s pleidlais"
+ },
"load_more": "Llwytho mwy o arolygon barn",
"loading": "Wrthi'n llwytho arolygon",
"past_heading": "Arolygon y gorffennol",
@@ -1917,6 +2030,7 @@
"error_jump_to_date_title": "Methu dod o hyd i ddigwyddiad ar y dyddiad hwnnw",
"face_pile_tooltip_shortcut": "Gan gynnwys %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Gan eich cynnwys chi, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Nid oes modd pennu pa ddefnyddiwr i'w anwybyddu gan fod y digwyddiad aelod wedi newid.",
"failed_reject_invite": "Wedi methu â gwrthod gwahoddiad",
"forget_room": "Anghofiwch yr ystafell hon",
"forget_space": "Anghofiwch y gofod hwn",
@@ -2004,6 +2118,8 @@
"read_topic": "Cliciwch i ddarllen y pwnc",
"rejecting": "Wrthi'n gwrthod gwahoddiad…",
"rejoin_button": "Ail-ymuno",
+ "room_content": "Cynnwys yr ystafell",
+ "room_is_low_priority": "Mae hon yn ystafell â blaenoriaeth isel",
"search": {
"all_rooms_button": "Chwiliwch bob ystafell",
"placeholder": "Chwilio negeseuon…",
@@ -2040,6 +2156,7 @@
"add_space_label": "Ychwanegu gofod",
"breadcrumbs_empty": "Dim ystafelloedd yr ymwelwyd â nhw yn ddiweddar",
"breadcrumbs_label": "Ymwelwyd ag ystafelloedd yn ddiweddar",
+ "collapse_filters": "Cwympo rhestr hidlo",
"empty": {
"no_chats": "Dim sgyrsiau eto",
"no_chats_description": "Dechreuwch drwy anfon neges at rywun neu drwy greu ystafell",
@@ -2047,6 +2164,7 @@
"no_favourites": "Nid oes gennych hoff sgwrs eto",
"no_favourites_description": "Gallwch ychwanegu sgwrs at eich ffefrynnau yn y gosodiadau sgwrsio",
"no_invites": "Does gennych chi ddim gwahoddiadau heb eu darllen",
+ "no_lowpriority": "Nid oes gennych unrhyw ystafelloedd blaenoriaeth isel",
"no_mentions": "Does gennych chi ddim crybwylliadau heb eu darllen",
"no_people": "Nid oes gennych chi sgyrsiau uniongyrchol gydag unrhyw un eto",
"no_people_description": "Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill",
@@ -2056,12 +2174,14 @@
"show_activity": "Gweld yr holl weithgarwch",
"show_chats": "Dangos pob sgwrs"
},
+ "expand_filters": "Ehangu rhestr hidlo",
"failed_add_tag": "Wedi methu ag ychwanegu tag %(tagName)s i'r ystafell",
"failed_remove_tag": "Wedi methu â thynnu'r tag %(tagName)s o'r ystafell",
"failed_set_dm_tag": "Wedi methu gosod tag neges uniongyrchol",
"filters": {
"favourite": "Ffefrynnau",
"invites": "Gwahoddiadau",
+ "low_priority": "Blaenoriaeth isel",
"mentions": "Crybwylliadau",
"people": "Pobl",
"rooms": "Ystafelloedd",
@@ -2601,13 +2721,11 @@
"inline_url_previews_room": "Galluogi rhagolygon URL fel rhagosodiad ar gyfer cyfranogwyr yn yr ystafell hon",
"inline_url_previews_room_account": "Galluogi rhagolygon URL ar gyfer yr ystafell hon (yn effeithio arnoch chi yn unig)",
"insert_trailing_colon_mentions": "Mewnosod colon sy'n llusgo ar ôl i'r defnyddiwr sôn amdano ar ddechrau neges",
+ "invite_controls": {
+ "default_label": "Caniatáu i ddefnyddwyr eich gwahodd i ystafelloedd"
+ },
"jump_to_bottom_on_send": "Symud i waelod y llinell amser pan fyddwch chi'n anfon neges",
"key_backup": {
- "backup_in_progress": "Mae copi wrth gefn o'ch allweddi (gallai'r copi wrth gefn cyntaf gymryd ychydig funudau).",
- "backup_starting": "Yn dechrau gwneud copi wrth gefn…",
- "backup_success": "Llwyddiant!",
- "cannot_create_backup": "Methu creu copi wrth gefn o'r allwedd",
- "create_title": "Creu copi wrth gefn allweddol",
"setup_secure_backup": {
"backup_setup_success_description": "Mae'ch allweddi bellach yn cael eu gwneud wrth gefn o'r ddyfais hon.",
"backup_setup_success_title": "Copi Wrth Gefn Diogel yn llwyddiannus",
@@ -2667,6 +2785,7 @@
"show_in_private": "Mewn ystafelloedd preifat",
"show_media": "Dangos bob tro"
},
+ "not_supported": "Nid yw eich gweinydd yn gweithredu'r nodwedd hon.",
"notifications": {
"default_setting_description": "Bydd y gosodiad hwn yn cael ei osod fel rhagosodiad i'ch holl ystafelloedd.",
"default_setting_section": "Rwyf am gael fy hysbysu am (Gosodiad Rhagosodedig)",
@@ -2724,6 +2843,7 @@
"voip": "Galwadau Sain a Fideo"
},
"preferences": {
+ "Electron.enableContentProtection": "Atal cynnwys y ffenestr rhag cael ei gipio gan apiau eraill",
"Electron.enableHardwareAcceleration": "Galluogi cyflymiad caledwedd (ailgychwyn %(appName)s i ddod i rym)",
"always_show_menu_bar": "Dangos far dewislen y ffenestr bob amser",
"autocomplete_delay": "Oedi awtogwblhau (ms)",
@@ -2732,6 +2852,7 @@
"composer_heading": "Cyfansoddwr",
"default_timezone": "Porwr rhagosodedig (%(timezone)s)",
"dialog_title": "Gosodiadau: Dewisiadau",
+ "enable_content_protection": "Galluogi diogelu cynnwys",
"enable_hardware_acceleration": "Galluogi cyflymiad caledwedd",
"enable_tray_icon": "Dangos eicon hambwrdd a lleihau ffenestr iddo ar agos",
"keyboard_heading": "Bysellau brys",
@@ -2765,7 +2886,6 @@
"ignore_users_empty": "Nid oes gennych unrhyw ddefnyddwyr sydd wedi'u hanwybyddu.",
"ignore_users_section": "Defnyddwyr wedi'u hanwybyddu",
"key_backup_algorithm": "Algorithm:",
- "key_backup_connect": "Cysylltwch y sesiwn hon i Bysell Wrth Gefn",
"message_search_disable_warning": "Os yw wedi'i analluogi, ni fydd negeseuon o ystafelloedd wedi'u hamgryptio yn ymddangos yn y canlyniadau chwilio.",
"message_search_disabled": "Cadwch negeseuon wedi'u hamgryptio'n ddiogel yn lleol er mwyn iddyn nhw ymddangos yn y canlyniadau chwilio.",
"message_search_failed": "Methwyd cychwyn chwiliad neges",
@@ -2864,6 +2984,7 @@
"show_chat_effects": "Dangos effeithiau sgwrsio (animeiddiadau wrth dderbyn e.e. conffeti)",
"show_displayname_changes": "Dangos newidiadau enw dangos",
"show_join_leave": "Dangos negeseuon ymuno/gadael (gwahoddiadau/dileu/gwaharddiadau heb eu heffeithio)",
+ "show_message_previews": "Dangos rhagolygon negeseuon",
"show_nsfw_content": "Dangos cynnwys NSFW",
"show_read_receipts": "Dangos derbynebau darllen a anfonwyd gan ddefnyddwyr eraill",
"show_redaction_placeholder": "Dangos dalfan ar gyfer negeseuon sydd wedi'u dileu",
@@ -2970,6 +3091,8 @@
"jumptodate": "Symud i'r dyddiad a roddwyd yn y llinell amser",
"jumptodate_invalid_input": "Nid oeddem yn gallu deall y dyddiad a roddwyd (%(inputDate)s). Ceisiwch ddefnyddio'r fformat BBBB-MM-DD.",
"lenny": "Rhagflaenu ( ͡° ͜ʖ ͡°) i neges destun plaen",
+ "manual_device_verification_confirm_description": "Bydd hyn yn caniatáu i ddyfais arall anfon a derbyn negeseuon fel chi. OS BYDD RHYWUN WEDI DWEUD WRTHYCH CHI AM GLUDO RHYWBETH YMA, MAE'N DEBYGOL EICH BOD CHI'N CAEL EICH SGAMIO! Ydych chi'n siŵr eich bod chi eisiau gwirio'r ddyfais arall hon?",
+ "manual_device_verification_confirm_title": "Rhybudd: gwirio dyfais â llaw",
"me": "Yn dangos gweithredu",
"msg": "Yn anfon neges at y defnyddiwr a roddwyd",
"myavatar": "Yn newid eich llun proffil ym mhob ystafell",
@@ -3479,103 +3602,55 @@
"summary": {
"format": "%(matereList)s %(transitionList)s",
"invite_withdrawn_multiple": {
- "zero": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu",
- "one": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu",
- "two": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu %(count)s gwaith",
- "few": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu %(count)s gwaith",
- "many": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu %(count)s gwaith",
- "other": "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu %(count)s gwaith"
+ "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu": "one",
+ "Cafodd %(severalUsers)s eu gwahoddiadau eu dileu %(count)s gwaith": "other"
},
"joined": {
- "zero": "Ymunodd %(oneUser)s %(count)s gwaith",
- "one": "Ymunodd %(oneUser)s",
- "two": "Ymunodd %(oneUser)s %(count)s gwaith",
- "few": "Ymunodd %(oneUser)s %(count)s gwaith",
- "many": "Ymunodd %(oneUser)s %(count)s gwaith",
- "other": "Ymunodd %(oneUser)s %(count)s gwaith"
+ "Ymunodd %(oneUser)s %(count)s gwaith": "other",
+ "Ymunodd %(oneUser)s": "one"
},
"joined_and_left": {
- "zero": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "one": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "two": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "few": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "many": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "other": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith"
+ "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith": "other"
},
"joined_and_left_multiple": {
- "zero": "Ymunodd a gadawodd %(severalUsers)s",
- "one": "Ymunodd a gadawodd %(severalUsers)s",
- "two": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "few": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "many": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "other": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith"
+ "Ymunodd a gadawodd %(severalUsers)s": "one",
+ "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith": "other"
},
"joined_multiple": {
- "zero": "Ymunodd %(severalUsers)s",
- "one": "Ymunodd %(severalUsers)s %(count)s gwaith",
- "two": "Ymunodd %(severalUsers)s %(count)s gwaith",
- "few": "Ymunodd %(severalUsers)s %(count)s gwaith",
- "many": "Ymunodd %(severalUsers)s %(count)s gwaith",
- "other": "Ymunodd %(severalUsers)s %(count)s gwaith"
+ "Ymunodd %(severalUsers)s": "zero",
+ "Ymunodd %(severalUsers)s %(count)s gwaith": "other"
},
"left": {
- "zero": "Gadawodd %(oneUser)s",
- "one": "Gadawodd %(oneUser)s",
- "two": "Gadawodd %(oneUser)s %(count)s gwaith",
- "few": "Gadawodd %(oneUser)s %(count)s gwaith",
- "many": "Gadawodd %(oneUser)s %(count)s gwaith",
- "other": "Gadawodd %(oneUser)s %(count)s gwaith"
+ "Gadawodd %(oneUser)s": "one",
+ "Gadawodd %(oneUser)s %(count)s gwaith": "other"
},
"left_multiple": {
- "zero": "Gadawodd %(severalUsers)s",
- "one": "Gadawodd %(severalUsers)s",
- "two": "Gadawodd %(severalUsers)s %(count)s gwaith",
- "few": "Gadawodd %(severalUsers)s %(count)s gwaith",
- "many": "Gadawodd %(severalUsers)s %(count)s gwaith",
- "other": "Gadawodd %(severalUsers)s %(count)s gwaith"
+ "Gadawodd %(severalUsers)s": "one",
+ "Gadawodd %(severalUsers)s %(count)s gwaith": "other"
},
"rejected_invite": {
- "zero": "Gwrthododd %(severalUsers)s eu gwahoddiadau",
- "one": "Gwrthododd %(oneUser)s ei wahoddiad",
- "two": "Gwrthododd %(oneUser)s eu gwahoddiadau %(count)s gwaith",
- "few": "Gwrthododd %(oneUser)s eu gwahoddiadau %(count)s gwaith",
- "many": "Gwrthododd %(oneUser)s eu gwahoddiadau %(count)s gwaith",
- "other": "Gwrthododd %(oneUser)s eu gwahoddiadau %(count)s gwaith"
+ "Gwrthododd %(severalUsers)s eu gwahoddiadau": "zero",
+ "Gwrthododd %(oneUser)s ei wahoddiad": "one",
+ "Gwrthododd %(oneUser)s eu gwahoddiadau %(count)s gwaith": "other"
},
"rejected_invite_multiple": {
- "zero": "Gwrthododd %(severalUsers)s eu gwahoddiadau",
- "one": "Gwrthododd %(severalUsers)s eu gwahoddiadau",
- "two": "Gwrthododd %(severalUsers)s eu gwahoddiadau %(count)s gwaith",
- "few": "Gwrthododd %(severalUsers)s eu gwahoddiadau %(count)s gwaith",
- "many": "Gwrthododd %(severalUsers)s eu gwahoddiadau %(count)s gwaith",
- "other": "Gwrthododd %(severalUsers)s eu gwahoddiadau %(count)s gwaith"
+ "Gwrthododd %(severalUsers)s eu gwahoddiadau": "one",
+ "Gwrthododd %(severalUsers)s eu gwahoddiadau %(count)s gwaith": "other"
},
"rejoined": {
- "zero": "Ymunodd a gadawodd %(oneUser)s",
- "one": "Ymunodd a gadawodd %(oneUser)s",
- "two": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "few": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "many": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith",
- "other": "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith"
+ "Ymunodd a gadawodd %(oneUser)s": "one",
+ "Ymunodd a gadawodd %(oneUser)s %(count)s gwaith": "other"
},
"rejoined_multiple": {
- "zero": "Ymunodd a gadawodd %(severalUsers)s",
- "one": "Ymunodd a gadawodd %(severalUsers)s",
- "two": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "few": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "many": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith",
- "other": "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith"
+ "Ymunodd a gadawodd %(severalUsers)s": "one",
+ "Ymunodd a gadawodd %(severalUsers)s %(count)s gwaith": "other"
}
},
"thread_info_basic": "O edefyn",
"typing_indicator": {
"more_users": {
- "zero": "Mae %(names)s ac mae %(count)s eraill yn teipio...",
- "one": "Mae %(names)s ac mae un arall yn teipio...",
- "two": "Mae %(names)s ac mae %(count)s eraill yn teipio...",
- "few": "Mae %(names)s ac mae %(count)s eraill yn teipio...",
- "many": "Mae %(names)s ac mae %(count)s eraill yn teipio...",
- "other": "Mae %(names)s ac mae %(count)s eraill yn teipio..."
+ "Mae %(names)s ac mae %(count)s eraill yn teipio...": "other",
+ "Mae %(names)s ac mae un arall yn teipio...": "one"
},
"one_user": "Mae %(displayName)s yn teipio…",
"two_users": "Mae %(materes)s a %(lastPerson)s yn teipio…"
diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json
index 602b3f22f5..4afb00908e 100644
--- a/src/i18n/strings/de_DE.json
+++ b/src/i18n/strings/de_DE.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Du wirst den Zugang zu deinen verschlüsselten Nachrichten verlieren",
"setup_secure_backup_description_1": "Verschlüsselte Nachrichten sind mit Ende-zu-Ende-Verschlüsselung gesichert. Nur du und der/die Empfänger haben die Schlüssel um diese Nachrichten zu lesen.",
"setup_secure_backup_description_2": "Wenn du dich abmeldest, werden die Schlüssel auf diesem Gerät gelöscht. Das bedeutet, dass du keine verschlüsselten Nachrichten mehr lesen kannst, wenn du die Schlüssel nicht auf einem anderen Gerät oder eine Sicherung auf dem Server hast.",
- "skip_key_backup": "Ich möchte meine verschlüsselten Nachrichten nicht",
- "use_key_backup": "Beginne Schlüsselsicherung zu nutzen"
+ "skip_key_backup": "Ich möchte meine verschlüsselten Nachrichten nicht"
},
"misconfigured_body": "Wende dich an deinen %(brand)s-Admin um deine Konfiguration auf ungültige oder doppelte Einträge zu überprüfen.",
"misconfigured_title": "Dein %(brand)s ist falsch konfiguriert",
@@ -964,9 +963,7 @@
},
"reset_all_button": "Hast du alle Wiederherstellungsmethoden vergessen? Setze sie hier zurück",
"set_up_recovery": "Wiederherstellung einrichten",
- "set_up_recovery_later": "Nicht jetzt",
"set_up_recovery_toast_description": "Generieren Sie einen Wiederherstellungsschlüssel, damit Sie Ihren verschlüsselten Nachrichtenverlauf wiederherstellen können, falls Sie den Zugriff auf Ihre Geräte verlieren.",
- "set_up_toast_description": "Schütze dich vor dem Verlust verschlüsselter Nachrichten und Daten",
"set_up_toast_title": "Schlüsselsicherung einrichten",
"setup_secure_backup": {
"explainer": "Um deine Schlüssel nicht zu verlieren, musst du sie vor der Abmeldung sichern."
@@ -2684,11 +2681,6 @@
"insert_trailing_colon_mentions": "Doppelpunkt nach Erwähnungen einfügen",
"jump_to_bottom_on_send": "Nach Senden einer Nachricht im Verlauf nach unten springen",
"key_backup": {
- "backup_in_progress": "Deine Schlüssel werden gesichert (Das erste Backup könnte ein paar Minuten in Anspruch nehmen).",
- "backup_starting": "Beginne Sicherung …",
- "backup_success": "Erfolgreich!",
- "cannot_create_backup": "Konnte Schlüsselsicherung nicht erstellen",
- "create_title": "Schlüsselsicherung erstellen",
"setup_secure_backup": {
"backup_setup_success_description": "Deine Schlüssel werden nun von dieser Sitzung gesichert.",
"backup_setup_success_title": "Verschlüsselte Sicherung erfolgreich",
@@ -2846,7 +2838,6 @@
"ignore_users_empty": "Sie haben keinen Benutzer blockiert.",
"ignore_users_section": "Blockierte Benutzer",
"key_backup_algorithm": "Algorithmus:",
- "key_backup_connect": "Verbinde diese Sitzung mit einer Schlüsselsicherung",
"message_search_disable_warning": "Wenn deaktiviert, werden Nachrichten von verschlüsselten Räumen nicht in den Ergebnissen auftauchen.",
"message_search_disabled": "Speichere verschlüsselte Nachrichten lokal, sodass sie deinen Suchergebnissen erscheinen können.",
"message_search_enabled": {
diff --git a/src/i18n/strings/el.json b/src/i18n/strings/el.json
index 3a25563601..59d836b1cd 100644
--- a/src/i18n/strings/el.json
+++ b/src/i18n/strings/el.json
@@ -50,6 +50,8 @@
"download": "Λήψη",
"edit": "Επεξεργασία",
"enable": "Ενεργοποίηση",
+ "enter_fullscreen": "Είσοδος σε πλήρη οθόνη",
+ "exit_fullscreeen": "Έξοδος από την πλήρη οθόνη",
"expand": "Επέκταση",
"explore_public_rooms": "Εξερευνήστε δημόσιες αίθουσες",
"explore_rooms": "Εξερευνήστε αίθουσες",
@@ -149,10 +151,12 @@
"shared_data_heading": "Οποιοδήποτε από τα ακόλουθα δεδομένα μπορεί να κοινοποιηθεί:"
},
"auth": {
+ "3pid_in_use": "Αυτή η διεύθυνση ηλεκτρονικού ταχυδρομείου ή ο αριθμός τηλεφώνου χρησιμοποιείται ήδη.",
"account_clash": "Ο νέος λογαριασμός σας (%(newAccountId)s) έχει εγγραφεί, αλλά έχετε ήδη συνδεθεί με διαφορετικό λογαριασμό (%(loggedInUserId)s).",
"account_clash_previous_account": "Συνέχεια με τον προηγούμενο λογαριασμό",
"account_deactivated": "Αυτός ο λογαριασμός έχει απενεργοποιηθεί.",
"autodiscovery_generic_failure": "Απέτυχε η λήψη της διαμόρφωσης αυτόματης ανακάλυψης από τον διακομιστή",
+ "autodiscovery_hs_incompatible": "Ο αρχικός σας διακομιστής είναι πολύ παλιός και δεν υποστηρίζει την ελάχιστη απαιτούμενη έκδοση API. Παρακαλούμε επικοινωνήστε με τον ιδιοκτήτη του διακομιστή σας ή αναβαθμίστε τον διακομιστή σας.",
"autodiscovery_invalid": "Μη έγκυρη απόκριση εντοπισμού κεντρικού διακομιστή",
"autodiscovery_invalid_hs": "Η διεύθυνση URL του κεντρικού διακομιστή δε φαίνεται να αντιστοιχεί σε έγκυρο διακομιστή Matrix",
"autodiscovery_invalid_hs_base_url": "Μη έγκυρο base_url για m.homeserver",
@@ -160,6 +164,7 @@
"autodiscovery_invalid_is_base_url": "Μη έγκυρο base_url για m.identity_server",
"autodiscovery_invalid_is_response": "Μη έγκυρη απόκριση εντοπισμού διακομιστή ταυτότητας",
"autodiscovery_invalid_json": "Μη έγκυρο JSON",
+ "autodiscovery_no_well_known": "Δεν βρέθηκε αρχείο .well-known JSON",
"autodiscovery_unexpected_error_hs": "Μη αναμενόμενο σφάλμα κατά την επίλυση της διαμόρφωσης του κεντρικού διακομιστή",
"autodiscovery_unexpected_error_is": "Μη αναμενόμενο σφάλμα κατά την επίλυση της διαμόρφωσης διακομιστή ταυτότητας",
"captcha_description": "Αυτός ο κεντρικός διακομιστής θα ήθελε να βεβαιωθεί ότι δεν είστε ρομπότ.",
@@ -168,8 +173,14 @@
"change_password_confirm_label": "Επιβεβαίωση κωδικού πρόσβασης",
"change_password_current_label": "Τωρινός κωδικός πρόσβασης",
"change_password_empty": "Οι κωδικοί πρόσβασης δεν γίνετε να είναι κενοί",
+ "change_password_error": "Σφάλμα κατά την αλλαγή κωδικού πρόσβασης: %(error)s",
"change_password_mismatch": "Οι νέοι κωδικοί πρόσβασης είναι διαφορετικοί",
"change_password_new_label": "Νέος κωδικός πρόσβασης",
+ "check_email_explainer": "Ακολουθήστε τις οδηγίες που στάλθηκαν στο %(email)s",
+ "check_email_resend_prompt": "Δεν το λάβατε;",
+ "check_email_resend_tooltip": "Επανεστάλη email με τον συνδέσμο επαλήθευσης!",
+ "check_email_wrong_email_button": "Εισαγάγετε ξανά τη διεύθυνση ηλεκτρονικού ταχυδρομείου",
+ "check_email_wrong_email_prompt": "Λάθος διεύθυνση ηλεκτρονικού ταχυδρομείου;",
"continue_with_idp": "Συνεχίστε με %(provider)s",
"continue_with_sso": "Συνέχεια με %(ssoButtons)s",
"country_dropdown": "Αναπτυσσόμενο μενού Χώρας",
@@ -181,6 +192,8 @@
"email_field_label_required": "Εισάγετε διεύθυνση email",
"email_help_text": "Προσθέστε ένα email για να μπορείτε να κάνετε επαναφορά του κωδικού πρόσβασης σας.",
"email_phone_discovery_text": "Χρησιμοποιήστε email ή τηλέφωνο για να είστε προαιρετικά ανιχνεύσιμος από υπάρχουσες επαφές.",
+ "enter_email_explainer": "Το %(homeserver)s θα σας στείλει έναν σύνδεσμο επαλήθευσης για να επαναφέρετε τον κωδικό πρόσβασής σας.",
+ "enter_email_heading": "Εισάγετε το email σας για να επαναφέρετε τον κωδικό πρόσβασης",
"failed_connect_identity_server": "Δεν είναι δυνατή η πρόσβαση στον διακομιστή ταυτότητας",
"failed_connect_identity_server_other": "Μπορείτε να συνδεθείτε, αλλά ορισμένες λειτουργίες δε θα είναι διαθέσιμες μέχρι να συνδεθεί ξανά ο διακομιστής ταυτότητας. Εάν εξακολουθείτε να βλέπετε αυτήν την προειδοποίηση, ελέγξτε τις ρυθμίσεις σας ή επικοινωνήστε με έναν διαχειριστή του διακομιστή σας.",
"failed_connect_identity_server_register": "Μπορείτε να εγγραφείτε, αλλά ορισμένες λειτουργίες δεν θα είναι διαθέσιμες μέχρι να συνδεθεί ξανά ο διακομιστής ταυτότητας. Εάν εξακολουθείτε να βλέπετε αυτήν την ειδοποίηση, ελέγξτε τις ρυθμίσεις σας ή επικοινωνήστε με έναν διαχειριστή διακομιστή.",
@@ -192,6 +205,7 @@
"forgot_password_email_invalid": "Η διεύθυνση email δε φαίνεται να είναι έγκυρη.",
"forgot_password_email_required": "Πρέπει να εισηχθεί η διεύθυνση ηλ. αλληλογραφίας που είναι συνδεδεμένη με τον λογαριασμό σας.",
"forgot_password_prompt": "Ξεχάσετε τον κωδικό σας;",
+ "forgot_password_send_email": "Αποστολή email",
"identifier_label": "Συνδεθείτε με",
"incorrect_credentials": "Λανθασμένο όνομα χρήστη και/ή κωδικός.",
"incorrect_credentials_detail": "Σημειώστε ότι συνδέεστε στον διακομιστή %(hs)s, όχι στο matrix.org.",
@@ -202,8 +216,8 @@
"megolm_export": "Μη αυτόματη εξαγωγή κλειδιών",
"setup_key_backup_title": "Θα χάσετε την πρόσβαση στα κρυπτογραφημένα μηνύματά σας",
"setup_secure_backup_description_1": "Τα κρυπτογραφημένα μηνύματα προστατεύονται με κρυπτογράφηση από άκρο σε άκρο. Μόνο εσείς και οι παραλήπτες έχετε τα κλειδιά για να διαβάσετε αυτά τα μηνύματα.",
- "skip_key_backup": "Δε θέλω τα κρυπτογραφημένα μηνύματά μου",
- "use_key_backup": "Ξεκινήστε να χρησιμοποιείτε το αντίγραφο ασφαλείας κλειδιού"
+ "setup_secure_backup_description_2": "Όταν αποσυνδεθείτε, αυτά τα κλειδιά θα διαγραφούν από αυτήν τη συσκευή, πράγμα που σημαίνει ότι δεν θα μπορείτε να διαβάσετε κρυπτογραφημένα μηνύματα, εκτός εάν έχετε τα κλειδιά για αυτά στις άλλες συσκευές σας ή έχετε δημιουργήσει αντίγραφα ασφαλείας τους στον διακομιστή.",
+ "skip_key_backup": "Δε θέλω τα κρυπτογραφημένα μηνύματά μου"
},
"misconfigured_body": "Ζητήστε από τον %(brand)s διαχειριστή σας να ελέγξει τις ρυθμίσεις σας για λανθασμένες ή διπλότυπες καταχωρίσεις.",
"misconfigured_title": "Οι παράμετροι του %(brand)s σας είναι λανθασμένα ρυθμισμένοι",
@@ -216,11 +230,20 @@
"error_title": "Δεν μπορέσαμε να σας συνδέσουμε",
"missing_or_invalid_stored_state": "Ζητήσαμε από το πρόγραμμα περιήγησης να θυμάται τον διακομιστή που χρησιμοποιείτε για να συνδέεστε, αλλά το πρόγραμμα περιήγησης δεν το έχει αποθηκεύσει. Πηγαίνετε στην σελίδα σύνδεσεις για να προσπαθήσετε ξανά."
},
+ "password_field_keep_going_prompt": "Συνεχίστε...",
"password_field_label": "Εισάγετε τον κωδικό πρόσβασης",
"password_field_strong_label": "Πολύ καλά, ισχυρός κωδικός πρόσβασης!",
"password_field_weak_label": "Έγκυρος κωδικός πρόσβασης, αλλά δεν είναι ασφαλής",
"phone_label": "Τηλέφωνο",
"phone_optional_label": "Τηλέφωνο (προαιρετικό)",
+ "qr_code_login": {
+ "completing_setup": "Ολοκλήρωση της ρύθμισης της νέας συσκευής σας",
+ "error_unexpected": "Παρουσιάστηκε μη αναμενόμενο σφάλμα. Το αίτημα σύνδεσης της άλλης συσκευής σας ακυρώθηκε.",
+ "scan_code_instruction": "Σαρώστε τον κωδικό QR με άλλη συσκευή",
+ "scan_qr_code": "Συνδεθείτε με κωδικό QR",
+ "select_qr_code": "Επιλέξτε \"%(scanQRCode)s\"",
+ "waiting_for_device": "Αναμονή για σύνδεση της συσκευής"
+ },
"register_action": "Δημιουργία Λογαριασμού",
"registration": {
"continue_without_email_description": "Μια προειδοποίηση, αν δεν προσθέσετε ένα email και ξεχάσετε τον κωδικό πρόσβασης, ενδέχεται να χάσετε οριστικά την πρόσβαση στον λογαριασμό σας.",
@@ -234,14 +257,25 @@
"registration_username_unable_check": "Δεν είναι δυνατός ο έλεγχος εάν το όνομα χρήστη είναι διαθέσιμο. Δοκιμάστε ξανά αργότερα.",
"registration_username_validation": "Χρησιμοποιήστε μόνο πεζά γράμματα, αριθμούς, παύλες και κάτω παύλες",
"reset_password": {
+ "confirm_new_password": "Επιβεβαίωση νέου κωδικού πρόσβασης",
+ "devices_logout_success": "Έχετε αποσυνδεθεί από όλες τις συσκευές και δεν θα λαμβάνετε πλέον ειδοποιήσεις push. Για να ενεργοποιήσετε ξανά τις ειδοποιήσεις, συνδεθείτε ξανά σε κάθε συσκευή.",
+ "other_devices_logout_warning_1": "Η αποσύνδεση των συσκευών σας θα διαγράψει τα κλειδιά κρυπτογράφησης μηνυμάτων που είναι αποθηκευμένα σε αυτές, καθιστώντας το κρυπτογραφημένο ιστορικό συνομιλιών μη αναγνώσιμο.",
+ "other_devices_logout_warning_2": "Αν θέλετε να διατηρήσετε πρόσβαση στο ιστορικό των συνομιλιών σας σε κρυπτογραφημένες αίθουσες, ρυθμίστε τη δημιουργία αντιγράφων ασφαλείας κλειδιών ή εξάγετε τα κλειδιά των μηνυμάτων σας από μία από τις άλλες συσκευές σας πριν προχωρήσετε.",
"password_not_entered": "Ο νέος κωδικός πρόσβασης πρέπει να εισαχθεί.",
"passwords_mismatch": "Οι νέοι κωδικοί πρόσβασης πρέπει να ταιριάζουν.",
+ "rate_limit_error": "Πάρα πολλές προσπάθειες σε σύντομο χρονικό διάστημα. Περιμένετε λίγο πριν προσπαθήσετε ξανά.",
+ "rate_limit_error_with_time": "Πάρα πολλές προσπάθειες σε σύντομο χρονικό διάστημα. Δοκιμάστε ξανά μετά από %(timeout)s.",
"reset_successful": "Ο κωδικός πρόσβασής σας επαναφέρθηκε.",
- "return_to_login": "Επιστροφή στην οθόνη σύνδεσης"
+ "return_to_login": "Επιστροφή στην οθόνη σύνδεσης",
+ "sign_out_other_devices": "Αποσύνδεση από όλες τις συσκευές"
},
+ "reset_password_action": "Επαναφορά κωδικού πρόσβασης",
+ "reset_password_button": "Ξέχασες τον κωδικό πρόσβασης;",
"reset_password_email_field_description": "Χρησιμοποιήστε μια διεύθυνση email για να ανακτήσετε τον λογαριασμό σας",
"reset_password_email_field_required_invalid": "Εισαγάγετε τη διεύθυνση email (απαιτείται σε αυτόν τον κεντρικό διακομιστή)",
+ "reset_password_email_not_associated": "Η διεύθυνση ηλεκτρονικού ταχυδρομείου σας δεν φαίνεται να σχετίζεται με κάποιο αναγνωριστικό Matrix σε αυτόν τον αρχικό διακομιστή.",
"reset_password_email_not_found_title": "Δεν βρέθηκε η διεύθυνση ηλ. αλληλογραφίας",
+ "reset_password_title": "Επαναφέρετε τον κωδικό πρόσβασής σας",
"server_picker_custom": "Άλλος κεντρικός διακομιστής",
"server_picker_description": "Μπορείτε να χρησιμοποιήσετε τις προσαρμοσμένες επιλογές διακομιστή για να συνδεθείτε σε άλλους διακομιστές Matrix, καθορίζοντας μια διαφορετική διεύθυνση URL του κεντρικού διακομιστή. Αυτό σας επιτρέπει να χρησιμοποιείτε το %(brand)s με έναν υπάρχοντα λογαριασμό Matrix σε διαφορετικό τοπικό διακομιστή.",
"server_picker_description_matrix.org": "Συμμετέχετε δωρεάν στον μεγαλύτερο δημόσιο διακομιστή",
@@ -264,11 +298,14 @@
"verification_pending_title": "Εκκρεμεί επιβεβαίωση"
},
"set_email_prompt": "Θέλετε να ορίσετε μια διεύθυνση ηλεκτρονικής αλληλογραφίας;",
+ "sign_in_description": "Χρησιμοποιήστε τον λογαριασμό σας για να συνεχίσετε.",
+ "sign_in_instead": "Συνδεθείτε αντ' αυτού",
"sign_in_instead_prompt": "Έχετε ήδη λογαριασμό; Συνδεθείτε εδώ",
"sign_in_or_register": "Συνδεθείτε ή Δημιουργήστε Λογαριασμό",
"sign_in_or_register_description": "Χρησιμοποιήστε τον λογαριασμό σας ή δημιουργήστε νέο για να συνεχίσετε.",
"sign_in_prompt": "Έχετε λογαριασμό; Συνδεθείτε",
"sign_in_with_sso": "Συνδεθείτε με απλή σύνδεση",
+ "signing_in": "Σύνδεση...",
"soft_logout": {
"clear_data_button": "Εκκαθάριση όλων των δεδομένων",
"clear_data_description": "Η εκκαθάριση όλων των δεδομένων από αυτήν τη συνεδρία είναι μόνιμη. Τα κρυπτογραφημένα μηνύματα θα χαθούν εκτός εάν έχουν δημιουργηθεί αντίγραφα ασφαλείας των κλειδιών τους.",
@@ -279,19 +316,27 @@
"soft_logout_intro_sso": "Συνδεθείτε και αποκτήστε ξανά πρόσβαση στον λογαριασμό σας.",
"soft_logout_intro_unsupported_auth": "Δεν μπορείτε να συνδεθείτε στον λογαριασμό σας. Επικοινωνήστε με τον διαχειριστή του κεντρικού διακομιστή σας για περισσότερες πληροφορίες.",
"soft_logout_subheading": "Εκκαθάριση προσωπικών δεδομένων",
+ "soft_logout_warning": "Προειδοποίηση: τα προσωπικά σας δεδομένα (συμπεριλαμβανομένων των κλειδιών κρυπτογράφησης) εξακολουθούν να είναι αποθηκευμένα σε αυτήν την συνεδρία. Διαγράψτε τα εάν ολοκληρώσατε τη χρήση αυτής της συνεδρίας ή θέλετε να συνδεθείτε σε άλλον λογαριασμό.",
+ "sso": "Ενιαία Σύνδεση",
"sso_complete_in_browser_dialog_title": "Μεταβείτε στο πρόγραμμα περιήγησής σας για να ολοκληρώσετε τη σύνδεση",
"sso_failed_missing_storage": "Ζητήσαμε από το πρόγραμμα περιήγησης να θυμάται τον διακομιστή που χρησιμοποιείτε για να συνδέεστε, αλλά το πρόγραμμα περιήγησης δεν το έχει αποθηκεύσει. Πηγαίνετε στην σελίδα σύνδεσεις για να προσπαθήσετε ξανά.",
"sso_or_username_password": "%(ssoButtons)s Ή %(usernamePassword)s",
"sync_footer_subtitle": "Εάν έχετε συμμετάσχει σε πολλές αίθουσες, αυτό μπορεί να διαρκέσει λίγο",
+ "syncing": "Συγχρονισμός...",
"uia": {
"code": "Κωδικός",
+ "email": "Για να δημιουργήσετε τον λογαριασμό σας, ανοίξτε τον σύνδεσμο στο email που μόλις στείλαμε στο %(emailAddress)s.",
"email_auth_header": "Ελέγξτε το email σας για να συνεχίσετε",
+ "email_resend_prompt": "Δεν το λάβατε; Στείλτε το ξανά",
+ "email_resent": "Επαναστάλθηκε!",
"fallback_button": "Έναρξη πιστοποίησης",
"msisdn": "Ένα μήνυμα κειμένου έχει σταλεί στη διεύθυνση %(msisdn)s",
"msisdn_token_incorrect": "Εσφαλμένο διακριτικό",
"msisdn_token_prompt": "Παρακαλούμε εισάγετε τον κωδικό που περιέχει:",
"password_prompt": "Ταυτοποιηθείτε εισάγοντας παρακάτω τον κωδικό πρόσβασης του λογαριασμού σας.",
"recaptcha_missing_params": "Λείπει το δημόσιο κλειδί captcha από τη διαμόρφωση του κεντρικού διακομιστή. Αναφέρετε αυτό στον διαχειριστή του.",
+ "registration_token_label": "Διακριτικό εγγραφής",
+ "registration_token_prompt": "Εισάγετε ένα διακριτικό εγγραφής που παρέχεται από τον διαχειριστή του αρχικού διακομιστή.",
"sso_body": "Επιβεβαιώστε την προσθήκη αυτής της διεύθυνσης ηλ. ταχυδρομείου με την χρήση Single Sign On για να επικυρώσετε την ταυτότητα σας.",
"sso_failed": "Κάτι πήγε στραβά στην επιβεβαίωση της ταυτότητάς σας. Ακυρώστε και δοκιμάστε ξανά.",
"sso_postauth_body": "Κλικ στο κουμπί παρακάτω για να επιβεβαιώσετε την ταυτότητά σας.",
@@ -301,10 +346,13 @@
"terms": "Παρακαλώ διαβάστε και αποδεχτείτε όλες τις πολιτικές αυτού του κεντρικού διακομιστή:",
"terms_invalid": "Παρακαλώ διαβάστε και αποδεχτείτε όλες τις πολιτικές του κεντρικού διακομιστή"
},
+ "unsupported_auth": "Αυτός ο αρχικός διακομιστής δεν προσφέρει καμία ροή σύνδεσης που υποστηρίζεται από αυτήν την εφαρμογή.",
"unsupported_auth_email": "Αυτός ο κεντρικός διακομιστής δεν υποστηρίζει σύνδεση με χρήση διεύθυνσης email.",
"unsupported_auth_msisdn": "Αυτός ο διακομιστής δεν υποστηρίζει πιστοποίηση με αριθμό τηλεφώνου.",
"username_field_required_invalid": "Εισάγετε όνομα χρήστη",
- "username_in_use": "Κάποιος έχει ήδη αυτό το όνομα χρήστη, δοκιμάστε ένα άλλο."
+ "username_in_use": "Κάποιος έχει ήδη αυτό το όνομα χρήστη, δοκιμάστε ένα άλλο.",
+ "verify_email_explainer": "Πρέπει να γνωρίζουμε ότι είστε εσείς πριν επαναφέρουμε τον κωδικό πρόσβασής σας. Κάντε κλικ στον σύνδεσμο στο email που μόλις στείλαμε στη διεύθυνση %(email)s",
+ "verify_email_heading": "Επαληθεύστε το email σας για να συνεχίσετε"
},
"bug_reporting": {
"additional_context": "Εάν υπάρχουν πρόσθετες πληροφορίες που θα βοηθούσαν στην ανάλυση του ζητήματος, όπως τι κάνατε εκείνη τη στιγμή, αναγνωριστικά αιθουσών, αναγνωριστικά χρηστών κ.λπ., συμπεριλάβετέ τα εδώ.",
@@ -332,6 +380,7 @@
"uploading_logs": "Μεταφόρτωση αρχείων καταγραφής",
"waiting_for_server": "Αναμονή απάντησης από τον διακομιστή"
},
+ "cannot_invite_without_identity_server": "Δεν είναι δυνατή η πρόσκληση χρήστη μέσω email χωρίς διακομιστή ταυτότητας. Μπορείτε να συνδεθείτε σε έναν από τις \"Ρυθμίσεις\".",
"cannot_reach_homeserver": "Δεν είναι δυνατή η πρόσβαση στον κεντρικό διακομιστή",
"cannot_reach_homeserver_detail": "Βεβαιωθείτε ότι έχετε σταθερή σύνδεση στο διαδίκτυο ή επικοινωνήστε με τον διαχειριστή του διακομιστή",
"cant_load_page": "Δεν ήταν δυνατή η φόρτωση της σελίδας",
@@ -362,6 +411,7 @@
"are_you_sure": "Είστε σίγουροι;",
"attachment": "Επισύναψη",
"authentication": "Πιστοποίηση",
+ "avatar": "Εικόνα Προφίλ",
"beta": "Beta",
"camera": "Κάμερα",
"cameras": "Κάμερες",
@@ -444,6 +494,7 @@
"room": "Αίθουσα",
"room_name": "Όνομα αίθουσας",
"rooms": "Αίθουσες",
+ "saving": "Γίνεται αποθήκευση...",
"secure_backup": "Ασφαλές αντίγραφο ασφαλείας",
"select_all": "Επιλογή όλων",
"server": "Διακομιστής",
@@ -463,6 +514,7 @@
"thread": "Νήμα",
"threads": "Νήμα εκτέλεσης",
"timeline": "Χρονολόγιο",
+ "unavailable": "μη διαθέσιμο",
"unencrypted": "Μη κρυπτογραφημένο",
"unmute": "Άρση σίγασης",
"unnamed_room": "Ανώνυμη αίθουσα",
@@ -532,6 +584,7 @@
"create_room": {
"action_create_room": "Δημιουργία δωματίου",
"action_create_video_room": "Δημιουργία δωματίου βίντεο",
+ "encrypted_video_room_warning": "Δεν μπορείτε να το απενεργοποιήσετε αργότερα. Η αίθουσα θα κρυπτογραφηθεί, αλλά η ενσωματωμένη κλήση όχι.",
"encrypted_warning": "Δεν μπορείτε να το απενεργοποιήσετε αργότερα. Οι γέφυρες και τα περισσότερα ρομπότ δεν μπορούν να λειτουργήσουν ακόμα.",
"encryption_forced": "Ο διακομιστής σας απαιτεί την ενεργοποίηση της κρυπτογράφησης σε ιδιωτικά δωμάτια.",
"encryption_label": "Ενεργοποίηση κρυπτογράφησης από άκρο-σε-άκρο",
@@ -540,6 +593,7 @@
"join_rule_change_notice": "Μπορείτε να το αλλάξετε ανά πάσα στιγμή από τις ρυθμίσεις δωματίου.",
"join_rule_invite": "Ιδιωτικό δωμάτιο (μόνο με πρόσκληση)",
"join_rule_invite_label": "Μόνο τα άτομα που έχουν προσκληθεί θα μπορούν να βρουν και να εγγραφούν σε αυτό τον δωμάτιο.",
+ "join_rule_knock_label": "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει, αλλά οι διαχειριστές ή οι συντονιστές πρέπει να χορηγήσουν πρόσβαση. Μπορείτε να το αλλάξετε αργότερα.",
"join_rule_public_label": "Οποιοσδήποτε θα μπορεί να βρει και να εγγραφεί σε αυτό το δωμάτιο.",
"join_rule_public_parent_space_label": "Οποιοσδήποτε θα μπορεί να βρει και να εγγραφεί σε αυτόν τον χώρο, όχι μόνο μέλη του .",
"join_rule_restricted": "Ορατό στα μέλη του χώρου",
@@ -711,13 +765,14 @@
"cancel_search_label": "Ακύρωση αναζήτησης"
},
"empty_room": "Άδειο δωμάτιο",
+ "empty_room_was_name": "Άδεια αίθουσα (ήταν %(oldName)s)",
"encryption": {
"access_secret_storage_dialog": {
"key_validation_text": {
- "wrong_security_key": "Λάθος Κλειδί Ασφαλείας"
+ "wrong_security_key": "Το κλειδί ανάκτησης που εισαγάγατε δεν είναι σωστό."
},
"restoring": "Επαναφορά κλειδιών από αντίγραφο ασφαλείας",
- "security_key_title": "Κλειδί Ασφαλείας"
+ "security_key_title": "Κλειδί Ανάκτησης"
},
"bootstrap_title": "Ρύθμιση κλειδιών",
"confirm_encryption_setup_body": "Κάντε κλικ στο κουμπί παρακάτω για να επιβεβαιώσετε τη ρύθμιση της κρυπτογράφησης.",
@@ -730,6 +785,9 @@
"cross_signing_user_warning": "Αυτός ο χρήστης δεν έχει επαληθεύσει όλες τις συνεδρίες του.",
"event_shield_reason_authenticity_not_guaranteed": "Η αυθεντικότητα αυτού του κρυπτογραφημένου μηνύματος δεν είναι εγγυημένη σε αυτήν τη συσκευή.",
"event_shield_reason_mismatched_sender_key": "Κρυπτογραφήθηκε από μια μη επαληθευμένη συνεδρία",
+ "event_shield_reason_unknown_device": "Κρυπτογραφημένο από άγνωστη ή διαγραμμένη συσκευή.",
+ "event_shield_reason_unsigned_device": "Κρυπτογραφημένο από συσκευή που δεν έχει επαληθευτεί από τον κάτοχό της.",
+ "event_shield_reason_unverified_identity": "Κρυπτογραφημένο από μη επαληθευμένο χρήστη.",
"export_unsupported": "Ο περιηγητής σας δεν υποστηρίζει τα απαιτούμενα πρόσθετα κρυπτογράφησης",
"import_invalid_keyfile": "Μη έγκυρο αρχείο κλειδιού %(brand)s",
"import_invalid_passphrase": "Αποτυχία ελέγχου πιστοποίησης: λανθασμένος κωδικός πρόσβασης;",
@@ -754,12 +812,12 @@
"warning": "Εάν δεν καταργήσατε τη μέθοδο ανάκτησης, ένας εισβολέας μπορεί να προσπαθεί να αποκτήσει πρόσβαση στον λογαριασμό σας. Αλλάξτε τον κωδικό πρόσβασης του λογαριασμού σας και ορίστε μια νέα μέθοδο ανάκτησης αμέσως στις Ρυθμίσεις."
},
"reset_all_button": "Ξεχάσατε ή χάσατε όλες τις μεθόδους ανάκτησης; Επαναφορά όλων",
- "set_up_toast_description": "Προστατευτείτε από την απώλεια πρόσβασης σε κρυπτογραφημένα μηνύματα και δεδομένα",
"set_up_toast_title": "Ρυθμίστε το αντίγραφο ασφαλείας",
"setup_secure_backup": {
"explainer": "Δημιουργήστε αντίγραφα ασφαλείας των κλειδιών σας πριν αποσυνδεθείτε για να μην τα χάσετε."
},
"udd": {
+ "interactive_verification_button": "Διαδραστική επαλήθευση με emoji",
"other_ask_verify_text": "Ζητήστε από αυτόν τον χρήστη να επιβεβαιώσει την συνεδρία του, ή επιβεβαιώστε την χειροκίνητα παρακάτω.",
"other_new_session_text": "Ο %(name)s (%(userId)s) συνδέθηκε σε μία νέα συνεδρία χωρίς να την επιβεβαιώσει:",
"own_ask_verify_text": "Επιβεβαιώστε την άλλη σας συνεδρία χρησιμοποιώντας μία από τις παρακάτω επιλογές.",
@@ -782,14 +840,17 @@
"complete_action": "Κατανοώ",
"complete_description": "Επαληθεύσατε με επιτυχία αυτόν τον χρήστη.",
"complete_title": "Επαληθεύτηκε!",
+ "error_starting_description": "Δεν ήταν δυνατή η έναρξη συνομιλίας με τον άλλο χρήστη.",
+ "error_starting_title": "Σφάλμα κατά την έναρξη της επαλήθευσης",
"explainer": "Τα ασφαλή μηνύματα με αυτόν τον χρήστη είναι κρυπτογραφημένα από άκρο σε άκρο και δεν μπορούν να διαβαστούν από τρίτους.",
"in_person": "Για να είστε ασφαλείς, κάντε το αυτοπροσώπως ή χρησιμοποιήστε έναν αξιόπιστο τρόπο επικοινωνίας.",
"incoming_sas_device_dialog_text_1": "Επαληθεύστε αυτήν τη συσκευή για να την επισημάνετε ως αξιόπιστη. Η εμπιστοσύνη αυτής της συσκευής προσφέρει σε εσάς και σε άλλους χρήστες επιπλέον ηρεμία όταν χρησιμοποιείτε μηνύματα με κρυπτογράφηση από άκρο σε άκρο.",
"incoming_sas_device_dialog_text_2": "Η επαλήθευση αυτής της συσκευής θα την επισημάνει ως αξιόπιστη και οι χρήστες που έχουν επαληθευτεί μαζί σας θα εμπιστεύονται αυτήν τη συσκευή.",
"incoming_sas_dialog_title": "Εισερχόμενο Αίτημα Επαλήθευσης",
+ "incoming_sas_dialog_waiting": "Αναμονή επιβεβαίωσης από τον συνεργάτη…",
"incoming_sas_user_dialog_text_1": "Επαληθεύστε αυτόν τον χρήστη για να τον επισημάνετε ως αξιόπιστο. Η εμπιστοσύνη των χρηστών σάς προσφέρει επιπλέον ηρεμία όταν χρησιμοποιείτε μηνύματα με κρυπτογράφηση από άκρο σε άκρο.",
"incoming_sas_user_dialog_text_2": "Η επαλήθευση αυτού του χρήστη θα επισημάνει τη συνεδρία του ως αξιόπιστη και θα επισημάνει επίσης τη συνεδρία σας ως αξιόπιστη σε αυτόν.",
- "no_key_or_device": "Φαίνεται ότι δε διαθέτετε Κλειδί Ασφαλείας ή άλλες συσκευές με τις οποίες μπορείτε να επαληθεύσετε. Αυτή η συσκευή δε θα έχει πρόσβαση σε παλιά κρυπτογραφημένα μηνύματα. Για να επαληθεύσετε την ταυτότητά σας σε αυτήν τη συσκευή, θα πρέπει να επαναφέρετε τα κλειδιά επαλήθευσης.",
+ "no_key_or_device": "Φαίνεται ότι δεν έχετε Κλειδί Ανάκτησης ή άλλες συσκευές με τις οποίες μπορείτε να κάνετε επαλήθευση. Αυτή η συσκευή δεν θα έχει πρόσβαση σε παλιά κρυπτογραφημένα μηνύματα. Για να επαληθεύσετε την ταυτότητά σας σε αυτήν τη συσκευή, θα πρέπει να επαναφέρετε τα κλειδιά επαλήθευσης.",
"no_support_qr_emoji": "Η συσκευή που προσπαθείτε να επαληθεύσετε δεν υποστηρίζει τη σάρωση κωδικού QR ή επαλήθευσης emoji, κάτι που υποστηρίζει το %(brand)s. Δοκιμάστε με διαφορετικό πρόγραμμα-πελάτη.",
"other_party_cancelled": "Το άλλο μέρος ακύρωσε την επαλήθευση.",
"prompt_encrypted": "Επαληθεύστε όλους τους χρήστες σε ένα δωμάτιο για να βεβαιωθείτε ότι είναι ασφαλές.",
@@ -801,6 +862,8 @@
"qr_prompt": "Σαρώστε αυτόν τον μοναδικό κωδικό",
"qr_reciprocate_same_shield_device": "Σχεδόν έτοιμοι! Εμφανίζεται η ίδια ασπίδα και στην άλλη συσκευή σας;",
"qr_reciprocate_same_shield_user": "Σχεδόν έτοιμοι! Εμφανίζεται η ίδια ασπίδα και στον χρήστη %(displayName)s;",
+ "request_toast_accept": "Επαλήθευση Συνεδρίας",
+ "request_toast_decline_counter": "Παράβλεψη (%(counter)s)",
"request_toast_detail": "%(deviceId)s από %(ip)s",
"reset_proceed_prompt": "Προχωρήστε με την επαναφορά",
"sas_caption_self": "Επαληθεύστε αυτήν τη συσκευή επιβεβαιώνοντας ότι ο ακόλουθος αριθμός εμφανίζεται στην οθόνη της.",
@@ -820,10 +883,12 @@
"successful_user": "Επαληθεύσατε με επιτυχία τον χρήστη %(displayName)s!",
"timed_out": "Η επαλήθευση έληξε.",
"unsupported_method": "Δεν είναι δυνατή η εύρεση μιας υποστηριζόμενης μεθόδου επαλήθευσης.",
+ "unverified_session_toast_accept": "Ναι, ήμουν εγώ",
"unverified_session_toast_title": "Νέα σύνδεση. Ήσουν εσύ;",
"unverified_sessions_toast_description": "Ελέγξτε για να βεβαιωθείτε ότι ο λογαριασμός σας είναι ασφαλής",
"unverified_sessions_toast_reject": "Αργότερα",
- "verification_description": "Επαληθεύστε την ταυτότητά σας για να αποκτήσετε πρόσβαση σε κρυπτογραφημένα μηνύματα και να αποδείξετε την ταυτότητά σας σε άλλους.",
+ "unverified_sessions_toast_title": "Έχετε μη επαληθευμένες συνεδρίες",
+ "verification_description": "Επαληθεύστε την ταυτότητά σας για να αποκτήσετε πρόσβαση σε κρυπτογραφημένα μηνύματα και να αποδείξετε την ταυτότητά σας σε άλλους. Εάν χρησιμοποιείτε επίσης κινητή συσκευή, ανοίξτε την εφαρμογή εκεί πριν προχωρήσετε.",
"verification_dialog_title_device": "Επαλήθευση άλλης συσκευής",
"verification_dialog_title_user": "Αίτημα επαλήθευσης",
"verification_skip_warning": "Χωρίς επαλήθευση, δε θα έχετε πρόσβαση σε όλα τα μηνύματά σας και ενδέχεται να φαίνεστε ως αναξιόπιστος στους άλλους.",
@@ -834,8 +899,8 @@
"verify_emoji_prompt_qr": "Εάν δεν μπορείτε να σαρώσετε τον παραπάνω κώδικα, επαληθεύστε το συγκρίνοντας μοναδικά emoji.",
"verify_later": "Θα επαληθεύσω αργότερα",
"verify_using_device": "Επαλήθευση με άλλη συσκευή",
- "verify_using_key": "Επαλήθευση με Κλειδί ασφαλείας",
- "verify_using_key_or_phrase": "Επαλήθευση με Κλειδί Ασφαλείας ή Φράση Ασφαλείας",
+ "verify_using_key": "Επαλήθευση με Κλειδί Ανάκτησης",
+ "verify_using_key_or_phrase": "Επαλήθευση με Κλειδί ή Φράση Ανάκτησης",
"waiting_for_user_accept": "Αναμονή αποδοχής από %(displayName)s…",
"waiting_other_device": "Αναμονή για επαλήθευση στην άλλη συσκευή σας…",
"waiting_other_device_details": "Αναμονή για επαλήθευση στην άλλη συσκευή σας, %(deviceName)s (%(deviceId)s)…",
@@ -881,6 +946,7 @@
"unknown_error_code": "άγνωστος κωδικός σφάλματος",
"update_power_level": "Δεν ήταν δυνατή η αλλαγή του επιπέδου δύναμης"
},
+ "error_database_closed_title": "Το %(brand)s σταμάτησε να λειτουργεί",
"error_dialog": {
"copy_room_link_failed": {
"description": "Αδυναμία αντιγραφής στο πρόχειρο του συνδέσμου δωματίου.",
@@ -889,6 +955,7 @@
"error_loading_user_profile": "Αδυναμία φόρτωσης του προφίλ χρήστη",
"forget_room_failed": "Δεν ήταν δυνατή η διαγραφή του δωματίου (%(errCode)s)"
},
+ "error_user_not_logged_in": "Ο χρήστης δεν είναι συνδεδεμένος",
"event_preview": {
"m.call.answer": {
"dm": "Κλήση σε εξέλιξη",
@@ -1042,7 +1109,11 @@
"impossible_dialog_title": "Δεν επιτρέπονται πρόσθετα"
},
"invite": {
+ "ask_anyway_description": "Δεν είναι δυνατή η εύρεση προφίλ για τα αναγνωριστικά Matrix που αναφέρονται παρακάτω - θα θέλατε να ξεκινήσετε μία συνομιλία ούτως ή άλλως;",
+ "ask_anyway_label": "Έναρξη συνομιλίας ούτως ή άλλως",
+ "ask_anyway_never_warn_label": "Έναρξη συνομιλίας ούτως ή άλλως και μην με προειδοποιήσετε ξανά",
"email_caption": "Πρόσκληση μέσω email",
+ "email_limit_one": "Οι προσκλήσεις μέσω email μπορούν να αποστέλλονται μόνο μία κάθε φορά",
"email_use_default_is": "Χρησιμοποιήστε έναν διακομιστή ταυτότητας για πρόσκληση μέσω email. Χρησιμοποιήστε τον προεπιλεγμένο (%(defaultIdentityServerName)s) ή διαμορφώστε στις Ρυθμίσεις.",
"email_use_is": "Χρησιμοποιήστε έναν διακομιστή ταυτότητας για πρόσκληση μέσω email. Διαχείριση στις Ρυθμίσεις.",
"error_already_invited_room": "Ο χρήστης έχει ήδη προσκληθεί στο δωμάτιο",
@@ -1087,7 +1158,13 @@
"unable_find_profiles_description_default": "Δεν είναι δυνατή η εύρεση προφίλ για τα αναγνωριστικά Matrix που αναφέρονται παρακάτω - θα θέλατε να τα προσκαλέσετε ούτως ή άλλως;",
"unable_find_profiles_invite_label_default": "Πρόσκληση ούτως ή άλλως",
"unable_find_profiles_invite_never_warn_label_default": "Προσκαλέστε ούτως ή άλλως και μην με προειδοποιήσετε ποτέ ξανά",
- "unable_find_profiles_title": "Οι παρακάτω χρήστες ενδέχεται να μην υπάρχουν"
+ "unable_find_profiles_title": "Οι παρακάτω χρήστες ενδέχεται να μην υπάρχουν",
+ "unban_first_title": "Δεν είναι δυνατή η πρόσκληση χρήστη μέχρι να αρθεί ο αποκλεισμός του"
+ },
+ "inviting_user1_and_user2": "Πρόσκληση %(user1)s και %(user2)s",
+ "inviting_user_and_n_others": {
+ "one": "Πρόσκληση %(user)s και ενός άλλου",
+ "other": "Πρόσκληση %(user)s και %(count)s άλλων"
},
"items_and_n_others": {
"one": " και ένα ακόμα",
@@ -1850,7 +1927,7 @@
},
"join_rule_upgrade_upgrading_room": "Αναβάθμιση δωματίου",
"public_without_alias_warning": "Για να δημιουργήσετε σύνδεσμο σε αυτό το δωμάτιο, παρακαλώ προσθέστε μια διεύθυνση.",
- "strict_encryption": "Μη στέλνετε ποτέ κρυπτογραφημένα μηνύματα σε μη επαληθευμένες συνεδρίες σε αυτό το δωμάτιο από αυτή τη συνεδρία",
+ "strict_encryption": "Αποστολή μηνυμάτων μόνο σε επαληθευμένους χρήστες.",
"title": "Ασφάλεια & Απόρρητο"
},
"title": "Ρυθμίσεις Δωματίου - %(roomName)s",
@@ -1880,7 +1957,9 @@
"error_power_level_invalid": "Το επίπεδο δύναμης πρέπει να είναι ένας θετικός ακέραιος.",
"error_room_not_visible": "Η αίθουσα %(roomId)s δεν είναι ορατή",
"error_room_unknown": "Αυτό το δωμάτιο δεν αναγνωρίζεται.",
- "error_send_request": "Δεν ήταν δυνατή η αποστολή αιτήματος."
+ "error_send_request": "Δεν ήταν δυνατή η αποστολή αιτήματος.",
+ "failed_read_event": "Αποτυχία ανάγνωσης συμβάντων",
+ "failed_send_event": "Αποτυχία αποστολής συμβάντος"
},
"server_offline": {
"description": "Ο διακομιστής σας δεν ανταποκρίνεται σε ορισμένα από τα αιτήματά σας. Παρακάτω είναι μερικοί από τους πιο πιθανούς λόγους.",
@@ -2020,44 +2099,46 @@
"insert_trailing_colon_mentions": "Εισαγάγετε άνω και κάτω τελεία μετά την αναφορά του χρήστη στην αρχή ενός μηνύματος",
"jump_to_bottom_on_send": "Μεταβείτε στο τέλος του χρονολογίου όταν στέλνετε ένα μήνυμα",
"key_backup": {
- "backup_in_progress": "Δημιουργούνται αντίγραφα ασφαλείας των κλειδιών σας (το πρώτο αντίγραφο ασφαλείας μπορεί να διαρκέσει μερικά λεπτά).",
- "backup_success": "Επιτυχία!",
- "cannot_create_backup": "Δεν είναι δυνατή η δημιουργία αντιγράφου ασφαλείας κλειδιού",
- "create_title": "Δημιουργία αντιγράφου ασφαλείας κλειδιού",
"setup_secure_backup": {
+ "backup_setup_success_description": "Τώρα δημιουργούνται αντίγραφα ασφαλείας των κλειδιών σας από αυτήν τη συσκευή.",
+ "backup_setup_success_title": "Επιτυχής δημιουργία ασφαλούς αντιγράφου ασφαλείας",
"cancel_warning": "Εάν ακυρώσετε τώρα, ενδέχεται να χάσετε κρυπτογραφημένα μηνύματα και δεδομένα εάν χάσετε την πρόσβαση στα στοιχεία σύνδεσής σας.",
"confirm_security_phrase": "Επιβεβαιώστε τη Φράση Ασφαλείας σας",
"description": "Προστατευτείτε από την απώλεια πρόσβασης σε κρυπτογραφημένα μηνύματα και δεδομένα, δημιουργώντας αντίγραφα ασφαλείας των κλειδιών κρυπτογράφησης στον διακομιστή σας.",
+ "download_or_copy": "%(downloadButton)s ή %(copyButton)s",
+ "enter_phrase_description": "Εισαγάγετε μια φράση ασφαλείας που γνωρίζετε μόνο εσείς, καθώς χρησιμοποιείται για την προστασία των δεδομένων σας. Για να είστε ασφαλείς, δεν πρέπει να χρησιμοποιήσετε ξανά τον κωδικό πρόσβασης του λογαριασμού σας.",
"enter_phrase_title": "Εισαγάγετε τη Φράση Ασφαλείας",
"enter_phrase_to_confirm": "Εισαγάγετε τη Φράση Ασφαλείας σας για δεύτερη φορά για να την επιβεβαιώσετε.",
- "generate_security_key_description": "Θα δημιουργήσουμε ένα κλειδί ασφαλείας για να το αποθηκεύσετε σε ασφαλές μέρος, όπως έναν διαχειριστή κωδικών πρόσβασης ή ένα χρηματοκιβώτιο.",
- "generate_security_key_title": "Δημιουργήστε ένα κλειδί ασφαλείας",
+ "generate_security_key_description": "Θα δημιουργήσουμε ένα κλειδί ανάκτησης για να το αποθηκεύσετε σε ασφαλές μέρος, όπως έναν διαχειριστή κωδικών πρόσβασης ή ένα χρηματοκιβώτιο.",
+ "generate_security_key_title": "Δημιουργήστε ένα Κλειδί Ανάκτησης",
"pass_phrase_match_failed": "Αυτό δεν ταιριάζει.",
"pass_phrase_match_success": "Ταιριάζει!",
"phrase_strong_enough": "Τέλεια! Αυτή η Φράση Ασφαλείας φαίνεται αρκετά ισχυρή.",
"secret_storage_query_failure": "Δεν είναι δυνατή η υποβολή ερωτήματος για την κατάσταση του μυστικού χώρου αποθήκευσης",
- "security_key_safety_reminder": "Αποθηκεύστε το Κλειδί ασφαλείας σας σε ασφαλές μέρος, όπως έναν διαχείριστη κωδικών πρόσβασης ή ένα χρηματοκιβώτιο, καθώς χρησιμοποιείται για την προστασία των κρυπτογραφημένων δεδομένων σας.",
+ "security_key_safety_reminder": "Αποθηκεύστε το Κλειδί Ανάκτησης σε ασφαλές μέρος, όπως έναν διαχειριστή κωδικών πρόσβασης ή ένα χρηματοκιβώτιο, καθώς χρησιμοποιείται για την προστασία των κρυπτογραφημένων δεδομένων σας.",
"set_phrase_again": "Επιστρέψτε για να το ρυθμίσετε ξανά.",
"settings_reminder": "Μπορείτε επίσης να ρυθμίσετε το Ασφαλές αντίγραφο ασφαλείας και να διαχειριστείτε τα κλειδιά σας στις Ρυθμίσεις.",
"title_confirm_phrase": "Επιβεβαίωση Φράσης Ασφαλείας",
- "title_save_key": "Αποθηκεύστε το κλειδί ασφαλείας σας",
+ "title_save_key": "Αποθηκεύστε το Κλειδί Ανάκτησης",
"title_set_phrase": "Ορίστε μια Φράση Ασφαλείας",
"unable_to_setup": "Δεν είναι δυνατή η ρύθμιση του μυστικού χώρου αποθήκευσης",
"use_different_passphrase": "Να χρησιμοποιηθεί διαφορετική φράση;",
- "use_phrase_only_you_know": "Χρησιμοποιήστε μια μυστική φράση που γνωρίζετε μόνο εσείς και προαιρετικά αποθηκεύστε ένα κλειδί ασφαλείας για να το χρησιμοποιήσετε για τη δημιουργία αντιγράφων ασφαλείας."
+ "use_phrase_only_you_know": "Χρησιμοποιήστε μια μυστική φράση που γνωρίζετε μόνο εσείς και, προαιρετικά, αποθηκεύστε ένα Κλειδί Ανάκτησης για να το χρησιμοποιήσετε ως αντίγραφο ασφαλείας."
}
},
"key_export_import": {
"confirm_passphrase": "Επιβεβαίωση συνθηματικού",
"enter_passphrase": "Εισαγωγή συνθηματικού",
"export_description_1": "Αυτή η διαδικασία σας επιτρέπει να εξαγάγετε τα κλειδιά για τα μηνύματα που έχετε λάβει σε κρυπτογραφημένα δωμάτια σε ένα τοπικό αρχείο. Στη συνέχεια, θα μπορέσετε να εισάγετε το αρχείο σε άλλο πρόγραμμα του Matrix, έτσι ώστε το πρόγραμμα να είναι σε θέση να αποκρυπτογραφήσει αυτά τα μηνύματα.",
+ "export_description_2": "Το εξαγόμενο αρχείο θα επιτρέψει σε οποιονδήποτε μπορεί να το διαβάσει να αποκρυπτογραφήσει τυχόν κρυπτογραφημένα μηνύματα που μπορείτε να δείτε, οπότε θα πρέπει να είστε προσεκτικοί για να το διατηρήσετε ασφαλές. Για να βοηθήσετε σε αυτό, θα πρέπει να εισαγάγετε μια μοναδική φράση πρόσβασης παρακάτω, η οποία θα χρησιμοποιηθεί μόνο για την κρυπτογράφηση των εξαγόμενων δεδομένων. Η εισαγωγή των δεδομένων θα είναι δυνατή μόνο χρησιμοποιώντας την ίδια φράση πρόσβασης.",
"export_title": "Εξαγωγή κλειδιών δωματίου",
"file_to_import": "Αρχείο για εισαγωγή",
"import_description_1": "Αυτή η διαδικασία σας επιτρέπει να εισαγάγετε κλειδιά κρυπτογράφησης που έχετε προηγουμένως εξάγει από άλλο πρόγραμμα του Matrix. Στη συνέχεια, θα μπορέσετε να αποκρυπτογραφήσετε τυχόν μηνύματα που το άλλο πρόγραμμα θα μπορούσε να αποκρυπτογραφήσει.",
"import_description_2": "Το αρχείο εξαγωγής θα είναι προστατευμένο με συνθηματικό. Θα χρειαστεί να πληκτρολογήσετε το συνθηματικό εδώ για να αποκρυπτογραφήσετε το αρχείο.",
"import_title": "Εισαγωγή κλειδιών δωματίου",
"phrase_cannot_be_empty": "Το συνθηματικό δεν πρέπει να είναι κενό",
- "phrase_must_match": "Δεν ταιριάζουν τα συνθηματικά"
+ "phrase_must_match": "Δεν ταιριάζουν τα συνθηματικά",
+ "phrase_strong_enough": "Υπέροχα! Αυτή η φράση πρόσβασης φαίνεται αρκετά ισχυρή"
},
"keyboard": {
"title": "Πληκτρολόγιο"
@@ -2145,6 +2226,7 @@
"prompt_invite": "Ερώτηση πριν από την αποστολή προσκλήσεων σε δυνητικά μη έγκυρα αναγνωριστικά matrix",
"replace_plain_emoji": "Αυτόματη αντικατάσταση απλού κειμένου Emoji",
"security": {
+ "analytics_description": "Μοιραστείτε ανώνυμα δεδομένα για να μας βοηθήσετε να εντοπίσουμε προβλήματα. Τίποτα προσωπικό. Χωρίς τρίτους.",
"bulk_options_accept_all_invites": "Αποδεχτείτε όλες τις %(invitedRooms)sπροσκλήσεις",
"bulk_options_reject_all_invites": "Απόρριψη όλων των προσκλήσεων %(invitedRooms)s",
"bulk_options_section": "Μαζικές επιλογές",
@@ -2154,7 +2236,6 @@
"ignore_users_empty": "Δεν έχετε χρήστες που έχετε αγνοήσει.",
"ignore_users_section": "Χρήστες που αγνοήθηκαν",
"key_backup_algorithm": "Αλγόριθμος:",
- "key_backup_connect": "Συνδέστε αυτήν την συνεδρία με το αντίγραφο ασφαλείας κλειδιού",
"message_search_disable_warning": "Εάν απενεργοποιηθεί, τα μηνύματα από κρυπτογραφημένα δωμάτια δε θα εμφανίζονται στα αποτελέσματα αναζήτησης.",
"message_search_disabled": "Αποθηκεύστε με ασφάλεια κρυπτογραφημένα μηνύματα τοπικά για να εμφανίζονται στα αποτελέσματα αναζήτησης.",
"message_search_enabled": {
@@ -2175,12 +2256,14 @@
"message_search_unsupported_web": "Το %(brand)s δεν μπορεί να αποθηκεύσει με ασφάλεια κρυπτογραφημένα μηνύματα τοπικά ενώ εκτελείται σε πρόγραμμα περιήγησης ιστού. Χρησιμοποιήστε την %(brand)s Επιφάνεια εργασίας για να εμφανίζονται κρυπτογραφημένα μηνύματα στα αποτελέσματα αναζήτησης.",
"record_session_details": "Κατέγραψε το όνομα του πελάτη, την έκδοση και τη διεύθυνση URL για να αναγνωρίζεις τις συνεδρίες πιο εύκολα στον διαχειριστή συνεδρίας",
"send_analytics": "Αποστολή δεδομένων αναλυτικών στοιχείων",
- "strict_encryption": "Μη στέλνετε ποτέ κρυπτογραφημένα μηνύματα σε μη επαληθευμένες συνεδρίες από αυτήν τη συνεδρία"
+ "strict_encryption": "Αποστολή μηνυμάτων μόνο σε επαληθευμένους χρήστες"
},
"send_read_receipts": "Αποστολή αποδείξεων ανάγνωσης",
"send_read_receipts_unsupported": "Ο διακομιστής σου δεν υποστηρίζει την απενεργοποίηση αποστολής αποδείξεων ανάγνωσης.",
"send_typing_notifications": "Αποστολή ειδοποιήσεων πληκτρολόγησης",
"sessions": {
+ "best_security_note": "Για τη βέλτιστη ασφάλεια, επαληθεύστε τις συνεδρίες σας και αποσυνδεθείτε από οποιαδήποτε συνεδρία που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
+ "browser": "Πρόγραμμα περιήγησης",
"confirm_sign_out": {
"one": "Επιβεβαιώστε την αποσύνδεση αυτής της συσκευής",
"other": "Επιβεβαιώστε την αποσύνδεση αυτών των συσκευών"
@@ -2197,8 +2280,83 @@
"one": "Επιβεβαιώστε ότι αποσυνδέεστε από αυτήν τη συσκευή χρησιμοποιώντας Single Sign On για να αποδείξετε την ταυτότητά σας.",
"other": "Επιβεβαιώστε την αποσύνδεση από αυτές τις συσκευές χρησιμοποιώντας Single Sign On για να αποδείξετε την ταυτότητά σας."
},
+ "current_session": "Τρέχουσα συνεδρία",
+ "desktop_session": "Συνεδρία εφαρμογής υπολογιστή",
+ "details_heading": "Λεπτομέρειες συνεδρίας",
+ "device_unverified_description": "Επαληθεύστε ή αποσυνδεθείτε από αυτήν τη συνεδρία για βέλτιστη ασφάλεια και αξιοπιστία.",
+ "device_unverified_description_current": "Επαληθεύστε την τρέχουσα συνεδρία σας για βελτιωμένα ασφαλή μηνύματα.",
+ "device_verified_description": "Αυτή η συνεδρία είναι έτοιμη για ασφαλή ανταλλαγή μηνυμάτων.",
+ "device_verified_description_current": "Η τρέχουσα συνεδρία σας είναι έτοιμη για ασφαλή ανταλλαγή μηνυμάτων.",
+ "error_pusher_state": "Αποτυχία ορισμού κατάστασης pusher",
+ "error_set_name": "Αποτυχία ορισμού ονόματος συνεδρίας",
+ "filter_all": "Όλα",
+ "filter_inactive": "Ανενεργό",
+ "filter_inactive_description": "Ανενεργό για%(inactiveAgeDays)s ημέρες ή και περισσότερο",
+ "filter_label": "Φιλτράρισμα συσκευών",
+ "filter_unverified_description": "Δεν είναι έτοιμο για ασφαλή ανταλλαγή μηνυμάτων",
+ "filter_verified_description": "Έτοιμο για ασφαλή ανταλλαγή μηνυμάτων",
+ "hide_details": "Απόκρυψη λεπτομερειών",
+ "inactive_days": "Ανενεργό για %(inactiveAgeDays)s+ ημέρες",
+ "inactive_sessions": "Ανενεργές συνεδρίες",
+ "inactive_sessions_explainer_1": "Οι ανενεργές συνεδρίες είναι συνεδρίες που δεν έχετε χρησιμοποιήσει για κάποιο χρονικό διάστημα, αλλά συνεχίζουν να λαμβάνουν κλειδιά κρυπτογράφησης.",
+ "inactive_sessions_explainer_2": "Η αφαίρεση ανενεργών συνεδριών βελτιώνει την ασφάλεια και την απόδοση και σας διευκολύνει να εντοπίσετε αν μια νέα συνεδρία είναι ύποπτη.",
+ "inactive_sessions_list_description": "Εξετάστε το ενδεχόμενο να αποσυνδεθείτε από παλιές συνεδρίες (%(inactiveAgeDays)s ημέρες ή παλαιότερες) που δεν χρησιμοποιείτε πλέον.",
+ "ip": "Διεύθυνση IP",
+ "last_activity": "Τελευταία δραστηριότητα",
+ "mobile_session": "Συνεδρία κινητού",
+ "n_sessions_selected": {
+ "one": "%(count)s επιλεγμένη συνεδρία",
+ "other": "%(count)s επιλεγμένες συνεδρίες"
+ },
+ "no_inactive_sessions": "Δεν βρέθηκαν ανενεργές συνεδρίες.",
+ "no_sessions": "Δεν βρέθηκαν συνεδρίες.",
+ "no_unverified_sessions": "Δεν βρέθηκαν μη επαληθευμένες συνεδρίες.",
+ "no_verified_sessions": "Δεν βρέθηκαν επαληθευμένες συνεδρίες.",
+ "os": "Λειτουργικό σύστημα",
+ "other_sessions_heading": "Άλλες συνεδρίες",
+ "push_heading": "Ειδοποιήσεις push",
+ "push_subheading": "Λάβετε ειδοποιήσεις push σε αυτήν τη συνεδρία.",
+ "push_toggle": "Ενεργοποίηση/απενεργοποίηση ειδοποιήσεων push σε αυτήν τη συνεδρία.",
+ "rename_form_caption": "Λάβετε υπόψη ότι τα ονόματα των συνεδριών είναι επίσης ορατά στα άτομα με τα οποία επικοινωνείτε.",
+ "rename_form_heading": "Μετονομασία συνεδρίας",
+ "rename_form_learn_more": "Μετονομασία συνεδριών",
+ "rename_form_learn_more_description_1": "Άλλοι χρήστες σε απευθείας μηνύματα και αίθουσες στις οποίες συμμετέχετε μπορούν να δουν μια πλήρη λίστα των συνεδριών σας.",
+ "rename_form_learn_more_description_2": "Αυτό τους παρέχει την εμπιστοσύνη ότι πραγματικά μιλούν με εσάς, αλλά σημαίνει επίσης ότι μπορούν να δουν το όνομα της συνεδρίας που εισάγετε εδώ.",
+ "security_recommendations": "Συστάσεις ασφαλείας",
+ "security_recommendations_description": "Βελτιώστε την ασφάλεια του λογαριασμού σας ακολουθώντας αυτές τις συστάσεις.",
"session_id": "Αναγνωριστικό συνεδρίας",
- "verify_session": "Επαλήθευση συνεδρίας"
+ "show_details": "Εμφάνιση λεπτομερειών",
+ "sign_in_with_qr": "Σύνδεση νέας συσκευής",
+ "sign_in_with_qr_button": "Εμφάνιση κωδικού QR",
+ "sign_in_with_qr_description": "Χρησιμοποιήστε έναν κωδικό QR για να συνδεθείτε σε άλλη συσκευή και να ρυθμίσετε την ασφαλή ανταλλαγή μηνυμάτων.",
+ "sign_out": "Αποσυνδεθείτε από αυτήν τη συνεδρία",
+ "sign_out_all_other_sessions": "Αποσύνδεση από όλες τις άλλες συνεδρίες (%(otherSessionsCount)s)",
+ "sign_out_confirm_description": {
+ "one": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από τη %(count)s συνεδρία;",
+ "other": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από τις %(count)s συνεδρίες;"
+ },
+ "sign_out_n_sessions": {
+ "one": "Αποσύνδεση από %(count)s συνεδρία",
+ "other": "Αποσύνδεση από %(count)s συνεδρίες"
+ },
+ "title": "Συνεδρίες",
+ "unknown_session": "Άγνωστος τύπος συνεδρίας",
+ "unverified_session": "Μη επαληθευμένη συνεδρία",
+ "unverified_session_explainer_1": "Αυτή η συνεδρία δεν υποστηρίζει κρυπτογράφηση και συνεπώς δεν μπορεί να επαληθευτεί.",
+ "unverified_session_explainer_2": "Δεν θα μπορείτε να συμμετάσχετε σε αίθουσες όπου είναι ενεργοποιημένη η κρυπτογράφηση κατά τη χρήση αυτής της συνεδρίας.",
+ "unverified_session_explainer_3": "Για καλύτερη ασφάλεια και ιδιωτικότητα, συνιστάται να χρησιμοποιείτε εφαρμογές Matrix που υποστηρίζουν κρυπτογράφηση.",
+ "unverified_sessions": "Μη επαληθευμένες συνεδρίες",
+ "unverified_sessions_explainer_1": "Οι μη επαληθευμένες συνεδρίες είναι συνεδρίες στις οποίες έχετε συνδεθεί με τα διαπιστευτήριά σας, αλλά δεν έχουν επαληθευτεί.",
+ "unverified_sessions_explainer_2": "Θα πρέπει να βεβαιωθείτε ιδιαίτερα ότι αναγνωρίζετε αυτές τις συνεδρίες, καθώς ενδέχεται να αποτελούν μη εξουσιοδοτημένη χρήση του λογαριασμού σας.",
+ "unverified_sessions_list_description": "Επαληθεύστε τις συνεδρίες σας για βελτιωμένα ασφαλή μηνύματα ή αποσυνδεθείτε από αυτές που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
+ "url": "URL",
+ "verified_session": "Επαληθευμένη συνεδρία",
+ "verified_sessions": "Επαληθευμένες συνεδρίες",
+ "verified_sessions_explainer_1": "Οι επαληθευμένες συνεδρίες είναι οπουδήποτε χρησιμοποιείτε αυτόν τον λογαριασμό αφού εισαγάγετε τη φράση πρόσβασής σας ή επιβεβαιώσετε την ταυτότητά σας με άλλη επαληθευμένη συνεδρία.",
+ "verified_sessions_explainer_2": "Αυτό σημαίνει ότι έχετε όλα τα κλειδιά που απαιτούνται για να ξεκλειδώσετε τα κρυπτογραφημένα μηνύματά σας και να επιβεβαιώσετε σε άλλους χρήστες ότι εμπιστεύεστε αυτήν τη συνεδρία.",
+ "verified_sessions_list_description": "Για βέλτιστη ασφάλεια, αποσυνδεθείτε από οποιαδήποτε συνεδρία που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
+ "verify_session": "Επαλήθευση συνεδρίας",
+ "web_session": "Συνεδρία web"
},
"show_avatar_changes": "Εμφάνιση αλλαγών εικόνας προφίλ",
"show_breadcrumbs": "Εμφάνιση συντομεύσεων σε δωμάτια που προβλήθηκαν πρόσφατα πάνω από τη λίστα δωματίων",
@@ -2219,6 +2377,7 @@
"metaspaces_orphans_description": "Ομαδοποιήστε σε ένα μέρος όλα τα δωμάτιά σας που δεν αποτελούν μέρος ενός χώρου.",
"metaspaces_people_description": "Ομαδοποιήστε όλα τα άτομα σας σε ένα μέρος.",
"metaspaces_subsection": "Χώροι για εμφάνιση",
+ "spaces_explainer": "Οι χώροι είναι τρόποι ομαδοποίησης αιθουσών και ανθρώπων. Παράλληλα με τους χώρους στους οποίους βρίσκεστε, μπορείτε να χρησιμοποιήσετε και κάποιους προκατασκευασμένους χώρους.",
"title": "Πλαϊνή μπάρα"
},
"start_automatically": "Αυτόματη έναρξη μετά τη σύνδεση",
@@ -2249,7 +2408,8 @@
"voice_processing": "Επεξεργασία φωνής",
"voice_section": "Ρυθμίσεις φωνής"
},
- "warn_quit": "Προειδοποιήστε πριν την παραίτηση"
+ "warn_quit": "Προειδοποιήστε πριν την παραίτηση",
+ "warning": "ΠΡΟΕΙΔΟΠΟΙΗΣΗ:"
},
"share": {
"permalink_message": "Σύνδεσμος στο επιλεγμένο μήνυμα",
@@ -2260,6 +2420,7 @@
},
"slash_command": {
"addwidget": "Προσθέτει ένα προσαρμοσμένο widget μέσω URL στο δωμάτιο",
+ "addwidget_iframe_missing_src": "Το iframe δεν έχει χαρακτηριστικό src",
"addwidget_invalid_protocol": "Παρακαλώ εισάγετε ένα widget URL με https:// ή http://",
"addwidget_missing_url": "Παρακαλώ εισάγετε ένα widget URL ή ενσωματώστε κώδικα",
"addwidget_no_permissions": "Δεν μπορείτε να τροποποιήσετε μικροεφαρμογές σε αυτό το δωμάτιο.",
@@ -2273,10 +2434,12 @@
"command_error": "Σφάλμα εντολής",
"converttodm": "Μετατρέπει το δωμάτιο σε προσωπική συνομιλία",
"converttoroom": "Μετατρέπει την προσωπική συνομιλία σε δωμάτιο",
+ "could_not_find_room": "Δεν ήταν δυνατή η εύρεση αίθουσας",
"deop": "Deop χρήστη με το συγκεκριμένο αναγνωριστικό",
"devtools": "Ανοίγει το παράθυρο Εργαλείων για Προγραμματιστές",
"discardsession": "Επιβάλλει την τρέχουσα εξερχόμενη ομαδική συνεδρία σε κρυπτογραφημένο δωμάτιο για απόρριψη",
"error_invalid_rendering_type": "Σφάλμα εντολής: Δεν είναι δυνατή η εύρεση του τύπου απόδοσης (%(renderingType)s)",
+ "error_invalid_room": "Η εντολή απέτυχε: Δεν ήταν δυνατή η εύρεση αίθουσας (%(roomId)s)",
"error_invalid_runfn": "Σφάλμα εντολής: Δεν είναι δυνατή η χρήση της εντολής slash.",
"help": "Εμφανίζει τη λίστα εντολών με τρόπους χρήσης και περιγραφές",
"help_dialog_title": "Βοήθεια Εντολών",
@@ -2289,6 +2452,7 @@
"invite_3pid_needs_is_error": "Χρησιμοποιήστε έναν διακομιστή ταυτοτήτων για να προσκαλέσετε μέσω email. Μπορείτε να κάνετε διαχείριση στις Ρυθμίσεις.",
"invite_3pid_use_default_is_title": "Χρησιμοποιήστε ένα διακομιστή ταυτοτήτων",
"invite_3pid_use_default_is_title_description": "Χρησιμοποιήστε έναν διακομιστή ταυτοτήτων για να προσκαλέσετε μέσω email. Πατήστε συνέχεια για να χρησιμοποιήσετε τον προεπιλεγμένο διακομιστή ταυτοτήτων (%(defaultIdentityServerName)s) ή μπείτε στην διαχείριση στις Ρυθμίσεις.",
+ "invite_failed": "Ο χρήστης (%(user)s) δεν προσκλήθηκε στο %(roomId)s, αλλά δεν δόθηκε σφάλμα από το βοηθητικό πρόγραμμα πρόσκλησης.",
"join": "Σύνδεση στην αίθουσα με την δοθείσα διεύθυνση",
"jumptodate": "Μεταβείτε στη δεδομένη ημερομηνία στο χρονολόγιο",
"jumptodate_invalid_input": "Αδυναμία κατανόησης της δοθείσας ημερομηνίας (%(inputDate)s). Προσπαθήστε να χρησιμοποιήσετε την μορφή YYYY-MM-DD.",
@@ -2333,7 +2497,8 @@
"upgraderoom": "Αναβαθμίζει το δωμάτιο σε μια καινούργια έκδοση",
"upgraderoom_permission_error": "Δεν διαθέτετε τις απαιτούμενες άδειες για να χρησιμοποιήσετε αυτήν την εντολή.",
"usage": "Χρήση",
- "verify": "Επιβεβαιώνει έναν χρήστη, συνεδρία, και pubkey tuple",
+ "verify": "Επαληθεύστε χειροκίνητα μια από τις δικές σας συσκευές",
+ "view": "Προβάλει την αίθουσα με την δεδομένη διεύθυνση",
"whois": "Εμφανίζει πληροφορίες για έναν χρήστη"
},
"space": {
@@ -2929,6 +3094,8 @@
"truncated_list_n_more": {
"other": "Και %(count)s ακόμα..."
},
+ "unsupported_server_description": "Αυτός ο διακομιστής χρησιμοποιεί μια παλαιότερη έκδοση του Matrix. Αναβαθμίστε σε Matrix %(version)s στο Matrix για να χρησιμοποιήσετε το %(brand)s χωρίς σφάλματα.",
+ "unsupported_server_title": "Ο διακομιστής σας δεν υποστηρίζεται",
"update": {
"changelog": "Αλλαγές",
"check_action": "Έλεγχος για ενημέρωση",
@@ -3041,6 +3208,7 @@
"call_held": "%(peerName)s έβαλε την κλήση σε αναμονή",
"call_held_resume": "Έχετε βάλει την κλήση σε αναμονή Επαναφορά",
"call_held_switch": "Έχετε βάλει την κλήση σε αναμονή Switch",
+ "call_toast_unknown_room": "Άγνωστη αίθουσα",
"camera_disabled": "Η κάμερά σας είναι απενεργοποιημένη",
"camera_enabled": "Η κάμερά σας είναι ακόμα ενεργοποιημένη",
"cannot_call_yourself_description": "Δεν μπορείτε να καλέσετε τον εαυτό σας.",
@@ -3053,19 +3221,30 @@
"dialpad": "Πληκτρολόγιο κλήσης",
"disable_camera": "Απενεργοποίηση κάμερας",
"disable_microphone": "Σίγαση μικροφώνου",
+ "disabled_no_one_here": "Δεν υπάρχει κανείς εδώ για να καλέσετε",
+ "disabled_no_perms_start_video_call": "Δεν έχετε δικαίωμα έναρξης βιντεοκλήσεων",
+ "disabled_no_perms_start_voice_call": "Δεν έχετε δικαίωμα έναρξης φωνητικών κλήσεων",
+ "disabled_ongoing_call": "Κλήση σε εξέλιξη",
"enable_camera": "Ενεργοποίηση κάμερας",
"enable_microphone": "Κατάργηση σίγασης μικροφώνου",
"expand": "Επιστροφή στην κλήση",
"hangup": "Κλείσιμο",
"hide_sidebar_button": "Απόκρυψη πλαϊνής μπάρας",
"input_devices": "Συσκευές εισόδου",
+ "join_button_tooltip_call_full": "Λυπούμαστε — αυτή η κλήση είναι πλήρης αυτήν τη στιγμή",
"maximise": "Γέμισμα οθόνης",
"misconfigured_server": "Η κλήση απέτυχε λόγω της λανθασμένης διάρθρωσης του διακομιστή",
"misconfigured_server_description": "Παρακαλείστε να ρωτήσετε τον διαχειριστή του κεντρικού διακομιστή σας (%(homeserverDomain)s) να ρυθμίσουν έναν διακομιστή πρωτοκόλλου TURN ώστε οι κλήσεις να λειτουργούν απρόσκοπτα.",
+ "misconfigured_server_fallback": "Εναλλακτικά, μπορείτε να δοκιμάσετε να χρησιμοποιήσετε τον δημόσιο διακομιστή στη διεύθυνση , αλλά αυτό δεν θα είναι τόσο αξιόπιστο και θα κοινοποιήσει τη διεύθυνση IP σας σε αυτόν τον διακομιστή. Μπορείτε επίσης να το διαχειριστείτε αυτό στις Ρυθμίσεις.",
+ "misconfigured_server_fallback_accept": "Δοκιμάστε να χρησιμοποιήσετε το %(server)s",
"more_button": "Περισσότερα",
"msisdn_lookup_failed": "Αδυναμία αναζήτησης αριθμού τηλεφώνου",
"msisdn_lookup_failed_description": "Υπήρξε ένα σφάλμα κατά την αναζήτηση αριθμού τηλεφώνου",
"msisdn_transfer_failed": "Αδυναμία μεταφοράς κλήσης",
+ "n_people_joined": {
+ "one": "%(count)s άτομο εντάχθηκε",
+ "other": "%(count)s άτομα εντάχθηκαν"
+ },
"no_audio_input_description": "Δε βρέθηκε μικρόφωνο στη συσκευή σας. Παρακαλώ ελέγξτε τις ρυθμίσεις σας και δοκιμάστε ξανά.",
"no_audio_input_title": "Δε βρέθηκε μικρόφωνο",
"no_media_perms_description": "Μπορεί να χρειαστεί να ορίσετε χειροκίνητα την πρόσβαση του %(brand)s στο μικρόφωνο/κάμερα",
@@ -3195,10 +3374,12 @@
"error_loading": "Σφάλμα φόρτωσης Μικροεφαρμογής",
"error_mixed_content": "Σφάλμα - Μικτό περιεχόμενο",
"error_need_invite_permission": "Για να το κάνετε αυτό πρέπει να έχετε τη δυνατότητα να προσκαλέσετε χρήστες.",
+ "error_need_kick_permission": "Πρέπει να έχετε τη δυνατότητα να διώξετε χρήστες για να το κάνετε αυτό.",
"error_need_to_be_logged_in": "Πρέπει να είστε συνδεδεμένος.",
"error_unable_start_audio_stream_description": "Δεν είναι δυνατή η έναρξη ροής ήχου.",
"error_unable_start_audio_stream_title": "Η έναρξη της ζωντανής ροής απέτυχε",
- "modal_data_warning": "Τα δεδομένα σε αυτήν την οθόνη μοιράζονται με το %(widgetDomain)s",
+ "modal_data_warning": "Τα παρακάτω δεδομένα μοιράζονται με %(widgetDomain)s",
+ "modal_title_default": "Modal Widget",
"no_name": "Άγνωστη εφαρμογή",
"open_id_permissions_dialog": {
"remember_selection": "Να το θυμάσαι αυτό",
@@ -3206,15 +3387,20 @@
"title": "Επιτρέψτε σε αυτήν τη μικροεφαρμογή να επαληθεύσει την ταυτότητά σας"
},
"popout": "Αναδυόμενη μικροεφαρμογή",
- "set_room_layout": "Ορίστε τη διάταξη του δωματίου μου για όλους",
+ "set_room_layout": "Ορισμός διάταξης για όλους",
+ "shared_data_avatar": "URL της εικόνας προφίλ σας",
+ "shared_data_device_id": "Το αναγνωριστικό της συσκευής σας",
+ "shared_data_lang": "Η γλώσσα σας",
"shared_data_mxid": "Το αναγνωριστικό (ID) χρήστη σας",
"shared_data_name": "Το εμφανιζόμενο όνομά σας",
"shared_data_room_id": "ID Δωματίου",
"shared_data_theme": "Το θέμα εμφάνισης",
+ "shared_data_url": "%(brand)s URL",
"shared_data_warning": "Η χρήση αυτής της μικροεφαρμογής ενδέχεται να μοιράζεται δεδομένα με %(widgetDomain)s.",
"shared_data_warning_im": "Η χρήση αυτής της μικροεφαρμογής μπορεί να μοιραστεί δεδομένα με το %(widgetDomain)s και τον διαχειριστή πρόσθετων.",
"shared_data_widget_id": "Ταυτότητα μικροεφαρμογής",
"unencrypted_warning": "Οι μικροεοεφαρμογές δε χρησιμοποιούν κρυπτογράφηση μηνυμάτων.",
+ "unmaximise": "Απο-μεγιστοποίηση",
"unpin_to_view_right_panel": "Ξεκαρφιτσώστε αυτήν τη μικροεφαρμογή για να την προβάλετε σε αυτόν τον πίνακα"
},
"zxcvbn": {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 56f22dc6d7..3be18c544f 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "You'll lose access to your encrypted messages",
"setup_secure_backup_description_1": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.",
"setup_secure_backup_description_2": "When you sign out, these keys will be deleted from this device, which means you won't be able to read encrypted messages unless you have the keys for them on your other devices, or backed them up to the server.",
- "skip_key_backup": "I don't want my encrypted messages",
- "use_key_backup": "Start using Key Backup"
+ "skip_key_backup": "I don't want my encrypted messages"
},
"misconfigured_body": "Ask your %(brand)s admin to check your config for incorrect or duplicate entries.",
"misconfigured_title": "Your %(brand)s is misconfigured",
@@ -968,9 +967,7 @@
},
"reset_all_button": "Forgotten or lost all recovery methods? Reset all",
"set_up_recovery": "Set up recovery",
- "set_up_recovery_later": "Not now",
"set_up_recovery_toast_description": "Generate a recovery key that can be used to restore your encrypted message history in case you lose access to your devices.",
- "set_up_toast_description": "Safeguard against losing access to encrypted messages & data",
"set_up_toast_title": "Set up Secure Backup",
"setup_secure_backup": {
"explainer": "Back up your keys before signing out to avoid losing them."
@@ -1453,6 +1450,7 @@
"room_list_navigate_down": "Navigate down in the room list",
"room_list_navigate_up": "Navigate up in the room list",
"room_list_select_room": "Select room from the room list",
+ "save": "Save",
"scroll_down_timeline": "Scroll down in the timeline",
"scroll_up_timeline": "Scroll up in the timeline",
"search": "Search (must be enabled)",
@@ -2714,11 +2712,6 @@
},
"jump_to_bottom_on_send": "Jump to the bottom of the timeline when you send a message",
"key_backup": {
- "backup_in_progress": "Your keys are being backed up (the first backup could take a few minutes).",
- "backup_starting": "Starting backup…",
- "backup_success": "Success!",
- "cannot_create_backup": "Unable to create key backup",
- "create_title": "Create key backup",
"setup_secure_backup": {
"backup_setup_success_description": "Your keys are now being backed up from this device.",
"backup_setup_success_title": "Secure Backup successful",
@@ -2845,6 +2838,7 @@
"composer_heading": "Composer",
"default_timezone": "Browser default (%(timezone)s)",
"dialog_title": "Settings: Preferences",
+ "enable_content_protection": "Enable content protection",
"enable_hardware_acceleration": "Enable hardware acceleration",
"enable_tray_icon": "Show tray icon and minimise window to it on close",
"keyboard_heading": "Keyboard shortcuts",
@@ -2878,7 +2872,6 @@
"ignore_users_empty": "You have no ignored users.",
"ignore_users_section": "Ignored users",
"key_backup_algorithm": "Algorithm:",
- "key_backup_connect": "Connect this session to Key Backup",
"message_search_disable_warning": "If disabled, messages from encrypted rooms won't appear in search results.",
"message_search_disabled": "Securely cache encrypted messages locally for them to appear in search results.",
"message_search_enabled": {
@@ -3378,7 +3371,6 @@
"unable_to_decrypt": "Unable to decrypt message"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
- "download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file",
diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json
index 5ac803f285..1f22295141 100644
--- a/src/i18n/strings/eo.json
+++ b/src/i18n/strings/eo.json
@@ -184,8 +184,7 @@
"megolm_export": "Mane elporti ŝlosilojn",
"setup_key_backup_title": "Vi perdos aliron al viaj ĉifritaj mesaĝoj",
"setup_secure_backup_description_1": "Ĉifritaj mesaĝoj estas sekurigitaj per tutvoja ĉifrado. Nur vi kaj la ricevonto(j) havas la ŝlosilojn necesajn por legado.",
- "skip_key_backup": "Mi ne volas miajn ĉifritajn mesaĝojn",
- "use_key_backup": "Ekuzi Savkopiadon de ŝlosiloj"
+ "skip_key_backup": "Mi ne volas miajn ĉifritajn mesaĝojn"
},
"misconfigured_body": "Petu vian %(brand)s-administranton kontroli vian agordaron je malĝustaj aŭ duoblaj eroj.",
"misconfigured_title": "Via kliento %(brand)s estas misagordita",
@@ -635,7 +634,6 @@
"warning": "Se vi ne forigis la rehavan metodon, eble atakanto provas aliri vian konton. Vi tuj ŝanĝu la pasvorton de via konto, kaj agordu novan rehavan metodon en la agordoj."
},
"reset_all_button": "Ĉu vi forgesis aŭ perdis ĉiujn manierojn de rehavo? Restarigu ĉion",
- "set_up_toast_description": "Malhelpu perdon de aliro al ĉifritaj mesaĝoj kaj datumoj",
"set_up_toast_title": "Agordi Sekuran savkopiadon",
"setup_secure_backup": {
"explainer": "Savkopiu viajn ŝlosilojn antaŭ adiaŭo, por ilin ne perdi."
@@ -1696,10 +1694,6 @@
"inline_url_previews_room_account": "Ŝalti URL-antaŭrigardon en ĉi tiu ĉambro (nur por vi)",
"jump_to_bottom_on_send": "Salti al subo de historio sendinte mesaĝon",
"key_backup": {
- "backup_in_progress": "Viaj ŝlosiloj estas savkopiataj (la unua savkopio povas daŭri kelkajn minutojn).",
- "backup_success": "Sukceso!",
- "cannot_create_backup": "Ne povas krei savkopion de ŝlosiloj",
- "create_title": "Krei savkopion de ŝlosiloj",
"setup_secure_backup": {
"cancel_warning": "Se vi nuligos nun, vi eble perdos ĉifritajn mesaĝojn kaj datumojn se vi perdos aliron al viaj salutoj.",
"confirm_security_phrase": "Konfirmu vian Sekurecan frazon",
@@ -1791,7 +1785,6 @@
"ignore_users_empty": "Vi malatentas neniujn uzantojn.",
"ignore_users_section": "Malatentaj uzantoj",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Konekti ĉi tiun salutaĵon al Savkopiado de ŝlosiloj",
"message_search_disable_warning": "Post malŝalto, mesaĝoj el ĉifritaj ĉambroj ne aperos en serĉorezultoj.",
"message_search_disabled": "Sekure kaŝmemori ĉifritajn mesaĝojn loke, por aperigi ilin en serĉrezultoj.",
"message_search_enabled": {
diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json
index 75a08251c5..63badc3b8f 100644
--- a/src/i18n/strings/es.json
+++ b/src/i18n/strings/es.json
@@ -212,8 +212,7 @@
"setup_key_backup_title": "Perderás acceso a tus mensajes cifrados",
"setup_secure_backup_description_1": "Los mensajes cifrados son seguros con el cifrado punto a punto. Solo tú y el/los destinatario/s tiene/n las claves para leer estos mensajes.",
"setup_secure_backup_description_2": "Al cerrar sesión, estas claves serán eliminadas del dispositivo. Esto significa que no podrás leer mensajes cifrados salvo que tengas sus claves en otros dispositivos, o hayas hecho una copia de seguridad usando el servidor.",
- "skip_key_backup": "No quiero mis mensajes cifrados",
- "use_key_backup": "Comenzar a usar la copia de claves"
+ "skip_key_backup": "No quiero mis mensajes cifrados"
},
"misconfigured_body": "Solicita al administrador de %(brand)s que compruebe si hay entradas duplicadas o erróneas en tu configuración.",
"misconfigured_title": "Tu %(brand)s tiene un error de configuración",
@@ -782,7 +781,6 @@
"warning": "Si no eliminó el método de recuperación, es posible que un atacante esté intentando acceder a su cuenta. Cambie la contraseña de su cuenta y configure un nuevo método de recuperación inmediatamente en Configuración."
},
"reset_all_button": "¿Has olvidado o perdido todos los métodos de recuperación? Restablecer todo",
- "set_up_toast_description": "Evita perder acceso a datos y mensajes cifrados",
"set_up_toast_title": "Configurar copia de seguridad segura",
"setup_secure_backup": {
"explainer": "Haz copia de seguridad de tus claves antes de cerrar sesión para evitar perderlas."
@@ -2138,10 +2136,6 @@
"insert_trailing_colon_mentions": "Inserta automáticamente dos puntos después de las menciones que hagas al principio de los mensajes",
"jump_to_bottom_on_send": "Saltar abajo del todo al enviar un mensaje",
"key_backup": {
- "backup_in_progress": "Se está realizando una copia de seguridad de sus claves (la primera copia de seguridad puede tardar unos minutos).",
- "backup_success": "¡Éxito!",
- "cannot_create_backup": "No se puede crear una copia de seguridad de la clave",
- "create_title": "Crear copia de seguridad de claves",
"setup_secure_backup": {
"cancel_warning": "Si cancela ahora, puede perder mensajes y datos cifrados si pierde el acceso a sus inicios de sesión.",
"confirm_security_phrase": "Confirma tu frase de seguridad",
@@ -2243,7 +2237,6 @@
"ignore_users_empty": "No has ignorado a nadie.",
"ignore_users_section": "Usuarios ignorados",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Conecta esta sesión a la copia de respaldo de tu clave",
"message_search_disable_warning": "Si está desactivado, los mensajes de las salas cifradas no aparecerán en los resultados de búsqueda.",
"message_search_disabled": "Almacenar localmente, de manera segura, a los mensajes cifrados localmente para que aparezcan en los resultados de búsqueda.",
"message_search_enabled": {
diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json
index 24f5661733..965298dd1e 100644
--- a/src/i18n/strings/et.json
+++ b/src/i18n/strings/et.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Sa kaotad ligipääsu oma krüptitud sõnumitele",
"setup_secure_backup_description_1": "Krüptitud sõnumid kasutavad läbivat krüptimist. Ainult sinul ja saaja(te)l on võtmed selliste sõnumite lugemiseks.",
"setup_secure_backup_description_2": "Kui sa logid välja, siis krüptovõtmed kustutatakse sellest seadmest. Seega, kui sul pole krüptovõtmeid varundatud teistes seadmetes või kasutusel serveripoolset varundust, siis sa krüptitud sõnumeid hiljem lugeda ei saa.",
- "skip_key_backup": "Ma ei soovi oma krüptitud sõnumeid",
- "use_key_backup": "Võta kasutusele krüptovõtmete varundamine"
+ "skip_key_backup": "Ma ei soovi oma krüptitud sõnumeid"
},
"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",
@@ -968,9 +967,7 @@
},
"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": {
"explainer": "Vältimaks nende kaotamist, varunda krüptovõtmed enne väljalogimist."
@@ -2066,6 +2063,7 @@
"read_topic": "Teema lugemiseks klõpsi",
"rejecting": "Hülgan kutset…",
"rejoin_button": "Liitu uuesti",
+ "room_content": "Jututoa sisu",
"room_is_low_priority": "See on vähetähtis jututuba",
"search": {
"all_rooms_button": "Otsi kõikidest jututubadest",
@@ -2713,11 +2711,6 @@
},
"jump_to_bottom_on_send": "Sõnumi saatmiseks hüppa ajajoone lõppu",
"key_backup": {
- "backup_in_progress": "Sinu krüptovõtmeid varundatakse (esimese varukoopia tegemine võib võtta paar minutit).",
- "backup_starting": "Alustame varundamist…",
- "backup_success": "Õnnestus!",
- "cannot_create_backup": "Ei õnnestu teha võtmetest varukoopiat",
- "create_title": "Tee võtmetest varukoopia",
"setup_secure_backup": {
"backup_setup_success_description": "Sinu krüptovõtmed on parasjagu sellest seadmest varundamisel.",
"backup_setup_success_title": "Krüptovõtmete varundus õnnestus",
@@ -2844,6 +2837,7 @@
"composer_heading": "Sõnumite kirjutamine",
"default_timezone": "Brauseri vaikimisi ajavöönd (%(timezone)s)",
"dialog_title": "Seadistused: Eelistused",
+ "enable_content_protection": "Kasuta sisu kaitset",
"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",
@@ -2877,7 +2871,6 @@
"ignore_users_empty": "Sa ei ole veel kedagi eiranud.",
"ignore_users_section": "Eiratud kasutajad",
"key_backup_algorithm": "Algoritm:",
- "key_backup_connect": "Seo see sessioon krüptovõtmete varundusega",
"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": {
diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json
index acf6654db0..a910167638 100644
--- a/src/i18n/strings/fa.json
+++ b/src/i18n/strings/fa.json
@@ -177,8 +177,7 @@
"setup_key_backup_title": "دسترسی به پیامهای رمزشدهی خود را از دست خواهید داد",
"setup_secure_backup_description_1": "پیامهای رمزشده با رمزنگاری سرتاسر ایمن میشوند. فقط شما و طرف گیرنده(ها) کلیدهای خواندن این پیام ها را در اختیار دارید.",
"setup_secure_backup_description_2": "وقتی از سیستم خارج میشوید، این کلیدها از این دستگاه حذف میشوند، به این معنی که نمیتوانید پیامهای رمزگذاریشده را بخوانید مگر اینکه کلیدهای آنها را در دستگاههای دیگر خود داشته باشید یا از آنها در سرور نسخه پشتیبان تهیه کنید.",
- "skip_key_backup": "پیامهای رمزشدهی خود را نمیخواهم",
- "use_key_backup": "شروع استفاده از نسخهی پشتیبان کلید"
+ "skip_key_backup": "پیامهای رمزشدهی خود را نمیخواهم"
},
"misconfigured_body": "از مدیر %(brand)s خود بخواهید تا پیکربندی شما را از جهت ورودیهای نادرست یا تکراری بررسی کند.",
"misconfigured_title": "%(brand)sی شما به درستی پیکربندی نشدهاست",
@@ -595,7 +594,6 @@
"warning": "اگر متد بازیابی را حذف نکردهاید، ممکن است حملهکنندهای سعی در دسترسی به حسابکاربری شما داشته باشد. گذرواژه حساب کاربری خود را تغییر داده و فورا یک روش بازیابی را از بخش تنظیمات خود تنظیم کنید."
},
"reset_all_button": "همه روشهای بازیابی را فراموش کرده یا از دست دادهاید؟ بازراهاندازی (reset) همه",
- "set_up_toast_description": "محافظ در برابر از دستدادن دادهها و پیامهای رمزشده",
"set_up_toast_title": "پشتیبانگیری امن را انجام دهید",
"setup_secure_backup": {
"explainer": "پیش از خروج از حساب کاربری، از کلیدهای خود پشتیبان بگیرید تا آنها را از دست ندهید."
@@ -1494,10 +1492,6 @@
"inline_url_previews_room_account": "فعالسازی پیشنمایش URL برای این اتاق (تنها شما را تحت تاثیر قرار میدهد)",
"jump_to_bottom_on_send": "زمانی که پیام ارسال میکنید، به صورت خودکار به آخرین پیام پرش کن",
"key_backup": {
- "backup_in_progress": "در حال پیشتیبانگیری از کلیدهای شما (اولین نسخه پشتیبان ممکن است چند دقیقه طول بکشد).",
- "backup_success": "موفقیتآمیز بود!",
- "cannot_create_backup": "ایجاد کلید پشتیبانگیری امکانپذیر نیست",
- "create_title": "ساختن نسخهی پشتیبان کلید",
"setup_secure_backup": {
"cancel_warning": "اگر الان لغو کنید، ممکن است پیامها و دادههای رمزشدهی خود را در صورت خارجشدن از حسابهای کاربریتان، از دست دهید.",
"confirm_security_phrase": "عبارت امنیتی خود را تأیید کنیدعبارت امنیتی خود را تائید نمائید",
@@ -1578,7 +1572,6 @@
"ignore_users_empty": "شما هیچ کاربری را نادیده نگرفتهاید.",
"ignore_users_section": "کاربران نادیدهگرفتهشده",
"key_backup_algorithm": "الگوریتم:",
- "key_backup_connect": "این نشست را به کلید پشتیبانگیر متصل کن",
"message_search_disable_warning": "اگر غیر فعال شود، پیامهای اتاقهای رمزشده در نتایج جستجوها نمایش داده نمیشوند.",
"message_search_disabled": "پیامهای رمزشده را به صورتی محلی و امن ذخیره کرده تا در نتایج جستجو ظاهر شوند.",
"message_search_enabled": {
diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json
index 7e993adc31..b0a9f7e974 100644
--- a/src/i18n/strings/fi.json
+++ b/src/i18n/strings/fi.json
@@ -224,8 +224,7 @@
"megolm_export": "Vie avaimet käsin",
"setup_key_backup_title": "Menetät pääsyn salattuihin viesteihisi",
"setup_secure_backup_description_1": "Salatut viestit turvataan päästä päähän -salauksella. Vain sinä ja viestien vastaanottaja(t) omaavat avaimet näiden viestien lukemiseen.",
- "skip_key_backup": "En halua salattuja viestejäni",
- "use_key_backup": "Aloita avainvarmuuskopion käyttö"
+ "skip_key_backup": "En halua salattuja viestejäni"
},
"misconfigured_body": "Pyydä %(brand)s-ylläpitäjääsi tarkistamaan, onko asetuksissasivirheellisiä tai toistettuja merkintöjä.",
"misconfigured_title": "%(brand)sin asetukset ovat pielessä",
@@ -829,9 +828,7 @@
},
"reset_all_button": "Unohtanut tai kadottanut kaikki palautustavat? Nollaa kaikki",
"set_up_recovery": "Määritä palautus",
- "set_up_recovery_later": "Ei nyt",
"set_up_recovery_toast_description": "Luo palautusavain, jota voit käyttää salatun viestihistorian palauttamiseen, jos menetät pääsyn laitteisiisi.",
- "set_up_toast_description": "Suojaudu salattuihin viesteihin ja tietoihin pääsyn menettämiseltä",
"set_up_toast_title": "Määritä turvallinen varmuuskopio",
"setup_secure_backup": {
"explainer": "Varmuuskopioi avaimesi ennen kuin kirjaudut ulos välttääksesi avainten menetyksen."
@@ -2304,11 +2301,6 @@
"insert_trailing_colon_mentions": "Lisää kaksoispiste käyttäjän maininnan perään viestin alussa",
"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",
"setup_secure_backup": {
"cancel_warning": "Jos peruutat nyt, voit menettää salattuja viestejä ja tietoja, jos menetät pääsyn kirjautumistietoihisi.",
"confirm_security_phrase": "Vahvista turvalause",
@@ -2436,7 +2428,6 @@
"ignore_users_empty": "Et ole sivuuttanut käyttäjiä.",
"ignore_users_section": "Sivuutetut käyttäjät",
"key_backup_algorithm": "Algoritmi:",
- "key_backup_connect": "Yhdistä tämä istunto avainten varmuuskopiointiin",
"message_search_disable_warning": "Jos ei ole käytössä, salattujen huoneiden viestejä ei näytetä hakutuloksissa.",
"message_search_disabled": "Pidä salatut viestit turvallisessa välimuistissa, jotta ne näkyvät hakutuloksissa.",
"message_search_enabled": {
diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json
index e7cab1d82a..00b6e11794 100644
--- a/src/i18n/strings/fr.json
+++ b/src/i18n/strings/fr.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Vous perdrez l’accès à vos messages chiffrés",
"setup_secure_backup_description_1": "Les messages chiffrés sont sécurisés avec un chiffrement de bout en bout. Seuls vous et le(s) destinataire(s) ont les clés pour lire ces messages.",
"setup_secure_backup_description_2": "Quand vous vous déconnectez, ces clés seront supprimées de cet appareil, et vous ne pourrez plus lire les messages chiffrés à moins d’avoir les clés de ces messages sur vos autres appareils, ou de les avoir sauvegardées sur le serveur.",
- "skip_key_backup": "Je ne veux pas de mes messages chiffrés",
- "use_key_backup": "Commencer à utiliser la sauvegarde de clés"
+ "skip_key_backup": "Je ne veux pas de mes messages chiffrés"
},
"misconfigured_body": "Demandez à votre administrateur %(brand)s de vérifier que votre configuration ne contient pas d’entrées incorrectes ou en double.",
"misconfigured_title": "Votre %(brand)s est mal configuré",
@@ -786,6 +785,7 @@
"cross_signing_status": "État de la signature croisée :",
"cross_signing_untrusted": "Votre compte a une identité de signature croisée dans le coffre secret, mais cette session ne lui fait pas encore confiance.",
"crypto_not_available": "Le module cryptographique n'est pas disponible",
+ "device_id": "Identifiant de l'appareil",
"key_backup_active_version": "Version de sauvegarde active :",
"key_backup_active_version_none": "Aucun",
"key_backup_inactive_warning": "Vos clés ne sont pas sauvegardées sur cette session.",
@@ -798,6 +798,8 @@
"secret_storage_ready": "prêt",
"secret_storage_status": "Coffre secret :",
"self_signing_private_key_cached_status": "Clé privée d’auto-signature :",
+ "session": "Session",
+ "session_fingerprint": "Empreinte numérique (clé de la session)",
"title": "Chiffrement de bout en bout",
"user_signing_private_key_cached_status": "Clé privée de signature de l’utilisateur :"
},
@@ -823,6 +825,7 @@
"low_bandwidth_mode": "Mode faible bande passante",
"low_bandwidth_mode_description": "Nécessite un serveur d’accueil compatible.",
"main_timeline": "Historique principal",
+ "manual_device_verification": "Vérification manuelle de l'appareil",
"no_receipt_found": "Aucun accusé disponible",
"notification_state": "L’état des notifications est %(notificationState)s",
"notifications_debug": "Débogage des notifications",
@@ -915,7 +918,7 @@
"key_validation_text": {
"wrong_security_key": "La clé de récupération que vous avez saisie est incorrecte."
},
- "privacy_warning": "Assurez vous que personne d'autre ne regarde votre écran !",
+ "privacy_warning": "Assurez-vous que personne d'autre ne regarde votre écran !",
"restoring": "Restauration des clés depuis la sauvegarde",
"security_key_title": "Clé de récupération"
},
@@ -964,9 +967,7 @@
},
"reset_all_button": "Vous avez perdu ou oublié tous vos moyens de récupération ? Tout réinitialiser",
"set_up_recovery": "Configurer la récupération",
- "set_up_recovery_later": "Pas maintenant",
"set_up_recovery_toast_description": "Générez une clé de récupération qui peut être utilisée pour restaurer l'historique de vos messages chiffrés au cas où vous perdriez l'accès à vos appareils.",
- "set_up_toast_description": "Sécurité contre la perte d’accès aux messages et données chiffrées",
"set_up_toast_title": "Configurer la sauvegarde sécurisée",
"setup_secure_backup": {
"explainer": "Sauvegardez vos clés avant de vous déconnecter pour éviter de les perdre."
@@ -1007,6 +1008,21 @@
"incoming_sas_dialog_waiting": "Attente de la confirmation du partenaire…",
"incoming_sas_user_dialog_text_1": "Vérifier cet utilisateur pour le marquer comme fiable. Faire confiance aux utilisateurs vous permet d’être tranquille lorsque vous utilisez des messages chiffrés de bout en bout.",
"incoming_sas_user_dialog_text_2": "Vérifier cet utilisateur marquera sa session comme fiable, et marquera aussi votre session comme fiable pour lui.",
+ "manual": {
+ "already_verified": "Cet appareil est déjà vérifié",
+ "already_verified_and_wrong_fingerprint": "L'empreinte numérique fournie ne correspond pas, mais l'appareil est déjà vérifié !",
+ "device_id": "Identifiant de l'appareil",
+ "failure_description": "Échec de la vérification de '%(deviceId)s' : %(error)s",
+ "failure_title": "Échec de la vérification",
+ "fingerprint": "Empreinte numérique (clé de la session)",
+ "no_crypto": "Impossible de vérifier l'appareil - le chiffrement n'est pas activé",
+ "no_device": "Impossible de vérifier l'appareil - l'appareil %(deviceId)s n'a pas été trouvé",
+ "no_userid": "Impossible de vérifier l'appareil - identifiant utilisateur introuvable",
+ "success_description": "L’appareil (%(deviceId)s) est maintenant signé de manière croisée",
+ "success_title": "Vérification réussie",
+ "text": "Fournissez l'identifiant et l'empreinte numétrique de l'un de vos appareils pour le vérifier. REMARQUE : cela permet à l'autre appareil d'envoyer et de recevoir des messages comme vous. SI QUELQU'UN VOUS A DIT DE COLLER QUELQUE CHOSE ICI, IL EST PROBABLE QUE VOUS SOYEZ VICTIME D'UNE ARNAQUE !",
+ "wrong_fingerprint": "Impossible de vérifier l'appareil %(deviceId)s - l'empreinte numérique %(fingerprint)s fournie ne correspond pas à celle de l'appareil %(fprint)s"
+ },
"no_key_or_device": "Il semblerait que vous ne disposiez pas de clé de récupération ou d’autres appareils pour réalisation la vérification. Cet appareil ne pourra pas accéder aux anciens messages chiffrés. Afin de vérifier votre identité sur cet appareil, vous devrez réinitialiser vos clés de vérifications.",
"no_support_qr_emoji": "L’appareil que vous essayez de vérifier ne prend pas en charge les QR codes ou la vérification d’émojis, qui sont les méthodes prises en charge par %(brand)s. Essayez avec un autre client.",
"other_party_cancelled": "L’autre personne a annulé la vérification.",
@@ -1733,9 +1749,9 @@
},
"total_no_votes": "Aucun vote exprimé",
"total_not_ended": "Les résultats seront visibles lorsque le sondage sera terminé",
- "type_closed": "Sondage terminé",
+ "type_closed": "Sondage fermé",
"type_heading": "Type de sondage",
- "type_open": "Ouvrir le sondage",
+ "type_open": "Sondage ouvert",
"unable_edit_description": "Désolé, vous ne pouvez pas modifier un sondage après que les votes aient été exprimés.",
"unable_edit_title": "Impossible de modifier le sondage"
},
@@ -2046,6 +2062,8 @@
"read_topic": "Cliquer pour lire le sujet",
"rejecting": "Rejet de l’invitation…",
"rejoin_button": "Revenir",
+ "room_content": "Contenu du salon",
+ "room_is_low_priority": "Ce salon est de priorité basse",
"search": {
"all_rooms_button": "Rechercher dans tous les salons",
"placeholder": "Rechercher des messages…",
@@ -2094,6 +2112,7 @@
"add_space_label": "Ajouter un espace",
"breadcrumbs_empty": "Aucun salon visité récemment",
"breadcrumbs_label": "Salons visités récemment",
+ "collapse_filters": "Réduire la liste des filtres",
"empty": {
"no_chats": "Pas encore de discussions",
"no_chats_description": "Commencez par envoyer un message à quelqu'un ou en créant un salon",
@@ -2101,6 +2120,7 @@
"no_favourites": "Vous n'avez pas encore de discussion favorite",
"no_favourites_description": "Vous pouvez ajouter une discussion à vos favoris dans les paramètres de discussion",
"no_invites": "Vous n'avez aucune invitation non lue",
+ "no_lowpriority": "Vous n'avez aucun salon avec une priorité basse",
"no_mentions": "Vous n'avez aucune mention non lue",
"no_people": "Vous n'avez encore de discussions",
"no_people_description": "Veuillez désélectionner des filtres pour voir vos discussions",
@@ -2110,13 +2130,14 @@
"show_activity": "Voir toutes les activités",
"show_chats": "Afficher toutes les discussions"
},
+ "expand_filters": "Développer la liste des filtres",
"failed_add_tag": "Échec de l’ajout de l’étiquette %(tagName)s au salon",
"failed_remove_tag": "Échec de la suppression de l’étiquette %(tagName)s du salon",
"failed_set_dm_tag": "Échec de l’ajout de l’étiquette de conversation privée",
"filters": {
"favourite": "Favoris",
"invites": "Invitations",
- "low_priority": "Faible priorité",
+ "low_priority": "Priorité basse",
"mentions": "Mentions",
"people": "Personnes",
"rooms": "Salons",
@@ -2684,13 +2705,11 @@
"inline_url_previews_room": "Activer l’aperçu des URL par défaut pour les participants de ce salon",
"inline_url_previews_room_account": "Activer l’aperçu des URL pour ce salon (n’affecte que vous)",
"insert_trailing_colon_mentions": "Insérer deux-points après les mentions de l'utilisateur au début d'un message",
+ "invite_controls": {
+ "default_label": "Autoriser les utilisateurs à vous inviter dans les salons"
+ },
"jump_to_bottom_on_send": "Sauter en bas du fil de discussion lorsque vous envoyez un message",
"key_backup": {
- "backup_in_progress": "Vous clés sont en cours de sauvegarde (la première sauvegarde peut prendre quelques minutes).",
- "backup_starting": "Début de la sauvegarde…",
- "backup_success": "Terminé !",
- "cannot_create_backup": "Impossible de créer la sauvegarde des clés",
- "create_title": "Créer une sauvegarde de clé",
"setup_secure_backup": {
"backup_setup_success_description": "Vos clés sont maintenant sauvegardées depuis cet appareil.",
"backup_setup_success_title": "Sauvegarde sécurisée réalisée avec succès",
@@ -2750,6 +2769,7 @@
"show_in_private": "Dans les salons privés",
"show_media": "Toujours afficher"
},
+ "not_supported": "Votre serveur ne propose pas cette fonctionnalité",
"notifications": {
"default_setting_description": "Ce réglage sera appliqué par défaut à tous vos salons.",
"default_setting_section": "Je veux être notifié pour (réglage par défaut)",
@@ -2816,6 +2836,7 @@
"composer_heading": "Compositeur",
"default_timezone": "Navigateur par défaut (%(timezone)s)",
"dialog_title": "Paramètres : Préférences",
+ "enable_content_protection": "Activer la protection du contenu",
"enable_hardware_acceleration": "Activer l’accélération matérielle",
"enable_tray_icon": "Afficher l’icône dans la barre d’état et minimiser la fenêtre lors de la fermeture",
"keyboard_heading": "Raccourcis clavier",
@@ -2849,7 +2870,6 @@
"ignore_users_empty": "Vous n’avez ignoré personne.",
"ignore_users_section": "Utilisateurs ignorés",
"key_backup_algorithm": "Algorithme :",
- "key_backup_connect": "Connecter cette session à la sauvegarde de clés",
"message_search_disable_warning": "Si l’option est désactivée, les messages des salons chiffrés n’apparaîtront pas dans les résultats de recherche.",
"message_search_disabled": "Mettre en cache les messages chiffrés localement et de manière sécurisée pour qu’ils apparaissent dans les résultats de recherche.",
"message_search_enabled": {
@@ -3087,6 +3107,8 @@
"jumptodate": "Aller à la date correspondante dans la discussion",
"jumptodate_invalid_input": "Nous n’avons pas pu comprendre la date saisie (%(inputDate)s). Veuillez essayer en utilisant le format AAAA-MM-JJ.",
"lenny": "Ajoute ( ͡° ͜ʖ ͡°) en préfixe du message",
+ "manual_device_verification_confirm_description": "Cela permettra à un autre appareil d'envoyer et de recevoir des messages comme vous. SI QUELQU'UN VOUS A DIT DE COLLER QUELQUE CHOSE ICI, IL EST PROBABLE QUE VOUS SOYEZ VICTIME D'UNE ARNAQUE ! Êtes-vous sûr de vouloir vérifier cet autre appareil ?",
+ "manual_device_verification_confirm_title": "Attention : vérification manuelle de l'appareil",
"me": "Affiche l’action",
"msg": "Envoie un message à l’utilisateur fourni",
"myavatar": "Modifier votre image de profil dans tous les salons",
@@ -3127,7 +3149,7 @@
"upgraderoom": "Met à niveau un salon vers une nouvelle version",
"upgraderoom_permission_error": "Vous n’avez pas les autorisations nécessaires pour utiliser cette commande.",
"usage": "Utilisation",
- "verify": "Vérifie un utilisateur, une session et une collection de clés publiques",
+ "verify": "Vérifiez manuellement l'un de vos appareils",
"view": "Affiche le salon avec cette adresse",
"whois": "Affiche des informations à propos de l’utilisateur"
},
diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json
index 8b32535f57..bee7604de3 100644
--- a/src/i18n/strings/gl.json
+++ b/src/i18n/strings/gl.json
@@ -197,8 +197,7 @@
"setup_key_backup_title": "Perderás o acceso as túas mensaxes cifradas",
"setup_secure_backup_description_1": "As mensaxes cifradas están seguras con cifrado de extremo-a-extremo. Só ti e o correpondente(s) tedes as chaves para ler as mensaxes.",
"setup_secure_backup_description_2": "Ao saír, estas chaves serán eliminadas deste dispositivo, o que significa que non poderás ler as mensaxes cifradas a menos que teñas as chaves noutro dos teus dispositivos, ou unha copia de apoio no servidor.",
- "skip_key_backup": "Non quero as miñas mensaxes cifradas",
- "use_key_backup": "Fai unha Copia de apoio das chaves"
+ "skip_key_backup": "Non quero as miñas mensaxes cifradas"
},
"misconfigured_body": "Pídelle a administración do teu %(brand)s que comprobe a configuración para entradas duplicadas ou incorrectas.",
"misconfigured_title": "O teu %(brand)s está mal configurado",
@@ -710,7 +709,6 @@
"warning": "Se non eliminaches o método de recuperación, un atacante podería estar a intentar acceder á túa conta. Cambia inmediatamente o contrasinal da conta e establece un novo método de recuperación nos Axustes."
},
"reset_all_button": "Perdidos ou esquecidos tódolos métodos de recuperación? Restabléceos",
- "set_up_toast_description": "Protéxete de perder o acceso a mensaxes e datos cifrados",
"set_up_toast_title": "Configurar Copia de apoio Segura",
"setup_secure_backup": {
"explainer": "Fai unha copia de apoio das chaves antes de saír para evitar perdelas."
@@ -1962,10 +1960,6 @@
"insert_trailing_colon_mentions": "Inserir dous puntos tras mencionar a outra usuaria no inicio da mensaxe",
"jump_to_bottom_on_send": "Ir ao final da cronoloxía cando envías unha mensaxe",
"key_backup": {
- "backup_in_progress": "As chaves estanse a copiar (a primeira copia podería tardar un anaco).",
- "backup_success": "Feito!",
- "cannot_create_backup": "Non se creou a copia da chave",
- "create_title": "Crear copia da chave",
"setup_secure_backup": {
"cancel_warning": "Se cancelas agora, poderías perder mensaxes e datos cifrados se perdes o acceso ás sesións iniciadas.",
"confirm_security_phrase": "Confirma a Frase de Seguridade",
@@ -2062,7 +2056,6 @@
"ignore_users_empty": "Non tes usuarias ignoradas.",
"ignore_users_section": "Usuarias ignoradas",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Conecta esta sesión a Copia de Apoio de chaves",
"message_search_disable_warning": "Se está desactivado, as mensaxes das salas cifradas non aparecerán nos resultados das buscas.",
"message_search_disabled": "Gardar de xeito seguro mensaxes cifradas na caché local para que aparezan nos resultados de buscas.",
"message_search_enabled": {
diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json
index c4783212ef..4a743de9c7 100644
--- a/src/i18n/strings/he.json
+++ b/src/i18n/strings/he.json
@@ -179,8 +179,7 @@
"megolm_export": "ייצא ידנית מפתחות",
"setup_key_backup_title": "תאבד את הגישה להודעות המוצפנות שלך",
"setup_secure_backup_description_1": "הודעות מוצפנות מאובטחות באמצעות הצפנה מקצה לקצה. רק לך ולמקבל / ים יש / ה מקשים לקריאת הודעות אלה.",
- "skip_key_backup": "אני לא רוצה את ההודעות המוצפנות שלי",
- "use_key_backup": "התחל להשתמש בגיבוי מקשים"
+ "skip_key_backup": "אני לא רוצה את ההודעות המוצפנות שלי"
},
"misconfigured_body": "בקשו מהאדמין של %(brand)s לבדוק קונפיגורציה לרשומות כפולות שגויות.",
"misconfigured_title": "ה %(brand)s שלך מוגדר באופן שגוי",
@@ -602,7 +601,6 @@
"title": "שיטת השחזור הוסרה",
"warning": "אם לא הסרת את שיטת השחזור, ייתכן שתוקף מנסה לגשת לחשבונך. שנה את סיסמת החשבון שלך והגדר מיד שיטת שחזור חדשה בהגדרות."
},
- "set_up_toast_description": "שמור מפני איבוד גישה אל הודעות ומידע מוצפן",
"set_up_toast_title": "צור גיבוי מאובטח",
"setup_secure_backup": {
"explainer": "גבה את המפתחות שלך לפני היציאה כדי להימנע מלאבד אותם."
@@ -1619,10 +1617,6 @@
"insert_trailing_colon_mentions": "הוסף נקודתיים לאחר אזכור המשתמש בתחילת ההודעה",
"jump_to_bottom_on_send": "קפוץ לתחתית השיחה בעת שליחת הודעה",
"key_backup": {
- "backup_in_progress": "גיבוי המפתחות שלך (הגיבוי הראשון יכול לקחת מספר דקות).",
- "backup_success": "הצלחה!",
- "cannot_create_backup": "לא ניתן ליצור גיבוי מפתח",
- "create_title": "צור מפתח גיבוי",
"setup_secure_backup": {
"cancel_warning": "אם תבטל עכשיו, אתה עלול לאבד הודעות ונתונים מוצפנים אם תאבד את הגישה לכניסות שלך.",
"confirm_security_phrase": "אשר את ביטוי האבטחה שלך",
@@ -1712,7 +1706,6 @@
"ignore_users_empty": "אין לך משתמשים שהתעלמו מהם.",
"ignore_users_section": "משתמשים שהתעלמתם מהם",
"key_backup_algorithm": "אלגוריתם:",
- "key_backup_connect": "חבר את התחברות הזו לגיבוי מפתח",
"message_search_disable_warning": "אם מושבת, הודעות מחדרים מוצפנים לא יופיעו בתוצאות החיפוש.",
"message_search_disabled": "שמור באופן מאובטח הודעות מוצפנות באופן מקומי כדי שיופיעו בתוצאות החיפוש.",
"message_search_enabled": {
diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json
index c5d7cd6704..a6174d85fa 100644
--- a/src/i18n/strings/hu.json
+++ b/src/i18n/strings/hu.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Elveszíti a hozzáférést a titkosított üzeneteihez",
"setup_secure_backup_description_1": "A titkosított üzenetek végpontok közti titkosítással védettek. Csak Önnek és a címzetteknek lehet meg a kulcsuk az üzenet visszafejtéséhez.",
"setup_secure_backup_description_2": "A kijelentkezéssel a kulcsok az eszközről törlődnek, ami azt jelenti, hogy ha nincsenek meg máshol a kulcsok, vagy nincsenek mentve a kiszolgálón, akkor a titkosított üzenetek olvashatatlanná válnak.",
- "skip_key_backup": "Nincs szükségem a titkosított üzeneteimre",
- "use_key_backup": "Kulcsmentés használatának megkezdése"
+ "skip_key_backup": "Nincs szükségem a titkosított üzeneteimre"
},
"misconfigured_body": "Kérje meg a(z) %(brand)s adminisztrátorát, hogy ellenőrizze a beállításait, hibás vagy duplikált bejegyzéseket keresve.",
"misconfigured_title": "Az %(brand)s hibásan van beállítva",
@@ -784,6 +783,7 @@
"cross_signing_status": "Eszközök közti hitelesítés állapota:",
"cross_signing_untrusted": "A fiókjához tartozik egy eszközök közti hitelesítési személyazonosság, de ez a munkamenet még nem jelölte megbízhatónak.",
"crypto_not_available": "A kriptográfiai modul nem érhető el",
+ "device_id": "Eszközazonosító",
"key_backup_active_version": "Aktív biztonsági mentés verziója:",
"key_backup_active_version_none": "Nincs",
"key_backup_inactive_warning": "A kulcsokról nem készül biztonsági mentés ebből a munkamenetből.",
@@ -796,6 +796,8 @@
"secret_storage_ready": "kész",
"secret_storage_status": "Titkos tároló:",
"self_signing_private_key_cached_status": "Önaláíró titkos kulcs:",
+ "session": "Munkamenet",
+ "session_fingerprint": "Ujjlenyomat (munkamenetkulcs)",
"title": "Végpontok közti titkosítás",
"user_signing_private_key_cached_status": "Felhasználó aláírási titkos kulcsa:"
},
@@ -821,6 +823,7 @@
"low_bandwidth_mode": "Alacsony sávszélességű mód",
"low_bandwidth_mode_description": "Kompatibilis Matrix-kiszolgálóra van szükség.",
"main_timeline": "Fő idővonal",
+ "manual_device_verification": "Kézi eszközellenőrzés",
"no_receipt_found": "Nincs visszajelzés",
"notification_state": "Értesítés állapota: %(notificationState)s",
"notifications_debug": "Értesítések hibakeresése",
@@ -908,6 +911,7 @@
"empty_room_was_name": "Üres szoba (%(oldName)s volt)",
"encryption": {
"access_secret_storage_dialog": {
+ "alternatives": "Ha van biztonsági kulcsa vagy biztonsági jelmondata, akkor ez is fog működni.",
"key_validation_text": {
"wrong_security_key": "A megadott helyreállítási kulcs helytelen."
},
@@ -960,9 +964,7 @@
},
"reset_all_button": "Elfelejtette vagy elveszett minden helyreállítási lehetőség? Minden alaphelyzetbe állítása",
"set_up_recovery": "Helyreállítás beállítása",
- "set_up_recovery_later": "Most nem",
"set_up_recovery_toast_description": "Létrehozhat egy helyreállítási kulcsot, amellyel helyreállíthatja a titkosított üzenetelőzményeit, ha elveszíti a hozzáférést az eszközeihez.",
- "set_up_toast_description": "Biztosíték a titkosított üzenetekhez és adatokhoz való hozzáférés elvesztése ellen",
"set_up_toast_title": "Biztonsági mentés beállítása",
"setup_secure_backup": {
"explainer": "Mentse a kulcsait a kiszolgálóra kijelentkezés előtt, hogy ne veszítse el azokat."
@@ -1003,6 +1005,21 @@
"incoming_sas_dialog_waiting": "Várakozás a partner megerősítésére…",
"incoming_sas_user_dialog_text_1": "Ellenőrizze ezt a felhasználót, hogy megbízhatónak jelölje. A felhasználók megbízhatóságának megerősítése további biztonságot nyújt a végpontok közti titkosítással rendelkező üzenetek használatakor.",
"incoming_sas_user_dialog_text_2": "A felhasználó ellenőrzése által az ő munkamenete megbízhatónak lesz jelölve, és a te munkameneted is megbízhatónak lesz jelölve nála.",
+ "manual": {
+ "already_verified": "Ez az eszköz már ellenőrzött",
+ "already_verified_and_wrong_fingerprint": "A megadott ujjlenyomat nem egyezik, de az eszköz már ellenőrizve van!",
+ "device_id": "Eszközazonosító",
+ "failure_description": "Nem sikerült ellenőrizni ezt: %(deviceId)s: %(error)s",
+ "failure_title": "Az ellenőrzés sikertelen",
+ "fingerprint": "Ujjlenyomat (munkamenetkulcs)",
+ "no_crypto": "Nem lehet ellenőrizni az eszközt – a titkosítás nincs engedélyezve",
+ "no_device": "Nem sikerült ellenőrizni az eszközt – a(z) „%(deviceId)s” eszköz nem található",
+ "no_userid": "Nem sikerült ellenőrizni az eszközt – nem található a felhasználói azonosító",
+ "success_description": "Az eszköz (%(deviceId)s) mostantól keresztaláírással rendelkezik.",
+ "success_title": "Az ellenőrzés sikeres",
+ "text": "Adja meg valamelyik saját eszköze azonosítóját és ujjlenyomatát annak ellenőrzéséhez. MEGJEGYZÉS: ez lehetővé teszi a másik eszköz számára, hogy az Ön nevében küldjön és fogadjon üzeneteket. HA VALAKI AZT MONDTA, HOGY ILLESSZEN BE IDE VALAMIT, VALÓSZÍNŰLEG ÁTVERIK!",
+ "wrong_fingerprint": "Nem sikerült ellenőrizni a(z) „%(deviceId)s” eszközt – a mellékelt „%(fingerprint)s” ujjlenyomat nem egyezik az eszköz ujjlenyomatával: „%(fprint)s”"
+ },
"no_key_or_device": "Úgy tűnik, hogy nem rendelkezik helyreállítási kulccsal, vagy másik eszközzel, amellyel ellenőrizhetné. Ezzel az eszközzel nem fér majd hozzá a régi titkosított üzenetekhez. Ahhoz, hogy a személyazonosságát ezen az eszközön ellenőrizni lehessen, az ellenőrzési kulcsokat alaphelyzetbe kell állítania.",
"no_support_qr_emoji": "Az ellenőrizni kívánt eszköz nem támogatja sem a QR-kód leolvasását, sem az emodzsis ellenőrzést, amelyeket az %(brand)s támogat. Próbálja meg egy másik klienssel.",
"other_party_cancelled": "A másik fél megszakította az ellenőrzést.",
@@ -1940,6 +1957,7 @@
},
"face_pile_tooltip_shortcut": "Beleértve: %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Önt is beleértve, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Nem lehet meghatározni, hogy melyik felhasználót kell figyelmen kívül hagyni, mivel az esemény megváltozott.",
"failed_reject_invite": "A meghívót nem sikerült elutasítani",
"forget_room": "Szoba elfelejtése",
"forget_space": "Ennek a térnek az elfelejtése",
@@ -2031,6 +2049,8 @@
"read_topic": "Kattintson a téma elolvasásához",
"rejecting": "Meghívó elutasítása…",
"rejoin_button": "Újra-csatlakozás",
+ "room_content": "Szoba tartalma",
+ "room_is_low_priority": "Ez egy alacsony prioritású szoba",
"search": {
"all_rooms_button": "Keresés az összes szobában",
"placeholder": "Üzenetek keresése...",
@@ -2077,6 +2097,7 @@
"add_space_label": "Tér hozzáadása",
"breadcrumbs_empty": "Nincsenek nemrégiben meglátogatott szobák",
"breadcrumbs_label": "Nemrég meglátogatott szobák",
+ "collapse_filters": "Szűrőlista összecsukása",
"empty": {
"no_chats": "Még nincsenek csevegések",
"no_chats_description": "Kezdje azzal, hogy üzenetet küld valakinek, vagy létrehoz egy szobát",
@@ -2084,6 +2105,7 @@
"no_favourites": "Még nincs kedvenc csevegése",
"no_favourites_description": "A csevegési beállításokban adhat hozzá csevegést a kedvencekhez",
"no_invites": "Nincs olvasatlan meghívója",
+ "no_lowpriority": "Nincs alacsony prioritású szobája",
"no_mentions": "Nincs olvasatlan említése",
"no_people": "Még nincs közvetlen csevegése senkivel",
"no_people_description": "Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez",
@@ -2093,12 +2115,14 @@
"show_activity": "Összes tevékenység megtekintése",
"show_chats": "Összes csevegés megjelenítése"
},
+ "expand_filters": "Szűrőlista kibontása",
"failed_add_tag": "Nem sikerült hozzáadni a szobához ezt: %(tagName)s",
"failed_remove_tag": "Nem sikerült a szobáról eltávolítani ezt: %(tagName)s",
"failed_set_dm_tag": "Nem sikerült a közvetlen beszélgetés címkét beállítani",
"filters": {
"favourite": "Kedvencek",
"invites": "Meghívók",
+ "low_priority": "Alacsony prioritás",
"mentions": "Említések",
"people": "Emberek",
"rooms": "Szobák",
@@ -2662,13 +2686,11 @@
"inline_url_previews_room": "Webcím-előnézetek alapértelmezett engedélyezése a szobatagok számára",
"inline_url_previews_room_account": "Webcím-előnézetek engedélyezése ebben a szobában (csak Önt érinti)",
"insert_trailing_colon_mentions": "Záró kettőspont beszúrása egy felhasználó üzenet elején való megemlítésekor",
+ "invite_controls": {
+ "default_label": "Felhasználók meghívhatják szobákba"
+ },
"jump_to_bottom_on_send": "Üzenetküldés után az idővonal aljára ugrás",
"key_backup": {
- "backup_in_progress": "A kulcsaid mentése folyamatban van (az első mentés több percig is eltarthat).",
- "backup_starting": "Mentés indul…",
- "backup_success": "Sikeres!",
- "cannot_create_backup": "Kulcsmentés sikertelen",
- "create_title": "Kulcsmentés készítése",
"setup_secure_backup": {
"backup_setup_success_description": "A kulcsai nem kerülnek elmentésre erről az eszközről.",
"backup_setup_success_title": "Biztonsági mentés sikeres",
@@ -2728,6 +2750,7 @@
"show_in_private": "Privát szobákban",
"show_media": "Megjelenítés mindig"
},
+ "not_supported": "A kiszolgálója nem valósítja meg ezt a funkciót.",
"notifications": {
"default_setting_description": "Ez a beállítás alapértelmezés szerint az összes szobájára érvényes lesz.",
"default_setting_section": "Szeretnék értesítést kapni az alábbiakról (Alapértelmezett beállítás)",
@@ -2785,6 +2808,7 @@
"voip": "Hang- és videóhívások"
},
"preferences": {
+ "Electron.enableContentProtection": "Ablak tartalmának más alkalmazások általi rögzítésének megakadályozása",
"Electron.enableHardwareAcceleration": "Hardveres gyorsítás engedélyezése (a(z) %(appName)s újraindítása szükséges az érvénybe lépéshez)",
"always_show_menu_bar": "Ablak menüsávjának megjelenítése mindig",
"autocomplete_delay": "Automatikus kiegészítés késleltetése (ms)",
@@ -2793,6 +2817,7 @@
"composer_heading": "Szerkesztő",
"default_timezone": "Böngésző alapértelmezése (%(timezone)s)",
"dialog_title": "Beállítások: Beállítások",
+ "enable_content_protection": "Tartalomvédelem engedélyezése",
"enable_hardware_acceleration": "Hardveres gyorsítás engedélyezése",
"enable_tray_icon": "Tálcaikon megjelenítése és az ablak minimalizálása bezáráskor",
"keyboard_heading": "Gyorsbillentyűk",
@@ -2826,7 +2851,6 @@
"ignore_users_empty": "Nincsenek mellőzött felhasználók.",
"ignore_users_section": "Mellőzött felhasználók",
"key_backup_algorithm": "Algoritmus:",
- "key_backup_connect": "Munkamenet csatlakoztatása a kulcsmentéshez",
"message_search_disable_warning": "Ha nincs engedélyezve akkor a titkosított szobák üzenetei nem jelennek meg a keresések között.",
"message_search_disabled": "A titkosított üzenetek biztonságos helyi gyorsítótárazása, hogy megjelenhessenek a keresési találatok között.",
"message_search_enabled": {
@@ -2953,6 +2977,7 @@
"show_chat_effects": "Csevegési effektek (például a konfetti animáció) megjelenítése",
"show_displayname_changes": "Megjelenítendő nevek változásának megjelenítése",
"show_join_leave": "Be- és kilépési üzenetek megjelenítése (a meghívók/kirúgások/kitiltások üzeneteit nem érinti)",
+ "show_message_previews": "Üzenetelőnézetek megjelenítése",
"show_nsfw_content": "Felnőtt tartalmak megjelenítése",
"show_read_receipts": "Mások által küldött olvasási visszajelzések megjelenítése",
"show_redaction_placeholder": "Helykitöltő megjelenítése a törölt szövegek helyett",
@@ -3059,6 +3084,8 @@
"jumptodate": "Az idővonalon megadott dátumra ugrás",
"jumptodate_invalid_input": "A megadott dátum (%(inputDate)s) nem értelmezhető. Próbálja meg az ÉÉÉÉ-HH-NN formátum használatát.",
"lenny": "Az egyszerű szöveges üzenet elé teszi ezt: ( ͡° ͜ʖ ͡°)",
+ "manual_device_verification_confirm_description": "Ez lehetővé teszi egy másik eszköz számára, hogy az Ön nevében üzeneteket küldjön és fogadjon. HA VALAKI AZT MONDTA, HOGY ILLESSZEN BE IDE VALAMIT, VALÓSZÍNŰLEG ÁTVERIK! Biztosan ellenőrizni szeretné ezt a másik eszközt?",
+ "manual_device_verification_confirm_title": "Vigyázat: kézi eszközellenőrzés",
"me": "Megjeleníti a tevékenységet",
"msg": "Üzenet küldése a megadott felhasználónak",
"myavatar": "Az összes szobában módosítja a profilképét",
@@ -3099,7 +3126,7 @@
"upgraderoom": "Új verzióra fejleszti a szobát",
"upgraderoom_permission_error": "A parancs használatához nincs meg a megfelelő jogosultsága.",
"usage": "Használat",
- "verify": "Felhasználó, munkamenet és nyilvános kulcs hármas ellenőrzése",
+ "verify": "Az egyik saját eszköz kézi ellenőrzése",
"view": "Megadott címmel rendelkező szobák megjelenítése",
"whois": "Információt jelenít meg a felhasználóról"
},
diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json
index 331a1a597e..aec713be8c 100644
--- a/src/i18n/strings/id.json
+++ b/src/i18n/strings/id.json
@@ -238,8 +238,7 @@
"setup_key_backup_title": "Anda akan kehilangan akses ke pesan terenkripsi Anda",
"setup_secure_backup_description_1": "Pesan terenkripsi diamankan dengan enkripsi ujung ke ujung. Hanya Anda dan penerima punya kuncinya untuk membaca pesan ini.",
"setup_secure_backup_description_2": "Ketika Anda keluar, kunci-kunci ini akan dihapus dari perangkat ini, yang berarti Anda tidak dapat membaca pesan-pesan terenkripsi kecuali jika Anda mempunyai kuncinya di perangkat Anda yang lain, atau telah mencadangkannya ke server.",
- "skip_key_backup": "Saya tidak ingin pesan-pesan terenkripsi saya",
- "use_key_backup": "Mulai menggunakan Cadangan Kunci"
+ "skip_key_backup": "Saya tidak ingin pesan-pesan terenkripsi saya"
},
"misconfigured_body": "Tanyakan admin %(brand)s Anda untuk memeriksa konfigurasi Anda untuk entri yang tidak benar atau entri duplikat.",
"misconfigured_title": "%(brand)s Anda telah diatur dengan salah",
@@ -965,9 +964,7 @@
},
"reset_all_button": "Lupa atau kehilangan semua metode pemulihan? Atur ulang semuanya",
"set_up_recovery": "Siapkan pemulihan",
- "set_up_recovery_later": "Tidak sekarang",
"set_up_recovery_toast_description": "Buat kunci pemulihan yang dapat digunakan untuk memulihkan riwayat pesan terenkripsi jika Anda kehilangan akses ke perangkat Anda.",
- "set_up_toast_description": "Lindungi dari kehilangan akses ke pesan & data terenkripsi",
"set_up_toast_title": "Siapkan Cadangan Aman",
"setup_secure_backup": {
"explainer": "Cadangkan kunci Anda sebelum keluar untuk menghindari kehilangannya."
@@ -2059,6 +2056,7 @@
"read_topic": "Klik untuk membaca topik",
"rejecting": "Menolak undangan…",
"rejoin_button": "Bergabung Ulang",
+ "room_content": "Isi ruangan",
"room_is_low_priority": "Ini adalah ruangan dengan prioritas rendah",
"search": {
"all_rooms_button": "Cari semua ruangan",
@@ -2705,11 +2703,6 @@
},
"jump_to_bottom_on_send": "Pergi ke bawah lini masa ketika Anda mengirim pesan",
"key_backup": {
- "backup_in_progress": "Kunci Anda sedang dicadangkan (cadangan pertama mungkin membutuhkan beberapa menit).",
- "backup_starting": "Memulai pencadangan…",
- "backup_success": "Berhasil!",
- "cannot_create_backup": "Tidak dapat membuat cadangan kunci",
- "create_title": "Buat cadangan kunci",
"setup_secure_backup": {
"backup_setup_success_description": "Kunci Anda sekarang dicadangkan dari perangkat ini.",
"backup_setup_success_title": "Pencadangan Aman berhasil",
@@ -2836,6 +2829,7 @@
"composer_heading": "Komposer",
"default_timezone": "Bawaan peramban (%(timezone)s)",
"dialog_title": "Pengaturan: Preferensi",
+ "enable_content_protection": "Aktifkan perlindungan konten",
"enable_hardware_acceleration": "Aktifkan akselerasi perangkat keras",
"enable_tray_icon": "Tampilkan ikon baki dan minimalkan window ke ikonnya jika ditutup",
"keyboard_heading": "Pintasan keyboard",
@@ -2869,7 +2863,6 @@
"ignore_users_empty": "Anda tidak memiliki pengguna yang diabaikan.",
"ignore_users_section": "Pengguna yang diabaikan",
"key_backup_algorithm": "Algoritma:",
- "key_backup_connect": "Hubungkan sesi ini ke Pencadangan Kunci",
"message_search_disable_warning": "Jika dinonaktifkan, pesan dari ruangan terenkripsi tidak akan muncul di hasil pencarian.",
"message_search_disabled": "Simpan pesan terenkripsi secara lokal dengan aman agar muncul di hasil pencarian.",
"message_search_enabled": {
diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json
index 69792b62d1..bb8a2a7db5 100644
--- a/src/i18n/strings/is.json
+++ b/src/i18n/strings/is.json
@@ -197,8 +197,7 @@
"megolm_export": "Flytja út dulritunarlykla handvirkt",
"setup_key_backup_title": "Þú munt tapa dulrituðu skilaboðunum þínum",
"setup_secure_backup_description_1": "Öryggi dulritaðra skilaboða er tryggt með enda-í-enda dulritun. Einungis þú og viðtakendurnir hafa dulritunarlyklana til að lesa slík skilaboð.",
- "skip_key_backup": "Ég vil ekki dulrituðu skilaboðin mín",
- "use_key_backup": "Byrja að nota öryggisafrit dulritunarlykla"
+ "skip_key_backup": "Ég vil ekki dulrituðu skilaboðin mín"
},
"misconfigured_body": "Biddu kerfisstjórann á %(brand)s að athuga hvort uppsetningin þín innihaldi rangar eða tvíteknar færslur.",
"misconfigured_title": "%(brand)s-uppsetningin þín er rangt stillt",
@@ -703,7 +702,6 @@
"title": "Endurheimtuaðferð fjarlægð"
},
"reset_all_button": "Gleymdirðu eða týndir öllum aðferðum til endurheimtu? Endurstilla allt",
- "set_up_toast_description": "Tryggðu þig gegn því að missa aðgang að dulrituðum skilaboðum og gögnum",
"set_up_toast_title": "Setja upp varið öryggisafrit",
"setup_secure_backup": {
"explainer": "Taktu öryggisafrit af dulritunarlyklunum áður en þú skráir þig út svo þeir tapist ekki."
@@ -1858,10 +1856,6 @@
"insert_trailing_colon_mentions": "Setja tvípunkt á eftir þar sem minnst er á notanda í upphafi skilaboða",
"jump_to_bottom_on_send": "Hoppa neðst á tímalínuna þegar þú sendir skilaboð",
"key_backup": {
- "backup_in_progress": "Verið er að öryggisafrita dulritunarlyklana þína (öryggisafritun getur tekið dálítinn tíma í fyrsta skiptið).",
- "backup_success": "Tókst!",
- "cannot_create_backup": "Tókst ekki að gera öryggisafrit af dulritunarlykli",
- "create_title": "Gera öryggisafrit af dulritunarlykli",
"setup_secure_backup": {
"cancel_warning": "Ef þú hættir við núna, geturðu tapað dulrituðum skilaboðum og gögnum ef þú missir aðgang að innskráningum þínum.",
"confirm_security_phrase": "Staðfestu öryggisfrasann þinn",
@@ -1958,7 +1952,6 @@
"ignore_users_empty": "Þú ert ekki með neina hunsaða notendur.",
"ignore_users_section": "Hunsaðir notendur",
"key_backup_algorithm": "Reiknirit:",
- "key_backup_connect": "Tengja þessa setu við öryggisafrit af lykli",
"message_search_disable_warning": "Ef þetta er óvirkt, munu skilaboð frá dulrituðum spjallrásum ekki birtast í leitarniðurstöðum.",
"message_search_disabled": "Setja dulrituð skilaboð leynilega í skyndiminni á tækinu svo þau birtist í leitarniðurstöðum.",
"message_search_enabled": {
diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json
index 7213b88974..ed3a8eff45 100644
--- a/src/i18n/strings/it.json
+++ b/src/i18n/strings/it.json
@@ -221,8 +221,7 @@
"setup_key_backup_title": "Perderai l'accesso ai tuoi messaggi cifrati",
"setup_secure_backup_description_1": "I messaggi cifrati sono resi sicuri con una crittografia end-to-end. Solo tu e il/i destinatario/i avete le chiavi per leggere questi messaggi.",
"setup_secure_backup_description_2": "Quando ti disconnetti queste chiavi verranno eliminate dal dispositivo, quindi non potrai leggere i messaggi cifrati a meno che tu non abbia le chiavi su altri dispositivi, o salvate in backup sul server.",
- "skip_key_backup": "Non voglio i miei messaggi cifrati",
- "use_key_backup": "Inizia ad usare il backup chiavi"
+ "skip_key_backup": "Non voglio i miei messaggi cifrati"
},
"misconfigured_body": "Chiedi al tuo amministratore di %(brand)s di controllare la tua configurazione per voci non valide o doppie.",
"misconfigured_title": "Il tuo %(brand)s è configurato male",
@@ -838,7 +837,6 @@
"warning": "Se non hai rimosso il metodo di ripristino, è possibile che un aggressore stia cercando di accedere al tuo account. Cambia la password del tuo account e imposta immediatamente un nuovo metodo di recupero nelle impostazioni."
},
"reset_all_button": "Hai dimenticato o perso tutti i metodi di recupero? Reimposta tutto",
- "set_up_toast_description": "Proteggiti dalla perdita dei messaggi e dati crittografati",
"set_up_toast_title": "Imposta il Backup Sicuro",
"setup_secure_backup": {
"explainer": "Fai una copia delle tue chiavi prima di disconnetterti per evitare di perderle."
@@ -2308,11 +2306,6 @@
"insert_trailing_colon_mentions": "Inserisci dei due punti dopo le citazioni degli utenti all'inizio di un messaggio",
"jump_to_bottom_on_send": "Salta in fondo alla linea temporale quando invii un messaggio",
"key_backup": {
- "backup_in_progress": "Il backup delle chiavi è in corso (il primo backup potrebbe richiedere qualche minuto).",
- "backup_starting": "Avvio del backup…",
- "backup_success": "Completato!",
- "cannot_create_backup": "Impossibile creare backup della chiave",
- "create_title": "Crea backup chiavi",
"setup_secure_backup": {
"backup_setup_success_description": "Viene ora fatto il backup delle tue chiavi da questo dispositivo.",
"backup_setup_success_title": "Backup Sicuro completato",
@@ -2447,7 +2440,6 @@
"ignore_users_empty": "Non hai utenti ignorati.",
"ignore_users_section": "Utenti ignorati",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Connetti questa sessione al backup chiavi",
"message_search_disable_warning": "Se disattivato, i messaggi delle stanze cifrate non appariranno nei risultati di ricerca.",
"message_search_disabled": "Tieni in cache localmente i messaggi cifrati in modo sicuro affinché appaiano nei risultati di ricerca.",
"message_search_enabled": {
diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json
index c19ebfd082..9b46af9fe2 100644
--- a/src/i18n/strings/ja.json
+++ b/src/i18n/strings/ja.json
@@ -208,8 +208,7 @@
"setup_key_backup_title": "暗号化されたメッセージにアクセスできなくなります",
"setup_secure_backup_description_1": "暗号化されたメッセージは、エンドツーエンドの暗号化によって保護されています。これらの暗号化されたメッセージを読むための鍵を持っているのは、あなたと受信者だけです。",
"setup_secure_backup_description_2": "サインアウトすると、これらの鍵はこの端末から削除されます。使用できる鍵が他の端末にあったり、サーバーにバックアップされたりしているのでない限り、暗号化されたメッセージは読むことができなくなります。",
- "skip_key_backup": "暗号化されたメッセージは不要です",
- "use_key_backup": "鍵のバックアップを使用開始"
+ "skip_key_backup": "暗号化されたメッセージは不要です"
},
"misconfigured_body": "設定が間違っているか重複しているか確認するよう、%(brand)sの管理者に問い合わせてください。",
"misconfigured_title": "あなたの%(brand)sは正しく設定されていません",
@@ -794,7 +793,6 @@
"warning": "復元方法を削除しなかった場合、攻撃者があなたのアカウントにアクセスしようとしている可能性があります。設定画面でアカウントのパスワードを至急変更し、新しい復元方法を設定してください。"
},
"reset_all_button": "復元方法を全て失ってしまいましたか?リセットできます",
- "set_up_toast_description": "暗号化されたメッセージとデータへのアクセスが失われるのを防ぎましょう",
"set_up_toast_title": "セキュアバックアップを設定",
"setup_secure_backup": {
"explainer": "鍵を失くさないよう、サインアウトする前にバックアップしてください。"
@@ -2103,11 +2101,6 @@
"insert_trailing_colon_mentions": "ユーザーをメンションする際にコロンを挿入",
"jump_to_bottom_on_send": "メッセージを送信する際にタイムラインの最下部に移動",
"key_backup": {
- "backup_in_progress": "鍵をバックアップしています(最初のバックアップは数分かかる可能性があります)。",
- "backup_starting": "バックアップを開始しています…",
- "backup_success": "成功しました!",
- "cannot_create_backup": "鍵のバックアップを作成できません",
- "create_title": "鍵のバックアップを作成",
"setup_secure_backup": {
"backup_setup_success_description": "鍵はこの端末からバックアップされています。",
"backup_setup_success_title": "セキュアバックアップに成功しました",
@@ -2211,7 +2204,6 @@
"ignore_users_empty": "無視しているユーザーはいません。",
"ignore_users_section": "無視しているユーザー",
"key_backup_algorithm": "アルゴリズム:",
- "key_backup_connect": "このセッションを鍵のバックアップに接続",
"message_search_disable_warning": "無効にすると、暗号化されたルームのメッセージは検索結果に表示されません。",
"message_search_disabled": "検索結果の表示用に、暗号化されたメッセージをローカルに安全にキャッシュしています。",
"message_search_enabled": {
diff --git a/src/i18n/strings/ka.json b/src/i18n/strings/ka.json
index 440c373cac..bdda82509e 100644
--- a/src/i18n/strings/ka.json
+++ b/src/i18n/strings/ka.json
@@ -202,8 +202,7 @@
"setup_key_backup_title": "თქვენ დაკარგავთ წვდომას თქვენს დაშიფრულ შეტყობინებებზე",
"setup_secure_backup_description_1": "დაშიფრული შეტყობინებები დაცულია ბოლოდან ბოლომდე დაშიფვრით. მხოლოდ თქვენ და მიმღებ(ებ)ს გაქვთ ამ შეტყობინებების წაკითხვის გასაღები.",
"setup_secure_backup_description_2": "როდესაც გამოხვალთ, ეს გასაღებები წაიშლება ამ მოწყობილობიდან, რაც ნიშნავს, რომ თქვენ ვერ შეძლებთ დაშიფრული შეტყობინებების წაკითხვას, თუ არ გაქვთ მათი გასაღებები თქვენს სხვა მოწყობილობებზე, ან არ შექმნით მათ სარეზერვო ასლს სერვერზე.",
- "skip_key_backup": "არ მინდა ჩემი დაშიფრული შეტყობინებები",
- "use_key_backup": "დაიწყეთ Key Backup-ის გამოყენება"
+ "skip_key_backup": "არ მინდა ჩემი დაშიფრული შეტყობინებები"
},
"misconfigured_body": "ჰკითხეთ თქვენს%(brand)s ადმინი შესამოწმებლად თქვენი კონფიგურაცია არასწორი ან დუბლიკატი ჩანაწერებისთვის.",
"misconfigured_title": "შენი%(brand)s არასწორად არის კონფიგურირებული",
@@ -702,7 +701,6 @@
"warning": "თუ თქვენ არ წაშალეთ აღდგენის მეთოდი, შესაძლოა თავდამსხმელი ცდილობდეს თქვენს ანგარიშზე წვდომას. შეცვალეთ თქვენი ანგარიშის პაროლი და დააყენეთ აღდგენის ახალი მეთოდი დაუყოვნებლივ პარამეტრებში."
},
"reset_all_button": "დაგავიწყდათ თუ დაკარგეთ აღდგენის ყველა მეთოდი? გადატვირთეთ ყველა",
- "set_up_toast_description": "დაიცავით დაშიფრულ შეტყობინებებსა და მონაცემებზე წვდომის დაკარგვისგან",
"set_up_toast_title": "დააყენეთ უსაფრთხო სარეზერვო ასლი",
"setup_secure_backup": {
"explainer": "შექმენით თქვენი გასაღებების სარეზერვო ასლები გასვლამდე, რათა არ დაკარგოთ ისინი."
@@ -1745,11 +1743,6 @@
"insert_trailing_colon_mentions": "ჩადეთ ბოლო ორწერტილი მას შემდეგ, რაც მომხმარებელი აღნიშნავს შეტყობინების დასაწყისში",
"jump_to_bottom_on_send": "შეტყობინების გაგზავნისას გადადით ქრონოლოგიის ბოლოში",
"key_backup": {
- "backup_in_progress": "მიმდინარეობს თქვენი გასაღებების სარეზერვო ასლის შექმნა (პირველ სარეზერვო ასლს შეიძლება რამდენიმე წუთი დასჭირდეს).",
- "backup_starting": "იწყება სარეზერვო ასლის შექმნა…",
- "backup_success": "წარმატებები!",
- "cannot_create_backup": "გასაღების სარეზერვო ასლის შექმნა შეუძლებელია",
- "create_title": "შექმენით გასაღების სარეზერვო საშუალება",
"setup_secure_backup": {
"backup_setup_success_description": "ახლა მიმდინარეობს თქვენი გასაღებების სარეზერვო ასლის შექმნა ამ მოწყობილობიდან.",
"backup_setup_success_title": "უსაფრთხო სარეზერვო ასლის შექმნა წარმატებით დასრულდა",
@@ -1884,7 +1877,6 @@
"ignore_users_empty": "თქვენ არ გყავთ უგულებელყოფილი მომხმარებლები.",
"ignore_users_section": "იგნორირებული მომხმარებლები",
"key_backup_algorithm": "ალგორითმი:",
- "key_backup_connect": "დააკავშირეთ ეს სესია Key Backup-თან",
"message_search_disable_warning": "თუ გამორთულია, დაშიფრული ოთახებიდან შეტყობინებები არ გამოჩნდება ძიების შედეგებში.",
"message_search_disabled": "დაშიფრული შეტყობინებების ლოკალურად დაშიფვრა, რათა გამოჩნდეს ძიების შედეგებში.",
"message_search_enabled": {
diff --git a/src/i18n/strings/lo.json b/src/i18n/strings/lo.json
index 3feb855896..cca8039f20 100644
--- a/src/i18n/strings/lo.json
+++ b/src/i18n/strings/lo.json
@@ -193,8 +193,7 @@
"megolm_export": "ສົ່ງກະແຈອອກດ້ວຍຕົນເອງ",
"setup_key_backup_title": "ທ່ານຈະສູນເສຍການເຂົ້າເຖິງລະຫັດຂໍ້ຄວາມຂອງທ່ານ",
"setup_secure_backup_description_1": "ຂໍ້ຄວາມທີ່ເຂົ້າລະຫັດໄວ້ແມ່ນປອດໄພດ້ວຍການເຂົ້າລະຫັດແບບຕົ້ນທາງ. ພຽງແຕ່ທ່ານ ແລະ ຜູ້ຮັບເທົ່ານັ້ນທີ່ມີກະແຈເພື່ອອ່ານຂໍ້ຄວາມເຫຼົ່ານີ້.",
- "skip_key_backup": "ຂ້ອຍບໍ່ຕ້ອງການຂໍ້ຄວາມທີ່ຖືກເຂົ້າລະຫັດຂອງຂ້ອຍ",
- "use_key_backup": "ເລີ່ມຕົ້ນການນໍາໃຊ້ ສຳຮອງກະເເຈ"
+ "skip_key_backup": "ຂ້ອຍບໍ່ຕ້ອງການຂໍ້ຄວາມທີ່ຖືກເຂົ້າລະຫັດຂອງຂ້ອຍ"
},
"misconfigured_body": "ຂໍໃຫ້ຜູ້ຄຸ້ມຄອງ %(brand)s ຂອງທ່ານກວດເບິ່ງ ການຕັ້ງຄ່າຂອງທ່ານ ສໍາລັບລາຍການທີ່ບໍ່ຖືກຕ້ອງ ຫຼື ຊໍ້າກັນ.",
"misconfigured_title": "%(brand)s ກຳນົດຄ່າຂອງທ່ານບໍ່ຖືກຕ້ອງ",
@@ -702,7 +701,6 @@
"warning": "ຖ້າທ່ານບໍ່ໄດ້ລືບຂະບວນການກູ້ຄືນ, ຜູ້ໂຈມຕີອາດຈະພະຍາຍາມເຂົ້າເຖິງບັນຊີຂອງທ່ານ. ປ່ຽນລະຫັດຜ່ານບັນຊີຂອງທ່ານ ແລະ ກຳນົດຂະບວນການກູ້ຄືນໃໝ່ທັນທີໃນການຕັ້ງຄ່າ."
},
"reset_all_button": "ລືມ ຫຼື ສູນເສຍວິທີການກູ້ຄືນທັງຫມົດ? ຕັ້ງຄ່າຄືນໃໝ່ທັງໝົດ",
- "set_up_toast_description": "ປ້ອງກັນການສູນເສຍການເຂົ້າເຖິງຂໍ້ຄວາມ ແລະຂໍ້ມູນທີ່ເຂົ້າລະຫັດ",
"set_up_toast_title": "ຕັ້ງຄ່າການສໍາຮອງຂໍ້ມູນທີ່ປອດໄພ",
"setup_secure_backup": {
"explainer": "ສຳຮອງຂໍ້ມູນກະແຈຂອງທ່ານກ່ອນທີ່ຈະອອກຈາກລະບົບເພື່ອຫຼີກເວັ້ນການສູນເສຍຂໍ້ມູນ."
@@ -1923,10 +1921,6 @@
"insert_trailing_colon_mentions": "ຈໍ້າສອງເມັດພາຍຫຼັງຈາກຜູ້ໃຊ້ກ່າວເຖິງໃນຕອນເລີ່ມຕົ້ນຂອງຂໍ້ຄວາມ",
"jump_to_bottom_on_send": "ໄປຫາລຸ່ມສຸດຂອງທາມລາຍເມື່ອທ່ານສົ່ງຂໍ້ຄວາມ",
"key_backup": {
- "backup_in_progress": "ກະແຈຂອງທ່ານກຳລັງຖືກສຳຮອງຂໍ້ມູນ (ການສຳຮອງຂໍ້ມູນຄັ້ງທຳອິດອາດໃຊ້ເວລາສອງສາມນາທີ).",
- "backup_success": "ສໍາເລັດ!",
- "cannot_create_backup": "ບໍ່ສາມາດສ້າງສໍາຮອງຂໍ້ມູນທີ່ສໍາຄັນ",
- "create_title": "ສ້າງການສໍາຮອງຂໍ້ມູນທີ່ສໍາຄັນ",
"setup_secure_backup": {
"cancel_warning": "ຖ້າທ່ານຍົກເລີກດຽວນີ້, ທ່ານອາດຈະສູນເສຍຂໍ້ຄວາມ & ຂໍ້ມູນທີ່ຖືກເຂົ້າລະຫັດ ແລະ ທ່ານສູນເສຍການເຂົ້າເຖິງການເຂົ້າສູ່ລະບົບຂອງທ່ານ.",
"confirm_security_phrase": "ຢືນຢັນປະໂຫຍກຄວາມປອດໄພຂອງທ່ານ",
@@ -2020,7 +2014,6 @@
"ignore_users_empty": "ທ່ານບໍ່ມີຜູ້ໃຊ້ທີ່ຖືກລະເລີຍ.",
"ignore_users_section": "ຜູ້ໃຊ້ຖືກຍົກເວັ້ນ",
"key_backup_algorithm": "ສູດການຄິດໄລ່:",
- "key_backup_connect": "ເຊື່ອມຕໍ່ລະບົບນີ້ກັບ ກະເເຈສຳຮອງ",
"message_search_disable_warning": "ຖ້າປິດໃຊ້ງານ, ຂໍ້ຄວາມຈາກຫ້ອງທີ່ເຂົ້າລະຫັດຈະບໍ່ປາກົດຢູ່ໃນຜົນການຄົ້ນຫາ.",
"message_search_disabled": "ເກັບຮັກສາຂໍ້ຄວາມທີ່ຖືກເຂົ້າລະຫັດໄວ້ຢ່າງປອດໄພຢູ່ໃນເຄື່ອງເພື່ອໃຫ້ປາກົດໃນຜົນການຄົ້ນຫາ.",
"message_search_enabled": {
diff --git a/src/i18n/strings/lt.json b/src/i18n/strings/lt.json
index ce5a36d807..1547c50b2b 100644
--- a/src/i18n/strings/lt.json
+++ b/src/i18n/strings/lt.json
@@ -163,8 +163,7 @@
"megolm_export": "Eksportuoti raktus rankiniu būdu",
"setup_key_backup_title": "Jūs prarasite prieigą prie savo užšifruotų žinučių",
"setup_secure_backup_description_1": "Užšifruotos žinutės yra apsaugotos visapusiu šifravimu. Tik jūs ir gavėjas(-ai) turi raktus šioms žinutėms perskaityti.",
- "skip_key_backup": "Man nereikalingos užšifruotos žinutės",
- "use_key_backup": "Pradėti naudoti atsarginę raktų kopiją"
+ "skip_key_backup": "Man nereikalingos užšifruotos žinutės"
},
"misconfigured_body": "Paprašykite savo %(brand)s administratoriaus patikrinti ar jūsų konfigūracijoje nėra neteisingų arba pasikartojančių įrašų.",
"misconfigured_title": "Jūsų %(brand)s yra neteisingai sukonfigūruotas",
@@ -537,7 +536,6 @@
"warning": "Jei jūs nepašalinote paskyros atgavimo metodo, gali būti, kad užpuolikas bando patekti į jūsų paskyrą. Nedelsiant nustatymuose pakeiskite savo paskyros slaptažodį ir nustatykite naują atgavimo metodą."
},
"reset_all_button": "Pamiršote arba praradote visus atkūrimo metodus? Iš naujo nustatyti viską",
- "set_up_toast_description": "Apsisaugokite nuo prieigos prie šifruotų žinučių ir duomenų praradimo",
"set_up_toast_title": "Nustatyti Saugią Atsarginę Kopiją",
"setup_secure_backup": {
"explainer": "Prieš atsijungdami sukurkite atsarginę savo raktų kopiją, kad išvengtumėte jų praradimo."
@@ -1479,9 +1477,6 @@
"insert_trailing_colon_mentions": "Įterpti dvitaškį po naudotojo paminėjimų žinutės pradžioje",
"jump_to_bottom_on_send": "Peršokti į laiko juostos apačią, kai siunčiate žinutę",
"key_backup": {
- "backup_in_progress": "Kuriama jūsų raktų atsarginė kopija (pirmas atsarginės kopijos sukūrimas gali užtrukti kelias minutes).",
- "cannot_create_backup": "Nepavyko sukurti atsarginės raktų kopijos",
- "create_title": "Sukurti atsarginę raktų kopiją",
"setup_secure_backup": {
"confirm_security_phrase": "Patvirtinkite savo Saugumo Frazę",
"enter_phrase_title": "Įveskite Slaptafrazę",
@@ -1571,7 +1566,6 @@
"ignore_users_empty": "Nėra ignoruojamų naudotojų.",
"ignore_users_section": "Ignoruojami vartotojai",
"key_backup_algorithm": "Algoritmas:",
- "key_backup_connect": "Prijungti šį seansą prie Atsarginės Raktų Kopijos",
"message_search_disabled": "Šifruotas žinutes saugiai talpinkite lokaliai, kad jos būtų rodomos paieškos rezultatuose.",
"message_search_enabled": {
"one": "Saugiai talpinkite užšifruotas žinutes vietoje, kad jos būtų rodomos paieškos rezultatuose, naudojant %(size)s žinutėms iš %(rooms)s kambario saugoti.",
diff --git a/src/i18n/strings/lv.json b/src/i18n/strings/lv.json
index 18c1d8361b..1b29ef2c3e 100644
--- a/src/i18n/strings/lv.json
+++ b/src/i18n/strings/lv.json
@@ -224,8 +224,7 @@
"setup_key_backup_title": "Tu zaudēsi piekļuvi savām šifrētajām ziņām",
"setup_secure_backup_description_1": "Šifrētas ziņas tiek nodrošinātas ar pilnīgu šifrēšanu. Tikai Tev un saņēmējam(iem) ir atslēgas šo ziņojumu lasīšanai.",
"setup_secure_backup_description_2": "Kad Tu atteiksies, šīs atslēgas tiks izdzēstas no šīs ierīces, kas nozīmē, ka Tu nevarēsi lasīt šifrētas ziņas, līdz Tev būs to atslēgas citās ierīcēs vai serverī izveidosi to rezerves kopiju.",
- "skip_key_backup": "Es nevēlos savas šifrētās ziņas",
- "use_key_backup": "Sākt izmantot atslēgu rezerves kopiju veidošanu"
+ "skip_key_backup": "Es nevēlos savas šifrētās ziņas"
},
"misconfigured_body": "Jāvaicā savam %(brand)s pārvaldniekam pārbaudīt, vai konfigurācijā ir nepareizu vai atkārtojošos ierakstu",
"misconfigured_title": "%(brand)s ir nepareizi konfigurēts",
@@ -877,7 +876,6 @@
"warning": "Ja nenoņēmi atkopšanas veidu, var būt, ka uzbrucējs mēģina piekļūt Tavam kontam. Nekavējoties jānomaina sava konta parole un jāiestata jauns atkopšanas veids."
},
"reset_all_button": "Aizmirsāt vai pazaudējāt visas atkopšanās iespējas? Atiestatiet visu",
- "set_up_toast_description": "Pasargājieties pret piekļuves zaudēšanu šifrētām ziņām un datiem",
"set_up_toast_title": "Iestatīt drošu rezerves dublēšanu",
"setup_secure_backup": {
"explainer": "Pirms izrakstīšanās iestatiet atslēgu dublēšanu, lai izvairītos no tās pazaudēšanas."
@@ -2225,11 +2223,6 @@
"insert_trailing_colon_mentions": "Jāievieto beigu kols pēc lietotāja pieminēšanas ziņas sākumā",
"jump_to_bottom_on_send": "Nosūtot ziņu, pāriet uz laika skalas beigām",
"key_backup": {
- "backup_in_progress": "Jūsu atslēgas tiek dublētas (pirmā dublēšana var ilgt dažas minūtes).",
- "backup_starting": "Sāk dublējumu...",
- "backup_success": "Veiksmīgi!",
- "cannot_create_backup": "Nevar izveidot atslēgu rezerves kopiju",
- "create_title": "Izveidot atslēgu rezerves kopiju",
"setup_secure_backup": {
"backup_setup_success_description": "Tagad jūsu atslēgas tiek dublētas no šīs ierīces.",
"backup_setup_success_title": "Droša dublējuma izveide ir veiksmīga",
@@ -2364,7 +2357,6 @@
"ignore_users_empty": "Nav vērā neņemtu lietotāju.",
"ignore_users_section": "Vērā neņemtie lietotāji",
"key_backup_algorithm": "Algoritms:",
- "key_backup_connect": "Savienojiet šo sesiju ar atslēgu dublēšanu",
"message_search_disable_warning": "Ja šī opcija ir atspējota, ziņas no šifrētām istabām neparādīsies meklēšanas rezultātos.",
"message_search_disabled": "Drošā veidā saglabājiet lokālajā kešatmiņā šifrētās ziņas, lai tās parādītos meklēšanas rezultātos.",
"message_search_enabled": {
diff --git a/src/i18n/strings/mg_MG.json b/src/i18n/strings/mg_MG.json
index c6666dac0b..66cc4a257e 100644
--- a/src/i18n/strings/mg_MG.json
+++ b/src/i18n/strings/mg_MG.json
@@ -219,8 +219,7 @@
"setup_key_backup_title": "Ho very ny fidirana amin'ny hafatrao voasakana",
"setup_secure_backup_description_1": "Ny hafatra voarakitra dia arovana aminy fanafenana farany mankany amin'ny farany. Ianao sy ny mpandray (ireo) ihany no manana ny fanalahidy hamakiana ireo hafatra ireo.",
"setup_secure_backup_description_2": "Rehefa mivoaka ianao dia ho voafafa amin'ity fitaovana ity ireo fanalahidy ireo, izay midika fa tsy ho afaka hamaky hafatra voafono ianao raha tsy manana ny fanalahidin'izy ireo amin'ny fitaovanao hafa, na averinao any aminy mpizara.",
- "skip_key_backup": "Tsy tiako ny hafatra voarakotra",
- "use_key_backup": "Manomboka mampiasa fanalahidin'ny fitahirizana"
+ "skip_key_backup": "Tsy tiako ny hafatra voarakotra"
},
"misconfigured_body": "Anontanio ny anao%(brand)s admin hanamarina ny config hoany fidirana diso na dika mitovy.",
"misconfigured_title": "ny%(brand)s dia diso fanitsiana",
@@ -834,7 +833,6 @@
"warning": "Raha tsy nesorinao ny fomba fanarenana, dia mety hanandrana hiditra aminy kaontinao ny mpanafika. Hanova ny tenimiafiny kaontinao ary mametraha fomba fanarenana vaovao avy hatrany ao amin'ny fanitsiana."
},
"reset_all_button": "Adino na very ny fomba fanarenana rehetra? Avereno daholo",
- "set_up_toast_description": "Arovy aminy fahaverezany fidirana amin'ny hafatra sy angon-drakitra voatahiry",
"set_up_toast_title": "Hanitsy angona voaharo",
"setup_secure_backup": {
"explainer": "Amboary ny fanalahidinao alohany hivoahana mba tsy ho very."
@@ -2292,11 +2290,6 @@
"insert_trailing_colon_mentions": "Ampidiro tsangambato aoriana aorian'ny fiteny mpampiasa eo am-piandohany hafatra",
"jump_to_bottom_on_send": "Mankanesa any amin'ny farany fandaharam-potoana rehefa mandefa hafatra ianao",
"key_backup": {
- "backup_in_progress": "Averina averina ny fanalahidinao (mety haharitra minitra vitsivitsy ny vakorakitra voalohany).",
- "backup_starting": "Manomboka vakorakitra…",
- "backup_success": "Fahombiazana!",
- "cannot_create_backup": "Tsy afaka mamorona fanalahidin'ny vakorakitra",
- "create_title": "Mamorona fanalahidin'ny Vakorakitra ",
"setup_secure_backup": {
"backup_setup_success_description": "Averina averina amin'ity fitaovana ity izao ny fanalahidinao.",
"backup_setup_success_title": "Nahomby ny fiarovana vakorakitra",
@@ -2431,7 +2424,6 @@
"ignore_users_empty": "Tsy manana mpampiasa tsy noraharahiana ianao.",
"ignore_users_section": "Ireo mpampiasa tsy noraharahiana",
"key_backup_algorithm": "Algorithm:",
- "key_backup_connect": "Ampifandraiso amin'ny fanalahidin'ny vakorakitra ity fivoriana ity",
"message_search_disable_warning": "Raha kilemaina, dia tsy hiseho aminy valiny fikarohana ny hafatra avy aminy efitrano misy miafina.",
"message_search_disabled": "Ampidiro ao an-toerana ny hafatra miafina mba hiseho aminy valiny fikarohana.",
"message_search_enabled": {
diff --git a/src/i18n/strings/nb_NO.json b/src/i18n/strings/nb_NO.json
index e0f08b7ff8..d601d800bb 100644
--- a/src/i18n/strings/nb_NO.json
+++ b/src/i18n/strings/nb_NO.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Du mister tilgangen til de krypterte meldingene dine",
"setup_secure_backup_description_1": "Krypterte meldinger er sikret med punkt-til-punkt-kryptering. Bare du og mottakeren(e) har nøklene til å lese disse meldingene.",
"setup_secure_backup_description_2": "Når du logger av, slettes disse nøklene fra denne enheten, noe som betyr at du ikke vil kunne lese krypterte meldinger med mindre du har nøklene til dem på dine andre enheter, eller har sikkerhetskopiert dem til serveren.",
- "skip_key_backup": "Jeg vil ikke ha mine krypterte meldinger",
- "use_key_backup": "Begynn å bruke Nøkkelsikkerhetskopiering"
+ "skip_key_backup": "Jeg vil ikke ha mine krypterte meldinger"
},
"misconfigured_body": "Spør din%(brand)s administrator om å sjekke konfigurasjonen din for feil eller dupliserte oppføringer.",
"misconfigured_title": "Ditt %(brand)s-oppsett er feiloppsatt",
@@ -968,9 +967,7 @@
},
"reset_all_button": "Glemt eller mistet alle gjenopprettingsmetoder? Tilbakestill alt",
"set_up_recovery": "Sett opp gjenoppretting",
- "set_up_recovery_later": "Ikke nå",
"set_up_recovery_toast_description": "Generer en gjenopprettingsnøkkel som kan brukes til å gjenopprette den krypterte meldingshistorikken i tilfelle du mister tilgangen til enhetene dine.",
- "set_up_toast_description": "Sikre deg mot å miste tilgang til krypterte meldinger og data",
"set_up_toast_title": "Sett opp sikker sikkerhetskopiering",
"setup_secure_backup": {
"explainer": "Ta sikkerhetskopi av nøklene dine før du logger av for å unngå å miste dem."
@@ -1023,6 +1020,7 @@
"no_userid": "Kan ikke verifisere enheten - finner ikke bruker-ID",
"success_description": "Enheten (%(deviceId)s) er nå krysssignert",
"success_title": "Verifiseringen var vellykket",
+ "text": "Oppgi ID-en og fingeravtrykket til en av dine egne enheter for å verifisere det. MERK at dette lar den andre enheten sende og motta meldinger som deg. HVIS NOEN HAR FORTALT DEG BARE Å LIME INN NOE HER, ER DET SANNSYNLIGVIS AT DU BLIR SVINDLET!",
"wrong_fingerprint": "Kan ikke verifisere enheten %(deviceId)s '- det medfølgende fingeravtrykket'%(fingerprint)s «samsvarer ikke med enhetens fingeravtrykk»%(fprint)s '"
},
"no_key_or_device": "Det ser ut til at du ikke har en gjenopprettingsnøkkel eller andre enheter du kan verifisere mot. Denne enheten vil ikke kunne få tilgang til gamle krypterte meldinger. For å bekrefte identiteten din på denne enheten, må du tilbakestille verifiseringsnøklene dine.",
@@ -2065,6 +2063,7 @@
"read_topic": "Klikk for å lese emnet",
"rejecting": "Avviser invitasjon...",
"rejoin_button": "Bli med igjen",
+ "room_content": "Rominnhold",
"room_is_low_priority": "Dette er et lavt prioritert rom",
"search": {
"all_rooms_button": "Søk i alle rom",
@@ -2114,6 +2113,7 @@
"add_space_label": "Legg til område",
"breadcrumbs_empty": "Ingen nylig besøkte rom",
"breadcrumbs_label": "Nylig besøkte rom",
+ "collapse_filters": "Skjul filterlisten",
"empty": {
"no_chats": "Ingen chatter ennå",
"no_chats_description": "Kom i gang ved å sende meldinger til noen eller ved å opprette et rom",
@@ -2710,11 +2710,6 @@
},
"jump_to_bottom_on_send": "Gå til bunnen av tidslinjen når du vil sende en melding",
"key_backup": {
- "backup_in_progress": "Nøklene dine blir sikkerhetskopiert (den første sikkerhetskopieringen kan ta noen minutter).",
- "backup_starting": "Starter sikkerhetskopiering...",
- "backup_success": "Suksess!",
- "cannot_create_backup": "Kan ikke opprette sikkerhetskopiering av nøkler",
- "create_title": "Opprett nøkkelsikkerhetskopi",
"setup_secure_backup": {
"backup_setup_success_description": "Nøklene dine blir nå sikkerhetskopiert fra denne enheten.",
"backup_setup_success_title": "Sikker sikkerhetskopiering vellykket",
@@ -2841,6 +2836,7 @@
"composer_heading": "Komposør",
"default_timezone": "Nettleserens standard (%(timezone)s)",
"dialog_title": "Innstillinger: Preferanser",
+ "enable_content_protection": "Aktiver innholdsbeskyttelse",
"enable_hardware_acceleration": "Aktiver maskinvareakselerasjon",
"enable_tray_icon": "Vis skuffikonet og minimer vinduet til det når det lukkes",
"keyboard_heading": "Tastatursnarveier",
@@ -2874,7 +2870,6 @@
"ignore_users_empty": "Du har ingen ignorerte brukere.",
"ignore_users_section": "Ignorerte brukere",
"key_backup_algorithm": "Algoritme:",
- "key_backup_connect": "Koble denne økten til hoved Backup",
"message_search_disable_warning": "Hvis deaktivert, vises ikke meldinger fra krypterte rom i søkeresultatene.",
"message_search_disabled": "Bufre krypterte meldinger lokalt på en sikker måte, slik at de vises i søkeresultatene.",
"message_search_enabled": {
@@ -3112,6 +3107,7 @@
"jumptodate": "Gå til den gitte datoen i tidslinjen",
"jumptodate_invalid_input": "Vi klarte ikke å forstå den gitte datoen (%(inputDate)s). Prøv å bruke formatet ÅÅÅÅ-MM-DD.",
"lenny": "Legger til ( ͡° ͜ʖ ͡°) foran en ren tekstmelding",
+ "manual_device_verification_confirm_description": "Dette vil tillate en annen enhet å sende og motta meldinger som deg. HVIS NOEN BA DEG LIME INN NOE HER, ER DET SANNSYNLIG AT DU BLIR LURT! Er du sikker på at du vil bekrefte denne andre enheten?",
"manual_device_verification_confirm_title": "Forsiktig: manuell enhetsverifisering",
"me": "Viser handling",
"msg": "Sender en melding til den angitte brukeren",
diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json
index 348a0b43d3..1e3b2206dc 100644
--- a/src/i18n/strings/nl.json
+++ b/src/i18n/strings/nl.json
@@ -209,8 +209,7 @@
"setup_key_backup_title": "Je zal de toegang tot je versleutelde berichten verliezen",
"setup_secure_backup_description_1": "Versleutelde berichten zijn beveiligd met eind-tot-eind-versleuteling. Enkel de ontvanger(s) en jij hebben de sleutels om deze berichten te lezen.",
"setup_secure_backup_description_2": "Wanneer je jezelf afmeldt, worden deze sleutels van dit apparaat verwijderd, wat betekent dat je geen versleutelde berichten kunt lezen, tenzij je de sleutels ervoor op je andere apparaten hebt of er een back-up van hebt gemaakt op de server.",
- "skip_key_backup": "Ik wil mijn versleutelde berichten niet",
- "use_key_backup": "Begin sleutelback-up te gebruiken"
+ "skip_key_backup": "Ik wil mijn versleutelde berichten niet"
},
"misconfigured_body": "Vraag jouw %(brand)s-beheerder je configuratie na te kijken op onjuiste of dubbele items.",
"misconfigured_title": "Je %(brand)s is onjuist geconfigureerd",
@@ -733,7 +732,6 @@
"warning": "Als je de herstelmethode niet hebt verwijderd, is het mogelijk dat er een aanvaller toegang tot jouw account probeert te verkrijgen. Wijzig onmiddellijk je wachtwoord en stel bij instellingen een nieuwe herstelmethode in."
},
"reset_all_button": "Alles vergeten en alle herstelmethoden verloren? Alles opnieuw instellen",
- "set_up_toast_description": "Beveiliging tegen verlies van toegang tot versleutelde berichten en gegevens",
"set_up_toast_title": "Beveiligde back-up instellen",
"setup_secure_backup": {
"explainer": "Maak een back-up van je sleutels voordat je jezelf afmeldt om ze niet te verliezen."
@@ -1979,10 +1977,6 @@
"insert_trailing_colon_mentions": "Voeg een dubbele punt in nadat de persoon het aan het begin van een bericht heeft vermeld",
"jump_to_bottom_on_send": "Naar de onderkant van de tijdlijn springen wanneer je een bericht verstuurd",
"key_backup": {
- "backup_in_progress": "Er wordt een back-up van je sleutels gemaakt (de eerste back-up kan enkele minuten duren).",
- "backup_success": "Klaar!",
- "cannot_create_backup": "Kan sleutelback-up niet aanmaken",
- "create_title": "Sleutelback-up aanmaken",
"setup_secure_backup": {
"cancel_warning": "Als je nu annuleert, kan je versleutelde berichten en gegevens verliezen als je geen toegang meer hebt tot je login.",
"confirm_security_phrase": "Bevestig je veiligheidswachtwoord",
@@ -2082,7 +2076,6 @@
"ignore_users_empty": "Je hebt geen persoon genegeerd.",
"ignore_users_section": "Genegeerde personen",
"key_backup_algorithm": "Algoritme:",
- "key_backup_connect": "Verbind deze sessie met de sleutelback-up",
"message_search_disable_warning": "Dit moet aan staan om te kunnen zoeken in versleutelde kamers.",
"message_search_disabled": "Sla versleutelde berichten veilig lokaal op om ze doorzoekbaar te maken.",
"message_search_enabled": {
diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json
index ce09d10a57..b8c3ac840e 100644
--- a/src/i18n/strings/pl.json
+++ b/src/i18n/strings/pl.json
@@ -241,8 +241,7 @@
"setup_key_backup_title": "Utracisz dostęp do swoich wiadomości szyfrowanych",
"setup_secure_backup_description_1": "Wiadomości szyfrowane są zabezpieczone szyfrowaniem end-to-end. Tylko Ty i Twój odbiorca mają klucze, aby je wyświetlić.",
"setup_secure_backup_description_2": "Po wylogowaniu, te klucze zostaną usunięte z urządzenia, co oznacza, że nie będziesz w stanie czytać wiadomości szyfrowanych, chyba że posiadasz je na swoich innych urządzeniach lub zapisałeś je na serwerze.",
- "skip_key_backup": "Nie chcę moich zaszyfrowanych wiadomości",
- "use_key_backup": "Rozpocznij z użyciem klucza kopii zapasowej"
+ "skip_key_backup": "Nie chcę moich zaszyfrowanych wiadomości"
},
"misconfigured_body": "Poproś swojego administratora %(brand)s by sprawdzić Twoją konfigurację względem niewłaściwych lub zduplikowanych elementów.",
"misconfigured_title": "Twój %(brand)s jest źle skonfigurowany",
@@ -356,7 +355,7 @@
"set_email_prompt": "Czy chcesz ustawić adres e-mail?",
"sign_in_description": "Użyj swojego konta, aby kontynuować.",
"sign_in_instead": "Zamiast tego zaloguj się",
- "sign_in_instead_prompt": "Masz już konto? Zaloguj się tutaj",
+ "sign_in_instead_prompt": "Masz już konto? Zaloguj się tutaj ",
"sign_in_or_register": "Zaloguj się lub utwórz konto",
"sign_in_or_register_description": "Użyj konta lub utwórz nowe, aby kontynuować.",
"sign_in_prompt": "Posiadasz już konto? Zaloguj się",
@@ -917,7 +916,7 @@
"encryption": {
"access_secret_storage_dialog": {
"key_validation_text": {
- "wrong_security_key": "Błędny klucz przywracania"
+ "wrong_security_key": "Wprowadzony klucz przywracania nie jest poprawny."
},
"restoring": "Przywracanie kluczy z kopii zapasowej",
"security_key_title": "Klucz przywracania"
@@ -967,9 +966,7 @@
},
"reset_all_button": "Zapomniałeś lub straciłeś wszystkie opcje odzyskiwania? Resetuj wszystko",
"set_up_recovery": "Skonfiguruj przywracanie",
- "set_up_recovery_later": "Nie teraz",
"set_up_recovery_toast_description": "Wygeneruj klucz przywracania, którego można użyć do przywrócenia zaszyfrowanej historii wiadomości w przypadku utraty dostępu do swoich urządzeń.",
- "set_up_toast_description": "Zabezpiecz się przed utratą dostępu do szyfrowanych wiadomości i danych",
"set_up_toast_title": "Skonfiguruj bezpieczną kopię zapasową",
"setup_secure_backup": {
"explainer": "Utwórz kopię zapasową kluczy przed wylogowaniem, aby ich nie utracić."
@@ -1650,7 +1647,7 @@
},
"member_list_back_action_label": "Członkowie pokoju",
"message_edit_dialog_title": "Edycje wiadomości",
- "migrating_crypto": "Trzymaj się mocno. Aktualizujemy %(brand)s, aby szyfrowanie było szybsze i bardziej niezawodne.",
+ "migrating_crypto": "Spokojnie. Aktualizujemy %(brand)s, aby szyfrowanie było szybsze i bardziej niezawodne.",
"mobile_guide": {
"toast_accept": "Użyj aplikacji",
"toast_description": "%(brand)s jest eksperymentalne na przeglądarce mobilnej. Dla lepszego doświadczenia i najnowszych funkcji użyj naszej darmowej natywnej aplikacji.",
@@ -2690,11 +2687,6 @@
"insert_trailing_colon_mentions": "Wstawiaj dwukropek po wzmiance użytkownika na początku wiadomości",
"jump_to_bottom_on_send": "Przejdź na dół osi czasu po wysłaniu wiadomości",
"key_backup": {
- "backup_in_progress": "Tworzy się kopia zapasowa Twoich kluczy (pierwsza kopia może potrwać kilka minut).",
- "backup_starting": "Rozpoczynanie kopii zapasowej…",
- "backup_success": "Sukces!",
- "cannot_create_backup": "Nie można utworzyć kopii zapasowej klucza",
- "create_title": "Utwórz kopię zapasową klucza",
"setup_secure_backup": {
"backup_setup_success_description": "Twoje klucze są właśnie przywracane z tego urządzenia.",
"backup_setup_success_title": "Wykonanie bezpiecznej kopii zapasowej powiodło się",
@@ -2852,7 +2844,6 @@
"ignore_users_empty": "Nie posiadasz ignorowanych użytkowników.",
"ignore_users_section": "Ignorowani użytkownicy",
"key_backup_algorithm": "Algorytm:",
- "key_backup_connect": "Połącz tę sesję z kopią zapasową kluczy",
"message_search_disable_warning": "Jeśli wyłączone, wiadomości z szyfrowanych pokojów nie pojawią się w wynikach wyszukiwania.",
"message_search_disabled": "Bezpiecznie przechowuj lokalnie wiadomości szyfrowane, aby mogły się wyświetlać w wynikach wyszukiwania.",
"message_search_enabled": {
@@ -4112,7 +4103,7 @@
"error_need_to_be_logged_in": "Musisz być zalogowany.",
"error_unable_start_audio_stream_description": "Nie można rozpocząć przesyłania strumienia audio.",
"error_unable_start_audio_stream_title": "Nie udało się rozpocząć transmisji na żywo",
- "modal_data_warning": "Poniższe dane są współdzielone z %(widgetDomain)s",
+ "modal_data_warning": "Poniższe dane są udostępniane z %(widgetDomain)s",
"modal_title_default": "Widżet modalny",
"no_name": "Nieznana aplikacja",
"open_id_permissions_dialog": {
diff --git a/src/i18n/strings/pt.json b/src/i18n/strings/pt.json
index eefb0a8254..501609346b 100644
--- a/src/i18n/strings/pt.json
+++ b/src/i18n/strings/pt.json
@@ -226,8 +226,7 @@
"setup_key_backup_title": "Perderás o acesso às tuas mensagens encriptadas",
"setup_secure_backup_description_1": "As mensagens encriptadas são protegidas com encriptação de ponta a ponta. Só tu e o(s) destinatário(s) têm as chaves para ler estas mensagens.",
"setup_secure_backup_description_2": "Quando terminares a sessão, estas chaves serão eliminadas deste dispositivo, o que significa que não poderás ler mensagens encriptadas, a menos que tenhas as chaves para elas nos teus outros dispositivos ou tenhas feito uma cópia de segurança das mesmas no servidor.",
- "skip_key_backup": "Não quero as minhas mensagens encriptadas",
- "use_key_backup": "Começa a utilizar a Cópia de Segurança das Chaves"
+ "skip_key_backup": "Não quero as minhas mensagens encriptadas"
},
"misconfigured_body": "Pede ao teu administrador %(brand)s para verificar a tua configuração para ver se há entradas incorretas ou duplicadas.",
"misconfigured_title": "O teu %(brand)s está mal configurado",
@@ -936,9 +935,7 @@
},
"reset_all_button": "Esqueceste-te ou perdeste todos os métodos de recuperação? Repor tudo",
"set_up_recovery": "Configurar a recuperação",
- "set_up_recovery_later": "Agora não",
"set_up_recovery_toast_description": "Gera uma chave de recuperação que pode ser utilizada para restaurar o teu histórico de mensagens encriptadas, caso percas o acesso aos teus dispositivos.",
- "set_up_toast_description": "Protege-te contra a perda de acesso a mensagens e dados encriptados",
"set_up_toast_title": "Configura uma cópia de segurança segura",
"setup_secure_backup": {
"explainer": "Guarda as tuas chaves antes de saíres para evitar perdê-las."
@@ -2627,11 +2624,6 @@
"insert_trailing_colon_mentions": "Insere dois pontos após as menções do utilizador no início de uma mensagem",
"jump_to_bottom_on_send": "Salta para o fundo da linha de tempo quando enviar uma mensagem",
"key_backup": {
- "backup_in_progress": "Está a ser feita uma cópia de segurança das tuas chaves (a primeira cópia de segurança pode demorar alguns minutos).",
- "backup_starting": "Iniciar cópia de segurança...",
- "backup_success": "Sucesso!",
- "cannot_create_backup": "Não é possível criar uma cópia de segurança da chave",
- "create_title": "Cria uma cópia de segurança da chave",
"setup_secure_backup": {
"backup_setup_success_description": "As tuas chaves estão agora a ser copiadas a partir deste dispositivo.",
"backup_setup_success_title": "Backup seguro bem-sucedido",
@@ -2781,7 +2773,6 @@
"ignore_users_empty": "Não tens utilizadores ignorados.",
"ignore_users_section": "Utilizadores ignorados",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Liga esta sessão à cópia de segurança das chaves",
"message_search_disable_warning": "Se estiver desactivada, as mensagens de salas encriptadas não aparecerão nos resultados da pesquisa.",
"message_search_disabled": "Armazena em cache de forma segura as mensagens encriptadas localmente para que apareçam nos resultados de pesquisa.",
"message_search_enabled": {
diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json
index ebe5b8c12f..0d88c23ceb 100644
--- a/src/i18n/strings/pt_BR.json
+++ b/src/i18n/strings/pt_BR.json
@@ -240,8 +240,7 @@
"setup_key_backup_title": "Você perderá acesso às suas mensagens criptografadas",
"setup_secure_backup_description_1": "As mensagens estão protegidas com a criptografia de ponta a ponta. Somente você e o(s) destinatário(s) têm as chaves para ler essas mensagens.",
"setup_secure_backup_description_2": "Quando você se desconectar, essas chaves serão excluídas desse dispositivo, o que significa que você não poderá ler mensagens criptografadas, a menos que tenha as chaves delas em seus outros dispositivos ou tenha feito backup delas no servidor.",
- "skip_key_backup": "Não quero minhas mensagens criptografadas",
- "use_key_backup": "Comece a usar backup de chave"
+ "skip_key_backup": "Não quero minhas mensagens criptografadas"
},
"misconfigured_body": "Entre em contato com o administrador do %(brand)s para verificar se há entradas inválidas ou duplicadas nas suas configurações.",
"misconfigured_title": "O %(brand)s está mal configurado",
@@ -968,9 +967,7 @@
},
"reset_all_button": "Esqueceu ou perdeu todos os métodos de recuperação? Redefinir tudo",
"set_up_recovery": "Configurar a recuperação",
- "set_up_recovery_later": "Agora não",
"set_up_recovery_toast_description": "Gere uma chave de recuperação que possa ser usada para restaurar seu histórico de mensagens criptografadas caso você perca o acesso aos seus dispositivos.",
- "set_up_toast_description": "Proteja-se contra a perda de acesso a mensagens e dados criptografados",
"set_up_toast_title": "Configurar o backup online",
"setup_secure_backup": {
"explainer": "Faça o backup das suas chaves antes de sair, para evitar perdê-las."
@@ -1606,7 +1603,7 @@
"MapStyleUrlNotConfigured": "Esse•servidor•doméstico•não•está•configurado•para•exibir•mapas.",
"MapStyleUrlNotReachable": "Esse•servidor•doméstico•não•está•configurado•corretamente•para•exibir•mapas,•ou•o•servidor•de•mapas•configurado•pode•estar•inacessível.",
"WebGLNotEnabled": "O•WebGL•é•necessário•para•exibir•mapas,•ative-o•nas•configurações•do•seu•navegador.",
- "click_drop_pin": "Clique para soltar um alfinete",
+ "click_drop_pin": "Clique para soltar um alfinete",
"click_move_pin": "Clique•para•mover•o•alfinete",
"close_sidebar": "Fechar•barra•lateral",
"error_fetch_location": "Não•foi•possível•obter•a•localização",
@@ -2065,6 +2062,7 @@
"read_topic": "Clique para ler o tópico",
"rejecting": "Rejeitando o convite...",
"rejoin_button": "Entrar novamente",
+ "room_content": "Conteúdo da sala",
"room_is_low_priority": "Esta é uma sala de baixa prioridade",
"search": {
"all_rooms_button": "Pesquisar todos as salas",
@@ -2114,6 +2112,7 @@
"add_space_label": "Adicionar espaço",
"breadcrumbs_empty": "Nenhuma sala foi visitada recentemente",
"breadcrumbs_label": "Salas visitadas recentemente",
+ "collapse_filters": "Recolher lista de filtros",
"empty": {
"no_chats": "Ainda não há conversas.",
"no_chats_description": "Comece enviando uma mensagem para alguém ou criando uma sala",
@@ -2121,6 +2120,7 @@
"no_favourites": "Você ainda não tem o bate-papo favorito",
"no_favourites_description": "Você pode adicionar um bate-papo aos seus favoritos nas configurações de bate-papo",
"no_invites": "Você não tem nenhum convite não lido",
+ "no_lowpriority": "Você não tem nenhuma sala de baixa prioridade",
"no_mentions": "Você não tem nenhuma menção não lida",
"no_people": "Você ainda não tem conversas diretas com ninguém",
"no_people_description": "Você pode desmarcar os filtros para ver suas outras conversas",
@@ -2130,6 +2130,7 @@
"show_activity": "Ver todas as atividades",
"show_chats": "Mostrar todas as conversas"
},
+ "expand_filters": "Expandir lista de filtros",
"failed_add_tag": "Falha ao adicionar a tag %(tagName)s para a sala",
"failed_remove_tag": "Falha ao remover a tag %(tagName)s da sala",
"failed_set_dm_tag": "Falha ao definir a marca de mensagem direta",
@@ -2709,11 +2710,6 @@
},
"jump_to_bottom_on_send": "Vá para o final da linha do tempo ao enviar uma mensagem",
"key_backup": {
- "backup_in_progress": "O backup de suas chaves está sendo feito (o primeiro backup pode demorar alguns minutos).",
- "backup_starting": "Iniciando o backup...",
- "backup_success": "Pronto!",
- "cannot_create_backup": "Não foi possível criar backup da chave",
- "create_title": "Criar backup de chave",
"setup_secure_backup": {
"backup_setup_success_description": "As suas chaves estão agora a ser copiadas a partir deste dispositivo.",
"backup_setup_success_title": "Backup seguro bem-sucedido",
@@ -2840,6 +2836,7 @@
"composer_heading": "Campo de texto",
"default_timezone": "Navegador padrão (%(timezone)s)",
"dialog_title": "Configurações: Preferências",
+ "enable_content_protection": "Habilitar a proteção de conteúdo",
"enable_hardware_acceleration": "Habilitar aceleração de hardware",
"enable_tray_icon": "Mostrar o ícone da bandeja e minimizar a janela ao fechar",
"keyboard_heading": "Teclas de atalho do teclado",
@@ -2873,7 +2870,6 @@
"ignore_users_empty": "Você não tem usuários ignorados.",
"ignore_users_section": "Usuários bloqueados",
"key_backup_algorithm": "Algoritmo:",
- "key_backup_connect": "Autorize esta sessão a fazer o backup de chaves",
"message_search_disable_warning": "Se desativado, as mensagens de salas criptografadas não aparecerão em resultados de buscas.",
"message_search_disabled": "Armazene mensagens criptografadas de forma segura localmente para que possam aparecer nos resultados das buscas.",
"message_search_enabled": {
@@ -3111,6 +3107,8 @@
"jumptodate": "Ir para a data especificada na linha do tempo",
"jumptodate_invalid_input": "Não foi possível entender a data fornecida (%(inputDate)s). Tente usando o formato AAAA-MM-DD.",
"lenny": "Adiciona ( ͡° ͜ʖ ͡°) a uma mensagem de texto",
+ "manual_device_verification_confirm_description": "Isso permitirá que outro dispositivo envie e receba mensagens como você. SE ALGUÉM LHE DISSE PARA COLAR ALGO AQUI, É PROVÁVEL QUE VOCÊ ESTEJA SENDO ENGANADO! Tem certeza de que deseja verificar esse outro dispositivo?",
+ "manual_device_verification_confirm_title": "Cuidado: verificação manual do dispositivo",
"me": "Visualizar atividades",
"msg": "Envia uma mensagem para determinada pessoa",
"myavatar": "Altera a foto do seu perfil em todas as salas",
diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json
index 44eeefdf92..27f0ca4afb 100644
--- a/src/i18n/strings/ru.json
+++ b/src/i18n/strings/ru.json
@@ -12,6 +12,18 @@
"one": "1 непрочитанное упоминание."
},
"recent_rooms": "Недавние комнаты",
+ "room_messsage_not_sent": "Открыть комнату %(roomName)s с неотправленным сообщением.",
+ "room_n_unread_invite": "Открыть приглашение в комнату %(roomName)s.",
+ "room_n_unread_messages": {
+ "one": "Открыть комнату %(roomName)s с 1 непрочитанным сообщением.",
+ "few": "Открыть комнату %(roomName)s с %(count)s непрочитанными сообщениями.",
+ "many": "Открыть комнату %(roomName)s с %(count)s непрочитанными сообщениями."
+ },
+ "room_n_unread_messages_mentions": {
+ "one": "Открыть комнату %(roomName)s с 1 непрочитанным упоминанием.",
+ "few": "Открыть комнату %(roomName)s с %(count)s непрочитанными упоминаниями.",
+ "many": "Открыть комнату %(roomName)s с %(count)s непрочитанными упоминаниями."
+ },
"room_name": "Комната %(name)s",
"room_status_bar": "Строка состояния комнаты",
"seek_bar_label": "Панель поиска аудио",
@@ -230,8 +242,7 @@
"setup_key_backup_title": "Вы потеряете доступ к вашим шифрованным сообщениям",
"setup_secure_backup_description_1": "Эти сообщения защищены сквозным шифрованием. Только вы и ваш собеседник имеете ключи для их расшифровки и чтения.",
"setup_secure_backup_description_2": "При выходе эти ключи будут удалены с данного устройства и вы больше не сможете прочитать зашифрованные сообщения, если у вас нет ключей для них на других устройствах или резервной копии на сервере.",
- "skip_key_backup": "Мне не нужны мои зашифрованные сообщения",
- "use_key_backup": "Использовать резервную копию ключей шифрования"
+ "skip_key_backup": "Мне не нужны мои зашифрованные сообщения"
},
"misconfigured_body": "Попросите администратора %(brand)s проверить конфигурационный файл на наличие неправильных или повторяющихся записей.",
"misconfigured_title": "Ваш %(brand)s неправильно настроен",
@@ -742,6 +753,7 @@
},
"decline_invitation_dialog": {
"confirm": "Вы действительно хотите отклонить приглашение присоединиться \"%(roomName)s\"?",
+ "ignore_user_help": "Вы не увидите сообщений или приглашений в комнату от этого пользователя.",
"reason_description": "Опишите причину сообщения о проблеме.",
"report_room_description": "Сообщите об этой комнате своему поставщику учетной записи.",
"title": "Отклонить приглашение"
@@ -762,6 +774,7 @@
"backup_key_not_stored": "не сохранено",
"backup_key_stored": "в секретном хранилище",
"backup_key_stored_status": "Сохраненный резервный ключ:",
+ "backup_key_unexpected_type": "непредвиденный тип",
"backup_key_well_formed": "корректный",
"cross_signing": "Кросс-подпись",
"cross_signing_cached": "сохранено локально",
@@ -775,6 +788,7 @@
"cross_signing_status": "Статус кросс-подписи:",
"cross_signing_untrusted": "У вашей учётной записи есть кросс-подпись в секретное хранилище, но она пока не является доверенной в этом сеансе.",
"crypto_not_available": "Криптографический модуль недоступен",
+ "device_id": "Идентификатор устройства",
"key_backup_active_version": "Активная резервная версия:",
"key_backup_active_version_none": "Нет",
"key_backup_inactive_warning": "Резервное копирование ваших ключей из этого сеанса не выполняется.",
@@ -787,6 +801,8 @@
"secret_storage_ready": "готово",
"secret_storage_status": "Секретное хранилище:",
"self_signing_private_key_cached_status": "Самоподписанный закрытый ключ:",
+ "session": "Сессия",
+ "session_fingerprint": "Отпечаток пальца (ключ сессии)",
"title": "Сквозное шифрование",
"user_signing_private_key_cached_status": "Закрытый ключ подписи пользователей:"
},
@@ -812,6 +828,7 @@
"low_bandwidth_mode": "Режим низкой пропускной способности",
"low_bandwidth_mode_description": "Требуется совместимый сервер.",
"main_timeline": "Основная хронология",
+ "manual_device_verification": "Ручная проверка устройства",
"no_receipt_found": "Квитанция не найдена",
"notification_state": "Состояние уведомления %(notificationState)s",
"notifications_debug": "Отладка уведомлений",
@@ -955,13 +972,13 @@
},
"reset_all_button": "Забыли или потеряли все варианты восстановления? Сбросить всё",
"set_up_recovery": "Настроить восстановление",
- "set_up_recovery_later": "Не сейчас",
"set_up_recovery_toast_description": "Создайте ключ восстановления, который можно использовать для восстановления зашифрованной истории сообщений в случае потери доступа к своим устройствам.",
- "set_up_toast_description": "Защита от потери доступа к зашифрованным сообщениям и данным",
"set_up_toast_title": "Настроить безопасное резервное копирование",
"setup_secure_backup": {
"explainer": "Перед выходом сохраните резервную копию ключей шифрования, чтобы не потерять их."
},
+ "turn_on_key_storage": "Включить хранилище ключей",
+ "turn_on_key_storage_description": "Безопасно храните свои криптографические идентификационные данные и ключи сообщений на сервере. Это позволит вам просматривать историю сообщений на любых новых устройствах.",
"udd": {
"interactive_verification_button": "Интерактивная сверка по смайлам",
"other_ask_verify_text": "Попросите этого пользователя подтвердить сеанс или подтвердите его вручную ниже.",
@@ -996,6 +1013,20 @@
"incoming_sas_dialog_waiting": "Ожидаем подтверждения от партнера…",
"incoming_sas_user_dialog_text_1": "Проверить этого пользователя, чтобы отметить его, как доверенного. Доверенные пользователи дают вам больше уверенности при использовании шифрованных сообщений.",
"incoming_sas_user_dialog_text_2": "Подтверждение этого пользователя сделает его сеанс доверенным у вас, а также сделает ваш сеанс доверенным у него.",
+ "manual": {
+ "already_verified": "Это устройство уже проверено",
+ "already_verified_and_wrong_fingerprint": "Предоставленный отпечаток пальца не совпадает, но устройство уже проверено!",
+ "device_id": "Идентификатор устройства",
+ "failure_description": "Не удалось проверить '%(deviceId)s': %(error)s",
+ "failure_title": "Сбой проверки",
+ "fingerprint": "Отпечаток пальца (ключ сессии)",
+ "no_crypto": "Невозможно проверить устройство — шифрование не включено",
+ "no_device": "Не удалось проверить устройство — устройство %(deviceId)s '' не найдено",
+ "no_userid": "Невозможно проверить устройство — не удается найти идентификатор пользователя",
+ "success_description": "Теперь устройство (%(deviceId)s) имеет перекрестную подпись",
+ "success_title": "Проверка прошла успешно",
+ "text": "Предоставьте идентификатор и отпечаток пальца одного из ваших устройств, чтобы подтвердить это. ПРИМЕЧАНИЕ. Это позволяет другому устройству отправлять и получать сообщения так же, как и вы. ЕСЛИ КТО-ТО СКАЗАЛ ВАМ ЧТО-ТО ВСТАВИТЬ СЮДА, СКОРЕЕ ВСЕГО, ВАС ОБМАНУЛИ!"
+ },
"no_key_or_device": "Похоже, у вас нет Ключа Восстановления, или других сеансов, с которыми вы могли бы свериться. В этом сеансе вы не сможете получить доступ к старым зашифрованным сообщениям. Чтобы подтвердить свою личность в этом сеансе, вам нужно будет сбросить свои ключи шифрования.",
"no_support_qr_emoji": "Устройство, которое вы пытаетесь проверить, не поддерживает сканирование QR-кода или проверку смайликов, которые поддерживает %(brand)s. Попробуйте использовать другой клиент.",
"other_party_cancelled": "Другая сторона отменила проверку.",
@@ -1035,7 +1066,7 @@
"unverified_sessions_toast_description": "Проверьте, чтобы убедиться, что ваша учётная запись в безопасности",
"unverified_sessions_toast_reject": "Позже",
"unverified_sessions_toast_title": "У вас есть незаверенные сеансы",
- "verification_description": "Подтвердите свою личность, чтобы получить доступ к зашифрованным сообщениям и доказать свою личность другим.",
+ "verification_description": "Подтвердите свою личность, чтобы получить доступ к зашифрованным сообщениям и подтвердить свою личность другим. Если вы также используете мобильное устройство, откройте приложение там, прежде чем продолжить.",
"verification_dialog_title_device": "Проверить другое устройство",
"verification_dialog_title_user": "Запрос на сверку",
"verification_skip_warning": "Без проверки вы не сможете получить доступ ко всем своим сообщениям и можете показаться другим людям недоверенным.",
@@ -1054,8 +1085,10 @@
"waiting_other_user": "Ожидание %(displayName)s для проверки…"
},
"verification_requested_toast_title": "Запрошено подтверждение",
+ "verified_identity_changed": "Подтвержденная личность %(displayName)s (%(userId)s) изменилась. Узнайте больше",
"verify_toast_description": "Другие пользователи могут не доверять этому сеансу",
- "verify_toast_title": "Заверьте этот сеанс"
+ "verify_toast_title": "Заверьте этот сеанс",
+ "withdraw_verification_action": "Подтверждение верификации"
},
"error": {
"admin_contact": "Пожалуйста, обратитесь к вашему администратору, чтобы продолжить использовать этот сервис.",
@@ -1096,13 +1129,14 @@
"unknown_error_code": "неизвестный код ошибки",
"update_power_level": "Не удалось изменить уровень прав"
},
- "error_app_open_in_another_tab": "%(brand)s был открыт в другой вкладке.",
+ "error_app_open_in_another_tab": "Переключитесь на другую вкладку, чтобы подключиться к %(brand)s . Теперь эту вкладку можно закрыть.",
+ "error_app_open_in_another_tab_title": "%(brand)s подключен в другой вкладке",
"error_app_opened_in_another_window": "%(brand)s открыт в другом окне. Нажмите \"%(label)s\" чтобы использовать %(brand)s в данном окне и отключить другое.",
"error_database_closed_description": {
"for_desktop": "Возможно, ваш диск переполнен. Освободите место и перезагрузите компьютер.",
"for_web": "Если вы очистили данные браузера, то это сообщение ожидаемо. %(brand)s также может быть открыт в другой вкладке, или ваш диск заполнен. Пожалуйста, освободите место и перезагрузите"
},
- "error_database_closed_title": "База данных неожиданно закрылась",
+ "error_database_closed_title": "%(brand)s перестал работать",
"error_dialog": {
"copy_room_link_failed": {
"description": "Не удалось скопировать ссылку на комнату в буфер обмена.",
@@ -1141,7 +1175,8 @@
"image": "Изображение",
"poll": "Опрос",
"video": "Видео"
- }
+ },
+ "preview": "%(prefix)s: %(preview)s"
},
"export_chat": {
"cancelled": "Экспорт отменён",
@@ -1266,12 +1301,16 @@
},
"incompatible_browser": {
"continue": "Продолжить в любом случае",
+ "description": "%(brand)s использует некоторые функции браузера, которые недоступны в вашем текущем браузере. %(detail)s",
+ "detail_can_continue": "Если вы продолжите, некоторые функции могут перестать работать, и существует риск потери данных в будущем.",
"detail_no_continue": "Попробуйте обновить этот браузер, если вы используете не последнюю версию, и повторите попытку.",
"learn_more": "Подробнее",
"linux": "Linux",
"macos": "Mac",
+ "supported_browsers": "Для наилучшего впечатления используйте Chrome, Firefox, Edge или Safari.",
"title": "Неподдерживаемый браузер",
"use_desktop_heading": "Вместо этого используйте %(brand)s Desktop",
+ "use_mobile_heading": "Для этого используйте %(brand)s на мобильном телефоне",
"use_mobile_heading_after_desktop": "Или воспользуйтесь нашим мобильным приложением",
"windows_64bit": "Windows (64-бит)",
"windows_arm_64bit": "Windows (ARM 64-бит)"
@@ -1284,8 +1323,8 @@
"explainer": "Менеджеры по интеграции получают данные конфигурации и могут изменять виджеты, отправлять приглашения в комнаты и устанавливать уровни доступа от вашего имени.",
"manage_title": "Управление интеграциями",
"toggle_label": "Включить менеджер интеграции",
- "use_im": "Используйте менеджер интеграций для управления ботами, виджетами и наклейками.",
- "use_im_default": "Используйте менеджер интеграций %(serverName)s для управления ботами, виджетами и наклейками."
+ "use_im": "Используйте менеджер интеграций для управления ботами, виджетами и наборами стикеров.",
+ "use_im_default": "Используйте менеджер интеграций %(serverName)s для управления ботами, виджетами и наборами стикеров."
},
"integrations": {
"disabled_dialog_description": "Включите '%(manageIntegrations)s' в Настройках.",
@@ -1327,7 +1366,7 @@
"name_email_mxid_share_room": "Пригласите кого-нибудь, используя его имя, адрес электронной почты, имя пользователя (например, ) или поделитесь этой комнатой.",
"name_email_mxid_share_space": "Пригласите кого-нибудь, используя их имя, адрес электронной почты, имя пользователя (например, ) или поделитесь этим пространством.",
"name_mxid_share_room": "Пригласите кого-нибудь, используя его имя, имя пользователя (например, ) или поделитесь этой комнатой.",
- "name_mxid_share_space": "Пригласите кого-нибудь, используя их имя, учётную запись (как ) или поделитесь этим пространством.",
+ "name_mxid_share_space": "Пригласите кого-нибудь, используя их отображаемое имя или имя учётной записи (например, ) или поделитесь этим пространством.",
"recents_section": "Недавние Диалоги",
"room_failed_partial": "Мы отправили остальных, но нижеперечисленные люди не могут быть приглашены в ",
"room_failed_partial_title": "Некоторые приглашения не могут быть отправлены",
@@ -1641,6 +1680,7 @@
"class_global": "Глобально",
"class_other": "Другие",
"default": "По умолчанию",
+ "default_settings": "Соответствует настройкам по умолчанию",
"email_pusher_app_display_name": "Уведомления по электронной почте",
"enable_prompt_toast_description": "Включить уведомления на рабочем столе",
"enable_prompt_toast_title": "Уведомления",
@@ -1659,7 +1699,8 @@
"mentions_and_keywords_description": "Получать уведомления только по упоминаниям и ключевым словам, установленным в ваших настройках",
"mentions_keywords": "Упоминания и ключевые слова",
"message_didnt_send": "Сообщение не отправлено. Нажмите для получения информации.",
- "mute_description": "Вы не будете получать никаких уведомлений"
+ "mute_description": "Вы не будете получать никаких уведомлений",
+ "mute_room": "Заглушить комнату"
},
"notifier": {
"m.key.verification.request": "%(name)s запрашивает проверку"
@@ -1783,6 +1824,10 @@
"spam_or_propaganda": "Спам или пропаганда",
"toxic_behaviour": "Токсичное поведение"
},
+ "report_room": {
+ "description": "Сообщите об этой комнате вашему провайдеру аккаунта. Если сообщения зашифрованы, ваш администратор не сможет их прочитать.",
+ "reason_label": "Опишите причину"
+ },
"restore_key_backup_dialog": {
"count_of_decryption_failures": "Не удалось расшифровать сеансы (%(failedCount)s)!",
"count_of_successfully_restored_keys": "Успешно восстановлены ключи (%(sessionCount)s)",
@@ -1934,6 +1979,7 @@
},
"face_pile_tooltip_shortcut": "Включая %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Включая вас, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Не удается определить, какого пользователя следует игнорировать, так как событие участника изменилось.",
"failed_reject_invite": "Не удалось отклонить приглашение",
"forget_room": "Забыть эту комнату",
"forget_space": "Забыть это пространство",
@@ -2018,11 +2064,16 @@
"pinned_message_badge": "Закреплённое сообщение",
"pinned_message_banner": {
"button_close_list": "Закрыть список",
- "button_view_all": "Посмотреть все"
+ "button_view_all": "Посмотреть все",
+ "description": "В этой комнате есть закрепленные сообщения. Нажмите, чтобы просмотреть их.",
+ "go_to_message": "Показать прикрепленное сообщение на временной шкале.",
+ "title": "%(index)s из %(length)s Закрепленные сообщения"
},
"read_topic": "Нажмите, чтобы увидеть тему",
"rejecting": "Отклонение приглашения…",
"rejoin_button": "Пере-присоединение",
+ "room_content": "Содержимое комнаты",
+ "room_is_low_priority": "Это комната с низким приоритетом.",
"search": {
"all_rooms_button": "Поиск по всем комнатам",
"placeholder": "Поиск сообщений...",
@@ -2063,6 +2114,7 @@
},
"uploading_single_file": "Отправка %(filename)s"
},
+ "video_room": "Эта комната представляет собой видеозал",
"waiting_for_join_subtitle": "Как только приглашенные пользователи присоединятся %(brand)s, вы сможете общаться в чате, а комната будет полностью зашифрована",
"waiting_for_join_title": "Ожидание присоединения пользователей %(brand)s"
},
@@ -2071,27 +2123,36 @@
"add_space_label": "Добавить пространство",
"breadcrumbs_empty": "Нет недавно посещенных комнат",
"breadcrumbs_label": "Недавно посещённые комнаты",
+ "collapse_filters": "Свернуть список фильтров",
"empty": {
"no_chats": "Пока нет доступных чатов",
"no_chats_description": "Начните с отправки сообщений или создания комнаты",
"no_chats_description_no_room_rights": "Начните переписку с отправки сообщения",
"no_favourites": "У вас пока нет чатов в Избранное",
"no_favourites_description": "Вы можете добавить в Избранное в настройках чата",
+ "no_invites": "У вас нет непрочитанных приглашений",
+ "no_lowpriority": "У вас нет комнат с низким приоритетом",
+ "no_mentions": "У вас нет непрочитанных упоминаний",
"no_people": "У вас пока нет личных чатов",
"no_people_description": "Вы можете убрать фильтры, чтобы увидеть другие ваши чаты",
"no_rooms": "Вы еще не находитесь ни в одной комнате",
"no_rooms_description": "Вы можете убрать фильтры, чтобы увидеть другие ваши чаты",
"no_unread": "Поздравляю! У вас нет непрочитанных сообщений",
+ "show_activity": "Посмотреть всю активность",
"show_chats": "Показать все чаты"
},
+ "expand_filters": "Развернуть список фильтров",
"failed_add_tag": "Не удалось добавить тег %(tagName)s в комнату",
"failed_remove_tag": "Не удалось удалить тег %(tagName)s из комнаты",
"failed_set_dm_tag": "Не удалось установить метку личного сообщения",
"filters": {
"favourite": "Избранное",
+ "invites": "Приглашения",
+ "low_priority": "Низкий приоритет",
+ "mentions": "Упоминания",
"people": "Люди",
"rooms": "Комнаты",
- "unread": "Непрочитанное"
+ "unread": "Непрочитанные"
},
"home_menu_label": "Параметры раздела \"Главная\"",
"join_public_room_label": "Присоединиться к публичной комнате",
@@ -2115,7 +2176,8 @@
"other": "Удаляются сообщения в %(count)s комнатах"
},
"room": {
- "more_options": "Дополнительные параметры"
+ "more_options": "Дополнительные параметры",
+ "open_room": "Открыть комнату %(roomName)s"
},
"show_less": "Показать меньше",
"show_n_more": {
@@ -2123,9 +2185,14 @@
"one": "Показать ещё %(count)s"
},
"show_previews": "Показывать последнее сообщение",
+ "sort": "Сортировать",
"sort_by": "Сортировать",
"sort_by_activity": "По активности",
"sort_by_alphabet": "А-Я",
+ "sort_type": {
+ "activity": "Активность",
+ "atoz": "А-Я"
+ },
"sort_unread_first": "Комнаты с непрочитанными сообщениями в начале",
"space_menu_label": "Меню %(spaceName)s",
"sublist_options": "Настройки списка",
@@ -2198,6 +2265,8 @@
"error_deleting_alias_description": "Произошла ошибка при удалении этого адреса. Возможно, он больше не существует или произошла временная ошибка.",
"error_deleting_alias_description_forbidden": "У вас нет прав для удаления этого адреса.",
"error_deleting_alias_title": "Ошибка при удалении адреса",
+ "error_publishing": "Невозможно опубликовать комнату",
+ "error_publishing_detail": "Произошла ошибка при публикации этой комнаты",
"error_save_space_settings": "Не удалось сохранить настройки пространства.",
"error_updating_alias_description": "Произошла ошибка при обновлении альтернативных адресов комнаты. Это может быть запрещено сервером или произошел временный сбой.",
"error_updating_canonical_alias_description": "При обновлении основного адреса комнаты произошла ошибка. Возможно, это не разрешено сервером или произошел временный сбой.",
@@ -2357,7 +2426,7 @@
"public_without_alias_warning": "Для связи с этой комнатой, пожалуйста, добавьте адрес.",
"publish_room": "Сделать эту комнату видимой в каталоге общественных комнат.",
"publish_space": "Сделайте это пространство видимым в каталоге общественных комнат.",
- "strict_encryption": "Никогда не отправлять зашифрованные сообщения непроверенным сеансам в этой комнате и через этот сеанс",
+ "strict_encryption": "Отправляйте сообщения только проверенным пользователям.",
"title": "Безопасность"
},
"title": "Настройки комнаты — %(roomName)s",
@@ -2438,20 +2507,26 @@
},
"settings": {
"account": {
+ "dialog_title": "Настройки: Учетная запись",
"title": "Учетная запись"
},
"all_rooms_home": "Показывать все комнаты на Главной",
"all_rooms_home_description": "Все комнаты, в которых вы находитесь, будут отображаться на Главной.",
"always_show_message_timestamps": "Всегда показывать время отправки сообщений",
"appearance": {
+ "bundled_emoji_font": "Использовать встроенный шрифт эмодзи",
+ "compact_layout": "Показывать компактный текст и сообщения",
+ "compact_layout_description": "Для использования этой функции необходимо выбрать современный макет.",
"custom_font": "Использовать системный шрифт",
"custom_font_description": "Установите имя шрифта, установленного в вашей системе, и %(brand)s попытается его использовать.",
"custom_font_name": "Название системного шрифта",
"custom_font_size": "Использовать другой размер",
"custom_theme_add": "Добавить пользовательскую тему",
"custom_theme_downloading": "Загрузка пользовательской темы…",
- "custom_theme_error_downloading": "Ошибка при загрузке информации темы.",
+ "custom_theme_error_downloading": "Ошибка при загрузке темы",
+ "custom_theme_help": "Введите URL-адрес пользовательской темы, которую вы хотите применить.",
"custom_theme_invalid": "Неверная схема темы.",
+ "dialog_title": "Настройки: Внешний вид",
"font_size": "Размер шрифта",
"font_size_default": "%(fontSize)s (по умолчанию)",
"high_contrast": "Высокая контрастность",
@@ -2506,6 +2581,7 @@
"title": "Вы уверены, что хотите отключить хранение ключей и удалить их?"
},
"device_not_verified_button": "Проверить это устройство",
+ "device_not_verified_description": "Для просмотра настроек шифрования необходимо подтвердить это устройство.",
"device_not_verified_title": "Устройство не проверено",
"dialog_title": "Настройки: Шифрование",
"key_storage": {
@@ -2515,19 +2591,26 @@
},
"recovery": {
"change_recovery_confirm_button": "Подтвердите новый ключ восстановления",
+ "change_recovery_confirm_description": "Чтобы завершить, введите новый Ключ Восстановления ниже. Ваш старый ключ больше работать не будет.",
"change_recovery_confirm_title": "Введите новый ключ восстановления",
"change_recovery_key": "Изменить ключ восстановления",
+ "change_recovery_key_description": "Запишите новый ключ восстановления в безопасном месте. Затем нажмите «Продолжить», чтобы подтвердить изменение.",
"change_recovery_key_title": "Изменить ключ восстановления?",
+ "description": "Восстановите свою идентификацию и историю сообщений с помощью ключа восстановления, если вы потеряли все существующие устройства.",
"enter_key_error": "Ключ восстановления, который вы ввел, неверный.",
"enter_recovery_key": "Введите ключ восстановления",
"forgot_recovery_key": "Забыли ключ восстановления?",
+ "key_storage_warning": "Хранилище ключей не синхронизировано. Нажмите кнопку ниже, чтобы устранить проблему.",
"save_key_description": "Не сообщайте эту информацию никому!",
"save_key_title": "Ключ восстановления",
"set_up_recovery": "Настройка восстановления",
"set_up_recovery_confirm_button": "Завершить настройку",
+ "set_up_recovery_confirm_description": "Введите ключ восстановления, показанный на предыдущем экране, чтобы завершить настройку восстановления.",
"set_up_recovery_confirm_title": "Для подтверждения введите ключ восстановления",
+ "set_up_recovery_description": "Хранилище ключей защищено ключом восстановления. Если после установки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав '%(changeRecoveryKeyButton)s'.",
"set_up_recovery_save_key_description": "Запишите ключ восстановления в безопасном месте, например в диспетчере паролей, зашифрованной заметке или физическом сейфе.",
"set_up_recovery_save_key_title": "Сохраните ключ восстановления в безопасном месте",
+ "set_up_recovery_secondary_description": "После нажатия кнопки «Продолжить» мы сгенерируем для вас ключ восстановления.",
"title": "Восстановление"
},
"title": "Шифрование"
@@ -2609,6 +2692,7 @@
"password_change_success": "Ваш пароль успешно изменён.",
"personal_info": "Личная информация",
"profile_subtitle": "Так вас видят другие пользователи приложения.",
+ "profile_subtitle_oidc": "Ваша учетная запись управляется отдельным поставщиком идентификационных данных, поэтому некоторые ваши личные данные изменить нельзя.",
"remove_email_prompt": "Удалить %(email)s?",
"remove_msisdn_prompt": "Удалить %(phone)s?",
"spell_check_locale_placeholder": "Выберите регион",
@@ -2622,11 +2706,6 @@
"insert_trailing_colon_mentions": "Вставлять двоеточие после упоминания пользователя в начале сообщения",
"jump_to_bottom_on_send": "Перейти к нижней части временной шкалы, когда вы отправляете сообщение",
"key_backup": {
- "backup_in_progress": "Выполняется резервная копия ключей (первый раз это может занять несколько минут).",
- "backup_starting": "Запуск резервного копирования…",
- "backup_success": "Успешно!",
- "cannot_create_backup": "Невозможно создать резервную копию ключа",
- "create_title": "Создать резервную копию ключа",
"setup_secure_backup": {
"backup_setup_success_description": "Выполняется резервное копирование ваших ключей с этого устройства.",
"backup_setup_success_title": "Резервное копирование успешно завершено",
@@ -2638,7 +2717,7 @@
"enter_phrase_title": "Введите секретную фразу",
"enter_phrase_to_confirm": "Введите секретную фразу второй раз, чтобы подтвердить ее.",
"generate_security_key_description": "Мы создадим ключ восстановления, который вы сможете хранить в безопасном месте, например в менеджере паролей или сейфе.",
- "generate_security_key_title": "Создание ключа безопасности",
+ "generate_security_key_title": "Создание Ключа Восстановления",
"pass_phrase_match_failed": "Они не совпадают.",
"pass_phrase_match_success": "Они совпадают!",
"phrase_strong_enough": "Отлично! Эта контрольная фраза выглядит достаточно сильной.",
@@ -2647,11 +2726,11 @@
"set_phrase_again": "Задать другой пароль.",
"settings_reminder": "Вы также можете настроить безопасное резервное копирование и управлять своими ключами в настройках.",
"title_confirm_phrase": "Подтвердите секретную фразу",
- "title_save_key": "Сохраните свой ключ безопасности",
+ "title_save_key": "Сохраните ключ восстановления",
"title_set_phrase": "Задайте секретную фразу",
"unable_to_setup": "Невозможно настроить секретное хранилище",
"use_different_passphrase": "Использовать другую кодовую фразу?",
- "use_phrase_only_you_know": "Используйте секретную фразу, известную только вам, и при необходимости сохраните ключ безопасности для резервного копирования."
+ "use_phrase_only_you_know": "Используйте секретную фразу, известную только вам, и при необходимости сохраните Ключ Восстановления от резервной копии."
}
},
"key_export_import": {
@@ -2678,6 +2757,13 @@
"labs_mjolnir": {
"dialog_title": "Настройки: Игнорируемые пользователи"
},
+ "media_preview": {
+ "hide_avatars": "Скрыть аватары комнаты и приглашающего",
+ "hide_media": "Всегда скрывать",
+ "show_in_private": "В личных комнатах",
+ "show_media": "Всегда показывать"
+ },
+ "not_supported": "На вашем сервере эта функция не реализована.",
"notifications": {
"default_setting_description": "Эта настройка будет применена по умолчанию ко всем вашим комнатам.",
"default_setting_section": "Я хочу получать уведомления о (настройка по умолчанию)",
@@ -2741,6 +2827,7 @@
"code_blocks_heading": "Блоки кода",
"compact_modern": "Использовать более компактный \"Современный\" макет",
"composer_heading": "Редактор",
+ "default_timezone": "Браузер по умолчанию (%(timezone)s)",
"dialog_title": "Настройки: Параметры",
"enable_hardware_acceleration": "Включить аппаратное ускорение",
"enable_tray_icon": "Показывать значок в трее и сворачивать в него окно при закрытии",
@@ -2766,6 +2853,7 @@
"bulk_options_accept_all_invites": "Принять все приглашения (%(invitedRooms)s)",
"bulk_options_reject_all_invites": "Отклонить все %(invitedRooms)s приглашения",
"bulk_options_section": "Основные опции",
+ "dehydrated_device_description": "Функция автономного устройства позволяет вам получать зашифрованные сообщения, даже если вы не вошли в систему ни на одном устройстве.",
"dehydrated_device_enabled": "Устройство в автономном режиме",
"dialog_title": "Настройки: Безопасность и конфиденциальность",
"e2ee_default_disabled_warning": "Администратор вашего сервера отключил сквозное шифрование по умолчанию в приватных комнатах и диалогах.",
@@ -2774,7 +2862,6 @@
"ignore_users_empty": "У вас нет игнорируемых пользователей.",
"ignore_users_section": "Игнорируемые пользователи",
"key_backup_algorithm": "Алгоритм:",
- "key_backup_connect": "Подключить этот сеанс к резервированию ключей",
"message_search_disable_warning": "Если этот параметр отключен, сообщения из зашифрованных комнат не будут отображаться в результатах поиска.",
"message_search_disabled": "Безопасно кэшировать шифрованные сообщения локально, чтобы они появлялись в результатах поиска.",
"message_search_enabled": {
@@ -2912,6 +2999,7 @@
"show_chat_effects": "Эффекты (анимация при получении, например, конфетти)",
"show_displayname_changes": "Изменения отображаемого имени",
"show_join_leave": "Сообщения о присоединении/покидании (приглашения/удаления/блокировки не затрагиваются)",
+ "show_message_previews": "Показать предварительный просмотр сообщений",
"show_nsfw_content": "Показать NSFW-контент",
"show_read_receipts": "Уведомления о прочтении другими пользователями",
"show_redaction_placeholder": "Плашки вместо удалённых сообщений",
@@ -3281,6 +3369,7 @@
"download_action_decrypting": "Расшифровка",
"download_action_downloading": "Загрузка",
"download_failed": "Загрузка не удалась",
+ "download_failed_description": "Произошла ошибка при загрузке этого файла",
"e2e_state": "Состояние сквозного шифрования",
"edits": {
"tooltip_label": "Изменено %(date)s. Нажмите для посмотра истории изменений.",
@@ -3436,6 +3525,7 @@
"left_reason": "%(targetName)s покинул(а) комнату: %(reason)s",
"no_change": "%(senderName)s не сделал(а) изменений",
"reject_invite": "%(targetName)s отклонил(а) приглашение",
+ "reject_invite_reason": "%(targetName)s отклонил приглашение: %(reason)s",
"remove_avatar": "%(senderName)s удалил(а) аватар",
"remove_name": "%(senderName)s удалил(а) отображаемое имя (%(oldDisplayName)s)",
"set_avatar": "%(senderName)s установил(а) аватар",
@@ -3472,9 +3562,10 @@
},
"m.room.tombstone": "%(senderDisplayName)s обновил(а) эту комнату.",
"m.room.topic": {
- "changed": "%(senderDisplayName)s изменил(а) тему комнаты на \"%(topic)s\"."
+ "changed": "%(senderDisplayName)s изменил(а) тему комнаты на \"%(topic)s\".",
+ "removed": "%(senderDisplayName)s удалил тему."
},
- "m.sticker": "%(senderDisplayName)s отправил(а) наклейку.",
+ "m.sticker": "%(senderDisplayName)s отправил(а) стикер.",
"m.video": {
"error_decrypting": "Ошибка расшифровки видео"
},
@@ -3524,7 +3615,8 @@
"reactions": {
"add_reaction_prompt": "Отреагировать",
"custom_reaction_fallback_label": "Пользовательская реакция",
- "label": "%(reactors)s отреагировали %(content)s"
+ "label": "%(reactors)s отреагировали %(content)s",
+ "tooltip_caption": "отреагировал с %(shortName)s"
},
"read_receipt_title": {
"one": "Просмотрел %(count)s человек",
@@ -3713,6 +3805,10 @@
"few": "И еще %(count)s...",
"many": "И еще %(count)s..."
},
+ "unsupported_browser": {
+ "description": "Если вы продолжите, некоторые функции могут перестать работать, и существует риск потери данных в будущем. Обновите браузер, чтобы продолжить использование %(brand)s.",
+ "title": "%(brand)s не поддерживает этот браузер"
+ },
"unsupported_server_description": "На этом сервере используется старая версия Matrix. Перейдите на Matrix%(version)s, чтобы использовать %(brand)s ее без ошибок.",
"unsupported_server_title": "Ваш сервер не поддерживается",
"update": {
@@ -3730,6 +3826,12 @@
"toast_title": "Обновление %(brand)s",
"unavailable": "Недоступен"
},
+ "update_room_access_modal": {
+ "description": "Чтобы создать ссылку для совместного доступа, сделайте эту комнату общедоступной и разрешите пользователям запрашивать присоединение. Это позволит гостям присоединиться без приглашения.",
+ "dont_change_description": "Кроме того, вы можете провести звонок в отдельной комнате.",
+ "no_change": "Я не хочу менять уровень доступа.",
+ "title": "Изменить уровень доступа в комнату"
+ },
"upload_failed_generic": "Файл '%(fileName)s' не был загружен.",
"upload_failed_size": "Размер файла '%(fileName)s' превышает допустимый предел загрузки, установленный на этом сервере",
"upload_failed_title": "Сбой отправки файла",
@@ -3739,6 +3841,7 @@
"error_files_too_large": "Эти файлы слишком большие для загрузки. Лимит размера файла составляет %(limit)s.",
"error_some_files_too_large": "Некоторые файлы имеют слишком большой размер, чтобы их можно было загрузить. Лимит размера файла составляет %(limit)s.",
"error_title": "Ошибка загрузки",
+ "not_image": "Выбранный вами файл не является изображением.",
"title": "Загрузка файлов",
"title_progress": "Загрузка файлов (%(current)s из %(total)s)",
"upload_all_button": "Загрузить всё",
@@ -3758,7 +3861,7 @@
"deactivate_confirm_description": "Деактивация этого пользователя приведет к его выходу из системы и запрету повторного входа. Кроме того, они оставит все комнаты, в которых он участник. Это действие безповоротно. Вы уверены, что хотите деактивировать этого пользователя?",
"deactivate_confirm_title": "Деактивировать пользователя?",
"demote_button": "Понижение",
- "demote_self_confirm_description_space": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии вбудущем.",
+ "demote_self_confirm_description_space": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии в будущем.",
"demote_self_confirm_room": "После понижения своих привилегий вы не сможете это отменить. Если вы являетесь последним привилегированным пользователем в этой комнате, выдать права кому-либо заново будет невозможно.",
"demote_self_confirm_title": "Понизить самого себя?",
"disinvite_button_room": "Отозвать приглашение в комнату",
@@ -3770,10 +3873,11 @@
"error_mute_user": "Не удалось заглушить пользователя",
"error_revoke_3pid_invite_description": "Не удалось отозвать приглашение. Возможно, на сервере возникла вре́менная проблема или у вас недостаточно прав для отзыва приглашения.",
"error_revoke_3pid_invite_title": "Не удалось отменить приглашение",
+ "ignore_button": "Игнорировать",
"ignore_confirm_description": "Все сообщения и приглашения от этого пользователя будут скрыты. Вы действительно хотите их игнорировать?",
"ignore_confirm_title": "Игнорировать %(user)s",
"invited_by": "Приглашен %(sender)s",
- "jump_to_rr_button": "Перейти к последнему прочтённому",
+ "jump_to_rr_button": "Перейти к последнему прочитанному сообщению",
"kick_button_room": "Удалить из комнаты",
"kick_button_room_name": "Удалить из %(roomName)s",
"kick_button_space": "Исключить из пространства",
@@ -3797,19 +3901,22 @@
"no_recent_messages_description": "Попробуйте пролистать ленту сообщений вверх, чтобы увидеть, есть ли более ранние.",
"no_recent_messages_title": "Последние сообщения от %(user)s не найдены"
},
- "redact_button": "Удалить последние сообщения",
+ "redact_button": "Удалить сообщения",
"revoke_invite": "Отозвать приглашение",
"room_encrypted": "Сообщения в этой комнате защищены сквозным шифрованием.",
"room_encrypted_detail": "Ваши сообщения в безопасности, ключи для расшифровки есть только у вас и получателя.",
"room_unencrypted": "Сообщения в этой комнате не защищены сквозным шифрованием.",
"room_unencrypted_detail": "В зашифрованных комнатах ваши сообщения в безопасности: только у вас и у получателя есть ключи для расшифровки.",
- "share_button": "Поделиться ссылкой на пользователя",
+ "send_message": "Отправить сообщение",
+ "share_button": "Поделиться профилем",
"unban_button_room": "Разблокировать в комнате",
"unban_button_space": "Разблокировать в пространстве",
"unban_room_confirm_title": "Разблокировать в %(roomName)s",
"unban_space_everything": "Разблокировать их везде, где я могу это сделать",
"unban_space_specific": "Разблокировать их из определённых мест, где я могу это сделать",
"unban_space_warning": "Они не смогут получить доступ к тем местам, где вы не являетесь администратором.",
+ "unignore_button": "Не игнорировать",
+ "verification_unavailable": "Проверка пользователя недоступна",
"verify_button": "Подтвердить пользователя",
"verify_explainer": "Для дополнительной безопасности подтвердите этого пользователя, сравнив одноразовый код на ваших устройствах."
},
@@ -3838,6 +3945,7 @@
"camera_disabled": "Ваша камера выключена",
"camera_enabled": "Ваша камера всё ещё включена",
"cannot_call_yourself_description": "Вы не можете позвонить самому себе.",
+ "close_lobby": "Закрыть лобби",
"connecting": "Подключение",
"connection_lost": "Соединение с сервером потеряно",
"connection_lost_description": "Вы не можете совершать вызовы без подключения к серверу.",
@@ -3851,14 +3959,23 @@
"disabled_no_perms_start_video_call": "У вас нет разрешения для запуска видеозвонка",
"disabled_no_perms_start_voice_call": "У вас нет разрешения для запуска звонка",
"disabled_ongoing_call": "Текущий звонок",
+ "element_call": "Element Call",
"enable_camera": "Включить камеру",
"enable_microphone": "Включить микрофон",
"expand": "Вернуться к звонку",
+ "get_call_link": "Поделиться ссылкой на звонок",
"hangup": "Повесить трубку",
"hide_sidebar_button": "Скрыть боковую панель",
"input_devices": "Устройства ввода",
+ "jitsi_call": "Конференция Jitsi",
"join_button_tooltip_call_full": "Извините — этот вызов в настоящее время заполнен",
+ "legacy_call": "Звонок (устаревший)",
"maximise": "Заполнить экран",
+ "maximise_call": "Развернуть звонок",
+ "metaspace_video_rooms": {
+ "conference_room_section": "Конференции"
+ },
+ "minimise_call": "Свернуть звонок",
"misconfigured_server": "Вызов не состоялся из-за неправильно настроенного сервера",
"misconfigured_server_description": "Попросите администратора вашего домашнего сервера (%(homeserverDomain)s) настроить сервер TURN для надежной работы звонков.",
"misconfigured_server_fallback": "В качестве альтернативы вы можете попробовать использовать общедоступный сервер по адресу , но он не будет таким надежным, и ваш IP-адрес будет передаваться на сервер. Вы также можете управлять этим в настройках.",
@@ -3906,6 +4023,7 @@
"user_is_presenting": "%(sharerName)s показывает",
"video_call": "Видеовызов",
"video_call_started": "Начался видеозвонок",
+ "video_call_using": "Видеозвонок с использованием:",
"voice_call": "Голосовой вызов",
"you_are_presenting": "Вы представляете"
},
@@ -4005,7 +4123,7 @@
"error_need_to_be_logged_in": "Вы должны войти в систему.",
"error_unable_start_audio_stream_description": "Невозможно запустить аудио трансляцию.",
"error_unable_start_audio_stream_title": "Не удалось запустить прямую трансляцию",
- "modal_data_warning": "Данные на этом экране используются %(widgetDomain)s",
+ "modal_data_warning": "Приведенные ниже данные передаются %(widgetDomain)s",
"modal_title_default": "Модальный виджет",
"no_name": "Неизвестное приложение",
"open_id_permissions_dialog": {
@@ -4014,7 +4132,7 @@
"title": "Разрешите этому виджету проверить ваш идентификатор"
},
"popout": "Всплывающий виджет",
- "set_room_layout": "Установить мой макет комнаты для всех",
+ "set_room_layout": "Установить макет для всех",
"shared_data_avatar": "URL-адрес изображения вашего профиля",
"shared_data_device_id": "Идентификатор вашего устройства",
"shared_data_lang": "Ваш язык",
diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json
index 08fc2c7d5f..3fa8b7c52e 100644
--- a/src/i18n/strings/sk.json
+++ b/src/i18n/strings/sk.json
@@ -244,8 +244,7 @@
"setup_key_backup_title": "Stratíte prístup ku zašifrovaným správam",
"setup_secure_backup_description_1": "Šifrované správy sú zabezpečené end-to-end šifrovaním. Kľúče na čítanie týchto správ máte len vy a príjemca (príjemcovia).",
"setup_secure_backup_description_2": "Po odhlásení sa tieto kľúče z tohto zariadenia vymažú, čo znamená, že nebudete môcť čítať zašifrované správy, pokiaľ k nim nemáte kľúče v iných zariadeniach alebo ich nemáte zálohované na serveri.",
- "skip_key_backup": "Nezáleží mi na zašifrovaných správach",
- "use_key_backup": "Začnite používať zálohovanie kľúčov"
+ "skip_key_backup": "Nezáleží mi na zašifrovaných správach"
},
"misconfigured_body": "Požiadajte správcu vášho %(brand)su, aby skontroloval vašu konfiguráciu. Pravdepodobne obsahuje chyby alebo duplikáty.",
"misconfigured_title": "Váš %(brand)s nie je nastavený správne",
@@ -793,6 +792,7 @@
"cross_signing_status": "Stav krížového podpisovania:",
"cross_signing_untrusted": "Váš účet má identitu s krížovým podpisom v tajnom úložisku, ale táto relácia mu zatiaľ nedôveruje.",
"crypto_not_available": "Kryptografický modul nie je k dispozícii",
+ "device_id": "ID zariadenia",
"key_backup_active_version": "Aktívna verzia zálohy:",
"key_backup_active_version_none": "Žiadna",
"key_backup_inactive_warning": "Vaše kľúče nie sú zálohované z tejto relácie.",
@@ -805,6 +805,8 @@
"secret_storage_ready": "pripravený",
"secret_storage_status": "Tajné úložisko:",
"self_signing_private_key_cached_status": "Súkromný kľúč s vlastným podpisom:",
+ "session": "Relácia",
+ "session_fingerprint": "Odtlačok prsta (kľúč relácie)",
"title": "Šifrovanie typu end-to-end",
"user_signing_private_key_cached_status": "Súkromný kľúč podpisovania používateľa:"
},
@@ -830,6 +832,7 @@
"low_bandwidth_mode": "Režim nízkej šírky pásma",
"low_bandwidth_mode_description": "Vyžaduje kompatibilný domovský server.",
"main_timeline": "Hlavná časová os",
+ "manual_device_verification": "Manuálne overenie zariadenia",
"no_receipt_found": "Nenašlo sa žiadne potvrdenie",
"notification_state": "Stav oznámenia je %(notificationState)s",
"notifications_debug": "Ladenie oznámení",
@@ -973,9 +976,7 @@
},
"reset_all_button": "Zabudli ste alebo ste stratili všetky metódy obnovy? Resetovať všetko",
"set_up_recovery": "Nastaviť obnovenie",
- "set_up_recovery_later": "Teraz nie",
"set_up_recovery_toast_description": "Vytvorte kľúč na obnovenie, ktorý môžete použiť na obnovenie histórie šifrovaných správ v prípade straty prístupu k zariadeniam.",
- "set_up_toast_description": "Zabezpečte sa proti strate šifrovaných správ a údajov",
"set_up_toast_title": "Nastaviť bezpečné zálohovanie",
"setup_secure_backup": {
"explainer": "Zálohujte si šifrovacie kľúče pred odhlásením, aby ste zabránili ich strate."
@@ -1016,6 +1017,21 @@
"incoming_sas_dialog_waiting": "Čakanie na potvrdenie od partnera…",
"incoming_sas_user_dialog_text_1": "Overte tohto používateľa a označte ho ako dôveryhodného. Dôveryhodní používatelia vám poskytujú dodatočný pokoj na duši pri používaní end-to-end šifrovaných správ.",
"incoming_sas_user_dialog_text_2": "Overenie tohto používateľa označí jeho reláciu ako dôveryhodnú a zároveň označí vašu reláciu ako dôveryhodnú pre neho.",
+ "manual": {
+ "already_verified": "Toto zariadenie je už overené",
+ "already_verified_and_wrong_fingerprint": "Zadaný odtlačok prsta sa nezhoduje, ale zariadenie je už overené!",
+ "device_id": "ID zariadenia",
+ "failure_description": "Nepodarilo sa overiť '%(deviceId)s': %(error)s",
+ "failure_title": "Overenie zlyhalo",
+ "fingerprint": "Odtlačok prsta (kľúč relácie)",
+ "no_crypto": "Nie je možné overiť zariadenie - kryptografia nie je povolená",
+ "no_device": "Zariadenie sa nepodarilo overiť - zariadenie '%(deviceId)s' nebolo nájdené",
+ "no_userid": "Nie je možné overiť zariadenie - nepodarilo sa nájsť naše ID používateľa",
+ "success_description": "Zariadenie (%(deviceId)s ) je teraz krížovo podpísané",
+ "success_title": "Overenie úspešné",
+ "text": "Na overenie zadajte ID a odtlačok jedného z vašich vlastných zariadení. POZNÁMKA: toto umožňuje druhému zariadeniu odosielať a prijímať správy ako vy. AK VÁM NIEKTO POVEDAL, ABY STE SEM NIEČO VLOŽILI, JE PRAVDEPODOBNÉ, ŽE IDE O PODVOD!",
+ "wrong_fingerprint": "Nie je možné overiť zariadenie '%(deviceId)s' - dodaný odtlačok prsta '%(fingerprint)s' sa nezhoduje s odtlačkom prsta zariadenia, '%(fprint)s'"
+ },
"no_key_or_device": "Vyzerá to, že nemáte kľúč na obnovenie ani žiadne iné zariadenie, pomocou ktorého by ste to mohli overiť. Toto zariadenie nebude mať prístup k starým zašifrovaným správam. Ak chcete overiť svoju totožnosť na tomto zariadení, budete musieť obnoviť svoje overovacie kľúče.",
"no_support_qr_emoji": "Zariadenie, ktoré sa snažíte overiť, nepodporuje overenie skenovaním QR kódu ani overenie pomocou emotikonov, ktoré podporuje aplikácia %(brand)s. Skúste použiť iného klienta.",
"other_party_cancelled": "Proti strana zrušila overovanie.",
@@ -1981,6 +1997,7 @@
},
"face_pile_tooltip_shortcut": "Vrátane %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Vrátane vás, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Nie je možné určiť, ktorého používateľa ignorovať, pretože sa zmenila udalosť člena.",
"failed_reject_invite": "Nepodarilo sa odmietnuť pozvanie",
"forget_room": "Zabudnúť túto miestnosť",
"forget_space": "Zabudnúť tento priestor",
@@ -2073,6 +2090,8 @@
"read_topic": "Kliknutím si prečítate tému",
"rejecting": "Odmietnutie pozvania …",
"rejoin_button": "Znovu sa pripojiť",
+ "room_content": "Obsah miestnosti",
+ "room_is_low_priority": "Toto je miestnosť s nízkou prioritou",
"search": {
"all_rooms_button": "Vyhľadávať vo všetkých miestnostiach",
"placeholder": "Hľadať správy…",
@@ -2124,6 +2143,7 @@
"add_space_label": "Pridať priestor",
"breadcrumbs_empty": "Žiadne nedávno navštívené miestnosti",
"breadcrumbs_label": "Nedávno navštívené miestnosti",
+ "collapse_filters": "Zbaliť zoznam filtrov",
"empty": {
"no_chats": "Zatiaľ žiadne konverzácie",
"no_chats_description": "Začnite tým, že niekomu napíšete správu alebo vytvoríte miestnosť",
@@ -2131,6 +2151,7 @@
"no_favourites": "Zatiaľ nemáte obľúbenú konverzáciu",
"no_favourites_description": "V nastaveniach konverzácií môžete pridať konverzáciu medzi obľúbené",
"no_invites": "Nemáte žiadne neprečítané pozvánky",
+ "no_lowpriority": "Nemáte žiadne miestnosti s nízkou prioritou",
"no_mentions": "Nemáte žiadne neprečítané zmienky",
"no_people": "Zatiaľ s nikým nemáte priame konverzácie",
"no_people_description": "Môžete zrušiť výber filtrov, aby ste videli svoje ostatné konverzácie",
@@ -2140,6 +2161,7 @@
"show_activity": "Zobraziť všetku aktivitu",
"show_chats": "Zobraziť všetky konverzácie"
},
+ "expand_filters": "Rozbaliť zoznam filtrov",
"failed_add_tag": "Miestnosti sa nepodarilo pridať značku %(tagName)s",
"failed_remove_tag": "Z miestnosti sa nepodarilo odstrániť značku %(tagName)s",
"failed_set_dm_tag": "Nepodarilo sa nastaviť značku priamej správy",
@@ -2720,13 +2742,11 @@
"inline_url_previews_room": "Predvolene povoliť náhľady URL adries pre členov tejto miestnosti",
"inline_url_previews_room_account": "Povoliť náhľady URL adries pre túto miestnosť (ovplyvňuje len vás)",
"insert_trailing_colon_mentions": "Vložiť na koniec dvojbodku za zmienkou používateľa na začiatku správy",
+ "invite_controls": {
+ "default_label": "Povoliť používateľom pozývať vás do miestností"
+ },
"jump_to_bottom_on_send": "Skok na koniec časovej osi pri odosielaní správy",
"key_backup": {
- "backup_in_progress": "Zálohovanie kľúčov máte aktívne (prvé zálohovanie môže trvať niekoľko minút).",
- "backup_starting": "Začína sa zálohovanie…",
- "backup_success": "Úspech!",
- "cannot_create_backup": "Nie je možné vytvoriť zálohu šifrovacích kľúčov",
- "create_title": "Vytvoriť zálohu kľúča",
"setup_secure_backup": {
"backup_setup_success_description": "Kľúče sa teraz zálohujú z tohto zariadenia.",
"backup_setup_success_title": "Bezpečné zálohovanie bolo úspešné",
@@ -2786,6 +2806,7 @@
"show_in_private": "V súkromných miestnostiach",
"show_media": "Vždy zobraziť"
},
+ "not_supported": "Váš server túto funkciu nepodporuje.",
"notifications": {
"default_setting_description": "Toto nastavenie sa predvolene použije pre všetky vaše miestnosti.",
"default_setting_section": "Chcem byť upozornený na (predvolené nastavenie)",
@@ -2843,6 +2864,7 @@
"voip": "Zvukové a video hovory"
},
"preferences": {
+ "Electron.enableContentProtection": "Zabrániť zachytávaniu obsahu okna inými aplikáciami",
"Electron.enableHardwareAcceleration": "Povoliť hardvérovú akceleráciu (reštartujte aplikáciu %(appName)s, aby sa prejavila)",
"always_show_menu_bar": "Vždy zobraziť hornú lištu okna",
"autocomplete_delay": "Oneskorenie automatického dokončovania (ms)",
@@ -2851,6 +2873,7 @@
"composer_heading": "Písanie správ",
"default_timezone": "Predvolená (%(timezone)s) prehliadača",
"dialog_title": "Nastavenia: Predvoľby",
+ "enable_content_protection": "Povoliť ochranu obsahu",
"enable_hardware_acceleration": "Povoliť hardvérovú akceleráciu",
"enable_tray_icon": "Zobraziť ikonu na lište a pri zatvorení minimalizovať okno na ňu",
"keyboard_heading": "Klávesové skratky",
@@ -2884,7 +2907,6 @@
"ignore_users_empty": "Nemáte žiadnych ignorovaných používateľov.",
"ignore_users_section": "Ignorovaní používatelia",
"key_backup_algorithm": "Algoritmus:",
- "key_backup_connect": "Pripojiť túto reláciu k Zálohe kľúčov",
"message_search_disable_warning": "Ak nie je povolené, správy zo zašifrovaných miestností sa nezobrazia vo výsledkoch vyhľadávania.",
"message_search_disabled": "Bezpečne lokálne ukladať zašifrované správy do vyrovnávacej pamäte, aby sa zobrazovali vo výsledkoch vyhľadávania.",
"message_search_enabled": {
@@ -3018,6 +3040,7 @@
"show_chat_effects": "Zobraziť efekty konverzácie (animácie pri prijímaní napr. konfety)",
"show_displayname_changes": "Zobrazovať zmeny zobrazovaného mena",
"show_join_leave": "Zobraziť správy o pripojení/odchode (pozvania/odstránenia/zákazy nie sú ovplyvnené)",
+ "show_message_previews": "Zobraziť náhľady správ",
"show_nsfw_content": "Zobraziť obsah NSFW",
"show_read_receipts": "Zobrazovať potvrdenia o prečítaní od ostatných používateľov",
"show_redaction_placeholder": "Zobrazovať náhrady za odstránené správy",
@@ -3124,6 +3147,8 @@
"jumptodate": "Prejsť na zadaný dátum na časovej osi",
"jumptodate_invalid_input": "Nepodarilo sa nám rozpoznať zadaný dátum (%(inputDate)s). Skúste použiť formát RRRR-MM-DD.",
"lenny": "Pridá znaky ( ͡° ͜ʖ ͡°) pred správy vo formáte obyčajného textu",
+ "manual_device_verification_confirm_description": "Toto umožní inému zariadeniu odosielať a prijímať správy ako keby ste to boli vy. AK VÁM NIEKTO POVEDAL, ABY STE SEM NIEČO VLOŽILI, JE PRAVDEPODOBNÉ, ŽE IDE PO PODVOD! Ste si istí, že chcete overiť toto iné zariadenie?",
+ "manual_device_verification_confirm_title": "Upozornenie: ručné overenie zariadenia",
"me": "Zobrazí akciu",
"msg": "Pošle správu danému používateľovi",
"myavatar": "Zmení váš profilový obrázok vo všetkých miestnostiach",
@@ -3164,7 +3189,7 @@
"upgraderoom": "Aktualizuje miestnosť na novšiu verziu",
"upgraderoom_permission_error": "Na použitie tohoto príkazu nemáte dostatočné povolenia.",
"usage": "Použitie",
- "verify": "Overí používateľa, reláciu a verejné kľúče",
+ "verify": "Manuálne overte jedno z vašich vlastných zariadení",
"view": "Zobrazí miestnosti s danou adresou",
"whois": "Zobrazuje informácie o používateľovi"
},
diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json
index 9637f7a820..7bbfa91734 100644
--- a/src/i18n/strings/sq.json
+++ b/src/i18n/strings/sq.json
@@ -209,8 +209,7 @@
"setup_key_backup_title": "Do të humbni hyrje te mesazhet tuaj të fshehtëzuar",
"setup_secure_backup_description_1": "Mesazhet e fshehtëzuar sigurohen me fshehtëzim skaj-më-skaj. Vetëm ju dhe marrësi(t) kanë kyçet për të lexuar këto mesazhe.",
"setup_secure_backup_description_2": "Kur dilni nga llogaria, këto kyçe do të fshihen te kjo pajisje, që do të thotë se s’do të jeni në gjendje të lexoni mesazhe të fshehtëzuar, veç në paçi kyçet për ta në pajisjet tuaja të tjera, ose të kopjeruajtur te shërbyesi.",
- "skip_key_backup": "Nuk i dua mesazhet e mia të fshehtëzuar",
- "use_key_backup": "Fillo të përdorësh Kopjeruajtje Kyçesh"
+ "skip_key_backup": "Nuk i dua mesazhet e mia të fshehtëzuar"
},
"misconfigured_body": "Kërkojini përgjegjësit të %(brand)s-it tuaj të kontrollojë formësimin tuaj për zëra të pasaktë ose të përsëdytur.",
"misconfigured_title": "%(brand)s-i juaj është i keqformësuar",
@@ -796,7 +795,6 @@
"warning": "Nëse metodën e re të rimarrjeve s’e keni hequr ju, dikush mund të jetë duke u rrekur të hyjë në llogarinë tuaj. Ndryshoni menjëherë fjalëkalimin e llogarisë tuaj, te Rregullimet, dhe caktoni një metodë të re rimarrjesh."
},
"reset_all_button": "Harruat, ose humbët krejt metodat e rimarrjes? Riujdisini të gjitha",
- "set_up_toast_description": "Mbrohuni nga humbja e hyrjes te mesazhe & të dhëna të fshehtëzuara",
"set_up_toast_title": "Ujdisni Kopjeruajtje të Sigurt",
"setup_secure_backup": {
"explainer": "Kopjeruajini kyçet tuaj, përpara se të dilni, që të shmangni humbjen e tyre."
@@ -2167,11 +2165,6 @@
"insert_trailing_colon_mentions": "Fut dy pika pas përmendjesh përdoruesi, në fillim të një mesazhi",
"jump_to_bottom_on_send": "Kalo te fundi i rrjedhës kohore, kur dërgoni një mesazh",
"key_backup": {
- "backup_in_progress": "Kyçet tuaj po kopjeruhen (kopjeruajtja e parë mund të hajë disa minuta).",
- "backup_starting": "Po fillohet kopjeruajtje…",
- "backup_success": "Sukses!",
- "cannot_create_backup": "S’arrihet të krijohet kopjeruajtje kyçesh",
- "create_title": "Krijo kopjeruajtje kyçesh",
"setup_secure_backup": {
"backup_setup_success_description": "Kyçet tuaj tani po kopjeruhen nga kjo pajisje.",
"backup_setup_success_title": "Kopjeruajtje e Sigurt e susksesshme",
@@ -2277,7 +2270,6 @@
"ignore_users_empty": "S’keni përdorues të shpërfillur.",
"ignore_users_section": "Përdorues të shpërfillur",
"key_backup_algorithm": "Algoritëm:",
- "key_backup_connect": "Lidhe këtë sesion me Kopjeruajtje Kyçesh",
"message_search_disable_warning": "Në u çaktivizoftë, mesazhet prej dhomash të fshehtëzuara s’do të duken në përfundime kërkimi.",
"message_search_disabled": "Ruaj lokalisht në mënyrë të sigurt në fshehtinë mesazhet që të shfaqen në përfundime kërkimi.",
"message_search_enabled": {
diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json
index 84deb17d98..17074afb88 100644
--- a/src/i18n/strings/sv.json
+++ b/src/i18n/strings/sv.json
@@ -157,6 +157,7 @@
"view_message": "Visa meddelande",
"view_source": "Visa källa",
"yes": "Ja",
+ "yes_dismiss": "Ja, avvisa",
"zoom_in": "Zooma in",
"zoom_out": "Zooma ut"
},
@@ -239,8 +240,7 @@
"setup_key_backup_title": "Du kommer att förlora åtkomst till dina krypterade meddelanden",
"setup_secure_backup_description_1": "Krypterade meddelanden är säkrade med totalsträckskryptering. Bara du och mottagaren/na har nycklarna för att läsa dessa meddelanden.",
"setup_secure_backup_description_2": "När du loggar ut kommer nycklarna att raderas från den här enheten, vilket betyder att du inte kommer kunna läsa krypterade meddelanden om du inte har nycklarna för dem på dina andra enheter, eller säkerhetskopierade dem till servern.",
- "skip_key_backup": "Jag vill inte ha mina krypterade meddelanden",
- "use_key_backup": "Börja använda nyckelsäkerhetskopiering"
+ "skip_key_backup": "Jag vill inte ha mina krypterade meddelanden"
},
"misconfigured_body": "Be din %(brand)s-administratör att kolla din konfiguration efter felaktiga eller duplicerade poster.",
"misconfigured_title": "Din %(brand)s är felkonfigurerad",
@@ -386,6 +386,7 @@
"fallback_button": "Starta autentisering",
"mas_cross_signing_reset_cta": "Gå till ditt konto",
"mas_cross_signing_reset_description": "Återställ din identitet via din kontoleverantör och kom sedan tillbaka och klicka på ”Försök igen”.",
+ "mas_cross_signing_reset_title": "Gå till ditt konto för att återställa din identitet",
"msisdn": "Ett SMS har skickats till %(msisdn)s",
"msisdn_token_incorrect": "Felaktig token",
"msisdn_token_prompt": "Vänligen ange koden det innehåller:",
@@ -909,9 +910,11 @@
"empty_room_was_name": "Tomt rum (var %(oldName)s)",
"encryption": {
"access_secret_storage_dialog": {
+ "alternatives": "Om du har en säkerhetsnyckel eller säkerhetsfras så funkar den också.",
"key_validation_text": {
"wrong_security_key": "Fel säkerhetsnyckel"
},
+ "privacy_warning": "Se till att ingen kan se den här skärmen",
"restoring": "Återställer nycklar från säkerhetskopia",
"security_key_title": "Säkerhetsnyckel"
},
@@ -960,13 +963,13 @@
},
"reset_all_button": "Glömt eller förlorat alla återställningsalternativ? Återställ allt",
"set_up_recovery": "Ställ in återställning",
- "set_up_recovery_later": "Inte nu",
"set_up_recovery_toast_description": "Generera en återställningsnyckel som kan användas för att återställa din krypterade meddelandehistorik om du förlorar åtkomst till dina enheter.",
- "set_up_toast_description": "Skydda mot att förlora åtkomst till krypterade meddelanden och data",
"set_up_toast_title": "Ställ in säker säkerhetskopiering",
"setup_secure_backup": {
"explainer": "Säkerhetskopiera dina nycklar innan du loggar ut för att undvika att du blir av med dem."
},
+ "turn_on_key_storage": "Slå på nyckellagring",
+ "turn_on_key_storage_description": "Lagra din kryptografiska identitet och dina meddelandenycklar säkert på servern. Detta gör att du kan se din meddelandehistorik på alla nya enheter.",
"udd": {
"interactive_verification_button": "Verifiera interaktivt med emoji",
"other_ask_verify_text": "Be den här användaren att verifiera sin session, eller verifiera den manuellt nedan.",
@@ -1948,6 +1951,7 @@
},
"face_pile_tooltip_shortcut": "Inklusive %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Inklusive dig, %(commaSeparatedMembers)s",
+ "failed_determine_user": "Kan inte avgöra vilken användare som ska ignoreras eftersom medlemshändelsen har ändrats.",
"failed_reject_invite": "Misslyckades att avböja inbjudan",
"forget_room": "Glöm det här rummet",
"forget_space": "Glöm det här utrymmet",
@@ -2039,6 +2043,7 @@
"read_topic": "Klicka för att läsa ämne",
"rejecting": "Nekar inbjudan …",
"rejoin_button": "Gå med igen",
+ "room_is_low_priority": "Detta är ett lågprioriterat room",
"search": {
"all_rooms_button": "Sök i alla rum",
"placeholder": "Sök meddelanden...",
@@ -2093,11 +2098,14 @@
"no_chats_description_no_room_rights": "Kom igång genom att skicka meddelanden till någon",
"no_favourites": "Du har ingen favoritchatt än",
"no_favourites_description": "Du kan lägga till en chatt till dina favoriter i chattinställningarna",
+ "no_invites": "Du har inga olästa inbjudningar",
+ "no_mentions": "Du har inga olästa omnämnanden",
"no_people": "Du har inte direktchattar med någon ännu",
"no_people_description": "Du kan avmarkera filter för att se dina andra chattar",
"no_rooms": "Du är inte i något rum än",
"no_rooms_description": "Du kan avmarkera filter för att se dina andra chattar",
"no_unread": "Grattis! Du har inga olästa meddelanden",
+ "show_activity": "Visa all aktivitet",
"show_chats": "Visa alla chattar"
},
"failed_add_tag": "Misslyckades att lägga till etiketten %(tagName)s till rummet",
@@ -2105,6 +2113,9 @@
"failed_set_dm_tag": "Misslyckades att sätta direktmeddelandetagg",
"filters": {
"favourite": "Favoriter",
+ "invites": "Inbjudningar",
+ "low_priority": "Låg prioritet",
+ "mentions": "Omnämnanden",
"people": "Personer",
"rooms": "Rum",
"unread": "Olästa"
@@ -2142,9 +2153,14 @@
"one": "Visa %(count)s till"
},
"show_previews": "Visa förhandsgranskningar av meddelanden",
+ "sort": "Sortera",
"sort_by": "Sortera efter",
"sort_by_activity": "Aktivitet",
"sort_by_alphabet": "A-Ö",
+ "sort_type": {
+ "activity": "Aktivitet",
+ "atoz": "A-Ö"
+ },
"sort_unread_first": "Visa rum med olästa meddelanden först",
"space_menu": {
"home": "Utrymmeshem",
@@ -2432,6 +2448,10 @@
"recent_changes_heading": "Nyliga ändringar har inte mottagits än",
"title": "Servern svarar inte"
},
+ "service_worker_error": {
+ "description": "%(brand)s kräver en service worker för att läsa in autentiserade media från Matrix-innehållsförråd. Detta stöds inte av din webbläsare, så du kan uppleva att media misslyckas att laddas.",
+ "title": "Misslyckades att ladda service worker"
+ },
"seshat": {
"error_initialising": "Initialisering av meddelandesök misslyckades, kolla dina inställningar för mer information",
"reset_button": "Återställ händelselagring",
@@ -2525,6 +2545,8 @@
"session_key": "Sessionsnyckel:",
"title": "Avancerad"
},
+ "confirm_key_storage_off": "Är du säker på att du vill hålla nyckellagring avstängd?",
+ "confirm_key_storage_off_description": "Om du loggar ut från alla dina enheter kommer du att förlora din meddelandehistorik och måste verifiera alla dina befintliga kontakter igen. Läs mer",
"delete_key_storage": {
"breadcrumb_page": "Radera nyckellagring",
"confirm": "Radera nyckellagring",
@@ -2610,6 +2632,7 @@
"discovery_needs_terms_title": "Låt andra hitta dig",
"display_name": "Visningsnamn",
"display_name_error": "Det gick inte att ställa in visningsnamn",
+ "email_adding_unsupported_by_hs": "Den här hemservern stöder inte att lägga till e-postadresser till ditt konto.",
"email_address_in_use": "Den här e-postadressen används redan",
"email_address_label": "E-postadress",
"email_not_verified": "Din e-postadress har inte verifierats än",
@@ -2634,7 +2657,9 @@
"error_share_msisdn_discovery": "Kunde inte dela telefonnummer",
"identity_server_no_token": "Ingen identitetsåtkomsttoken hittades",
"identity_server_not_set": "Identitetsserver inte inställd",
+ "invalid_phone_number": "Det angivna telefonnumret verkar inte vara giltigt.",
"language_section": "Språk",
+ "msisdn_adding_unsupported_by_hs": "Den här hemservern stöder inte att lägga till telefonnummer till ditt konto.",
"msisdn_in_use": "Detta telefonnummer används redan",
"msisdn_label": "Telefonnummer",
"msisdn_verification_field_label": "Verifieringskod",
@@ -2659,11 +2684,6 @@
"insert_trailing_colon_mentions": "Infoga kolon efter användaromnämnande på början av ett meddelande",
"jump_to_bottom_on_send": "Hoppa till botten av tidslinjen när du skickar ett meddelande",
"key_backup": {
- "backup_in_progress": "Dina nycklar säkerhetskopieras (den första säkerhetskopieringen kan ta några minuter).",
- "backup_starting": "Startar säkerhetskopiering …",
- "backup_success": "Framgång!",
- "cannot_create_backup": "Kunde inte skapa nyckelsäkerhetskopia",
- "create_title": "Skapa nyckelsäkerhetskopia",
"setup_secure_backup": {
"backup_setup_success_description": "Dina nycklar säkerhetskopieras nu från denna enhet.",
"backup_setup_success_title": "Säkerhetskopiering lyckades",
@@ -2780,6 +2800,7 @@
"voip": "Ljud- och videosamtal"
},
"preferences": {
+ "Electron.enableContentProtection": "Förhindra att fönsterinnehållet spelas in av andra appar",
"Electron.enableHardwareAcceleration": "Aktivera hårdvaruacceleration (starta om%(appName)s att träda i kraft)",
"always_show_menu_bar": "Visa alltid fönstermenyn",
"autocomplete_delay": "Autokompletteringsfördröjning (ms)",
@@ -2821,7 +2842,6 @@
"ignore_users_empty": "Du har inga ignorerade användare.",
"ignore_users_section": "Ignorerade användare",
"key_backup_algorithm": "Algoritm:",
- "key_backup_connect": "Anslut den här sessionen till nyckelsäkerhetskopiering",
"message_search_disable_warning": "Om den är inaktiverad visas inte meddelanden från krypterade rum i sökresultaten.",
"message_search_disabled": "Cachar krypterade meddelanden säkert lokalt för att de ska visas i sökresultat.",
"message_search_enabled": {
@@ -2952,6 +2972,7 @@
"show_chat_effects": "Visa chatteffekter (animeringar när du tar emot t.ex. konfetti)",
"show_displayname_changes": "Visa visningsnamnsändringar",
"show_join_leave": "Visa gå med/lämna-meddelanden (inbjudningar/borttagningar/banningar påverkas inte)",
+ "show_message_previews": "Visa förhandsgranskningar av meddelanden",
"show_nsfw_content": "Visa NSFW-innehåll",
"show_read_receipts": "Visa läskvitton som skickats av andra användare",
"show_redaction_placeholder": "Visa en platshållare för borttagna meddelanden",
@@ -3779,6 +3800,7 @@
"description": "För att skapa en delningslänk, gör det här rummet offentligt eller aktivera alternativet för användare att be om att få gå med. Detta gör att gäster kan gå med utan att bli inbjudna.",
"dont_change_description": "Om du inte vill ändra åtkomst till det här rummet kan du skapa ett nytt rum för samtalslänken.",
"no_change": "Jag vill inte ändra åtkomstnivån.",
+ "revert_access_description": "(Detta kan återställas till föregående värde i Rumsinställningarna: Säkerhet och sekretess / Åtkomst)",
"title": "Tillåt gästanvändare att gå med i det här rummet"
},
"upload_failed_generic": "Filen '%(fileName)s' kunde inte laddas upp.",
diff --git a/src/i18n/strings/tr.json b/src/i18n/strings/tr.json
index 264880a2d4..1da42f9e8c 100644
--- a/src/i18n/strings/tr.json
+++ b/src/i18n/strings/tr.json
@@ -225,8 +225,7 @@
"setup_key_backup_title": "Şifrelenmiş mesajlarınıza erişiminizi kaybedeceksiniz",
"setup_secure_backup_description_1": "Şifreli mesajlar uçtan uca şifreleme ile korunur. Bu mesajları yalnızca siz ve alıcı(lar) okuyabilir.",
"setup_secure_backup_description_2": "Oturumu kapattığınızda, bu anahtarlar bu cihazdan silinecek. Bu, diğer cihazlarınızda bu anahtarlara sahip olmadığınız veya anahtarları sunucuya yedeklemediğiniz sürece şifreli mesajları okuyamayacağınız anlamına gelir.",
- "skip_key_backup": "Şifrelenmiş mesajlarımı istemiyorum",
- "use_key_backup": "Anahtar Yedekleme kullanmaya başla"
+ "skip_key_backup": "Şifrelenmiş mesajlarımı istemiyorum"
},
"misconfigured_body": "%(brand)s yöneticinize yapılandırmanızın hatalı ve mükerrer girdilerini kontrol etmesi için talepte bulunun.",
"misconfigured_title": "%(brand)s hatalı ayarlanmış",
@@ -934,9 +933,7 @@
},
"reset_all_button": "Tüm kurtarma yöntemlerini unuttunuz veya kaybettiniz mi? Tümünü sıfırla",
"set_up_recovery": "Kurtarmayı ayarlayın",
- "set_up_recovery_later": "Şimdi değil",
"set_up_recovery_toast_description": "Cihazlarınıza erişiminizi kaybetmeniz durumunda şifrelenmiş mesaj geçmişinizi geri yüklemek için kullanılabilecek bir kurtarma anahtarı oluşturun.",
- "set_up_toast_description": "Şifrelenmiş mesajlara ve verilere erişimi kaybetmemek için koruma sağlayın",
"set_up_toast_title": "Güvenli Yedekleme kur",
"setup_secure_backup": {
"explainer": "Anahtarlarını kaybetmemek için, çıkış yapmadan önce önleri yedekle."
@@ -2597,11 +2594,6 @@
"insert_trailing_colon_mentions": "Mesajın başında kullanıcı etiketlerinden sonra iki nokta üst üste ekle",
"jump_to_bottom_on_send": "Mesaj gönderdiğinizde zaman çizelgesinin en sonuna atla",
"key_backup": {
- "backup_in_progress": "Anahtarlarınız yedekleniyor (ilk yedekleme birkaç dakika sürebilir).",
- "backup_starting": "Yedekleme başlatılıyor...",
- "backup_success": "Başarılı!",
- "cannot_create_backup": "Anahtar yedeklemesi oluşturulamıyor",
- "create_title": "Anahtar yedeklemesi oluştur",
"setup_secure_backup": {
"backup_setup_success_description": "Anahtarlarınız artık bu cihazdan yedekleniyor.",
"backup_setup_success_title": "Güvenli Yedekleme başarılı",
@@ -2751,7 +2743,6 @@
"ignore_users_empty": "Yok saydığınız kullanıcı yok.",
"ignore_users_section": "Yoksayılan kullanıcılar",
"key_backup_algorithm": "Algoritma:",
- "key_backup_connect": "Anahtar Yedekleme için bu oturuma bağlanın",
"message_search_disable_warning": "Devre dışı bırakılırsa, şifreli odalardaki mesajlar arama sonuçlarında görünmez.",
"message_search_disabled": "Arama sonuçlarında gozükmeleri için iletileri güvenli bir şekilde yerel olarak önbelleğe al.",
"message_search_enabled": {
diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json
index 6be282da55..27e9507923 100644
--- a/src/i18n/strings/uk.json
+++ b/src/i18n/strings/uk.json
@@ -242,8 +242,7 @@
"setup_key_backup_title": "Ви втратите доступ до ваших зашифрованих повідомлень",
"setup_secure_backup_description_1": "Зашифровані повідомлення захищені наскрізним шифруванням. Лише ви та отримувачі повідомлень мають ключі для їх читання.",
"setup_secure_backup_description_2": "Коли ви вийдете, ці ключі буде видалено з цього пристрою, і ви не зможете читати зашифровані повідомлення, якщо у вас немає ключів для них на інших пристроях або не створено їх резервну копію на сервері.",
- "skip_key_backup": "Мені не потрібні мої зашифровані повідомлення",
- "use_key_backup": "Скористайтеся резервним копіюванням ключів"
+ "skip_key_backup": "Мені не потрібні мої зашифровані повідомлення"
},
"misconfigured_body": "Попросіть адміністратора %(brand)s перевірити конфігураційний файл на наявність неправильних або повторюваних записів.",
"misconfigured_title": "Ваш %(brand)s налаштовано неправильно",
@@ -788,6 +787,7 @@
"cross_signing_status": "Стан перехресного підписування:",
"cross_signing_untrusted": "Ваш обліковий запис має ідентичність із перехресним підписом у таємному сховищі, але цей сеанс ще не довіряє їй.",
"crypto_not_available": "Криптографічний модуль недоступний",
+ "device_id": "ID пристрою",
"key_backup_active_version": "Активна версія резервної копії:",
"key_backup_active_version_none": "Немає",
"key_backup_inactive_warning": "Резервне копіювання ваших ключів із цього сеансу не виконується.",
@@ -800,6 +800,8 @@
"secret_storage_ready": "готовий",
"secret_storage_status": "Таємне сховище:",
"self_signing_private_key_cached_status": "Самопідписаний приватний ключ:",
+ "session": "Сеанс",
+ "session_fingerprint": "Цифровий відбиток (ключ сеансу)",
"title": "Наскрізне шифрування",
"user_signing_private_key_cached_status": "Приватний ключ підпису користувача:"
},
@@ -825,6 +827,7 @@
"low_bandwidth_mode": "Режим низької пропускної спроможності",
"low_bandwidth_mode_description": "Потрібен сумісний домашній сервер.",
"main_timeline": "Основна стрічка",
+ "manual_device_verification": "Ручна верифікація пристрою",
"no_receipt_found": "Підтвердження не знайдено",
"notification_state": "Стан сповіщень %(notificationState)s",
"notifications_debug": "Сповіщення зневадження",
@@ -967,9 +970,7 @@
},
"reset_all_button": "Забули чи втратили всі способи відновлення? Скинути все",
"set_up_recovery": "Налаштування відновлення",
- "set_up_recovery_later": "Не зараз",
"set_up_recovery_toast_description": "Згенеруйте ключ відновлення, який можна використовувати для відновлення історії зашифрованих повідомлень у разі втрати доступу до своїх пристроїв.",
- "set_up_toast_description": "Захистіться від втрати доступу до зашифрованих повідомлень і даних",
"set_up_toast_title": "Налаштувати захищене резервне копіювання",
"setup_secure_backup": {
"explainer": "Створіть резервну копію ключів перед виходом, щоб не втратити їх."
@@ -1010,6 +1011,21 @@
"incoming_sas_dialog_waiting": "Очікування підтвердження партнером…",
"incoming_sas_user_dialog_text_1": "Звірте цього користувача щоб позначити його довіреним. Довіряння користувачам додає спокою якщо ви користуєтесь наскрізно зашифрованими повідомленнями.",
"incoming_sas_user_dialog_text_2": "Звірка цього користувача позначить його сеанс довіреним вам, а ваш йому.",
+ "manual": {
+ "already_verified": "Цей пристрій вже верифіковано",
+ "already_verified_and_wrong_fingerprint": "Наданий цифровий відбиток не збігається, але пристрій уже верифіковано!",
+ "device_id": "ID пристрою",
+ "failure_description": "Не вдалося верифікувати '%(deviceId)s': %(error)s",
+ "failure_title": "Не вдалося верифікувати",
+ "fingerprint": "Цифровий відбиток (ключ сеансу)",
+ "no_crypto": "Не вдалося верифікувати пристрій – криптографію не ввімкнено",
+ "no_device": "Не вдалося верифікувати пристрій - пристрій '%(deviceId)s' не знайдено",
+ "no_userid": "Не вдалося верифікувати пристрій – не вдалося знайти наш ID користувача",
+ "success_description": "Пристрій (%(deviceId)s) тепер має перехресний підпис",
+ "success_title": "Успішно верифіковано",
+ "text": "Надайте ID і цифровий відбиток одного з ваших пристроїв, щоб верифікувати його. ЗАУВАЖТЕ, це дозволяє іншому пристрою надсилати та отримувати повідомлення від вашого імені. ЯКЩО ХТОСЬ СКАЗАВ ВАМ ВСТАВИТИ ЩОСЬ СЮДИ, ШВИДШЕ ЗА ВСЕ, ВАС ОБМАНЮЮТЬ!",
+ "wrong_fingerprint": "Не вдалося верифікувати пристрій '%(deviceId)s' - наданий цифровий відбиток '%(fingerprint)s' не збігається з відбитком пристрою, '%(fprint)s'"
+ },
"no_key_or_device": "Схоже, у вас немає ключа відновлення або будь-яких інших пристроїв, за допомогою яких ви можете виконати верифікацію. Цей пристрій не зможе отримати доступ до старих зашифрованих повідомлень. Щоб підтвердити свою ідентичність на цьому пристрої, вам потрібно буде скинути ключі верифікації.",
"no_support_qr_emoji": "Пристрій, який ви намагаєтесь звірити, не підтримує сканування QR-коду або звірення за допомогою емоджі, що є підтримувані %(brand)s. Спробуйте використати інший клієнт.",
"other_party_cancelled": "Друга сторона скасувала звірення.",
@@ -2052,6 +2068,8 @@
"read_topic": "Натисніть, щоб побачити тему",
"rejecting": "Відхилення запрошення…",
"rejoin_button": "Перепід'єднатись",
+ "room_content": "Вміст кімнати",
+ "room_is_low_priority": "Це кімната з низьким пріоритетом",
"search": {
"all_rooms_button": "Вибрати всі кімнати",
"placeholder": "Пошук повідомлень…",
@@ -2101,6 +2119,7 @@
"add_space_label": "Додати простір",
"breadcrumbs_empty": "Немає недавно відвіданих кімнат",
"breadcrumbs_label": "Недавно відвідані кімнати",
+ "collapse_filters": "Згорнути список фільтрів",
"empty": {
"no_chats": "Ще немає бесід",
"no_chats_description": "Почніть користування, надіславши комусь повідомлення або створивши кімнату",
@@ -2108,6 +2127,7 @@
"no_favourites": "У вас ще немає обраних бесід",
"no_favourites_description": "Ви можете додати бесіду до обраних у її налаштуваннях",
"no_invites": "У вас немає непрочитаних запрошень",
+ "no_lowpriority": "У вас немає неважливих кімнат",
"no_mentions": "У вас немає непрочитаних згадок",
"no_people": "У вас ще немає особистих бесід",
"no_people_description": "Ви можете очистити фільтри, щоб побачити інші ваші бесіди",
@@ -2117,6 +2137,7 @@
"show_activity": "Переглянути всю діяльність",
"show_chats": "Показати всі бесіди"
},
+ "expand_filters": "Розгорнути список фільтрів",
"failed_add_tag": "Не вдалось додати до кімнати мітку %(tagName)s",
"failed_remove_tag": "Не вдалося прибрати з кімнати мітку %(tagName)s",
"failed_set_dm_tag": "Не вдалося встановити мітку особистого повідомлення",
@@ -2691,13 +2712,11 @@
"inline_url_previews_room": "Увімкнути попередній перегляд гіперпосилань за умовчанням для учасників цієї кімнати",
"inline_url_previews_room_account": "Увімкнути попередній перегляд гіперпосилань в цій кімнаті (стосується тільки вас)",
"insert_trailing_colon_mentions": "Додавати двокрапку після згадки користувача на початку повідомлення",
+ "invite_controls": {
+ "default_label": "Дозволити користувачам запрошувати вас до кімнат"
+ },
"jump_to_bottom_on_send": "Переходити вниз стрічки під час надсилання повідомлення",
"key_backup": {
- "backup_in_progress": "Створюється резервна копія ваших ключів (перше копіювання може тривати кілька хвилин).",
- "backup_starting": "Запуск резервного копіювання…",
- "backup_success": "Успішно!",
- "cannot_create_backup": "Не вдалося створити резервну копію ключів",
- "create_title": "Створити резервну копію ключів",
"setup_secure_backup": {
"backup_setup_success_description": "На цьому пристрої створюється резервна копія ваших ключів.",
"backup_setup_success_title": "Безпечне резервне копіювання виконано успішно",
@@ -2757,6 +2776,7 @@
"show_in_private": "У приватних кімнатах",
"show_media": "Завжди показувати"
},
+ "not_supported": "Ваш сервер не впровадив цю функцію.",
"notifications": {
"default_setting_description": "Цей параметр буде застосовано усталеним до всіх ваших кімнат.",
"default_setting_section": "Я хочу отримувати сповіщення про (типове налаштування)",
@@ -2856,7 +2876,6 @@
"ignore_users_empty": "Ви не маєте нехтуваних користувачів.",
"ignore_users_section": "Нехтувані користувачі",
"key_backup_algorithm": "Алгоритм:",
- "key_backup_connect": "Налаштувати цьому сеансу резервне копіювання ключів",
"message_search_disable_warning": "Якщо вимкнути, пошук не показуватиме повідомлень зашифрованих кімнат.",
"message_search_disabled": "Безпечно локально кешувати зашифровані повідомлення щоб вони з'являлись у результатах пошуку.",
"message_search_enabled": {
@@ -3094,6 +3113,8 @@
"jumptodate": "Перейти до вказаної дати в стрічці",
"jumptodate_invalid_input": "Не вдалося розпізнати вказану дату (%(inputDate)s). Спробуйте формат рррр-мм-дд.",
"lenny": "Додає ( ͡° ͜ʖ ͡°) на початку текстового повідомлення",
+ "manual_device_verification_confirm_description": "Це дозволить іншому пристрою надсилати та отримувати повідомлення від вашого імені. ЯКЩО ХТОСЬ СКАЗАВ ВАМ ВСТАВИТИ ЩОСЬ СЮДИ, ЦІЛКОМ ІМОВІРНО, ВАС ОБМАНЮЮТЬ! Ви впевнені, що хочете верифікувати цей інший пристрій?",
+ "manual_device_verification_confirm_title": "Увага: ручна верифікація пристрою",
"me": "Показ дій",
"msg": "Надсилає повідомлення вказаному користувачеві",
"myavatar": "Змінює зображення профілю в усіх кімнатах",
@@ -3134,7 +3155,7 @@
"upgraderoom": "Поліпшує кімнату до нової версії",
"upgraderoom_permission_error": "Вам бракує дозволу на використання цієї команди.",
"usage": "Використання",
- "verify": "Звіряє користувача, сеанс та супровід відкритого ключа",
+ "verify": "Вручну верифікуйте один із власних пристроїв",
"view": "Перегляд кімнати з вказаною адресою",
"whois": "Показує відомості про користувача"
},
diff --git a/src/i18n/strings/vi.json b/src/i18n/strings/vi.json
index d8cfdbece1..cdec0dd64d 100644
--- a/src/i18n/strings/vi.json
+++ b/src/i18n/strings/vi.json
@@ -210,8 +210,7 @@
"setup_key_backup_title": "Bạn sẽ mất quyền truy cập vào các tin nhắn được mã hóa của mình",
"setup_secure_backup_description_1": "Các tin nhắn được mã hóa được bảo mật bằng mã hóa đầu cuối. Chỉ bạn và (những) người nhận mới có chìa khóa để đọc những tin nhắn này.",
"setup_secure_backup_description_2": "Khi bạn đăng xuất, các khóa sẽ được xóa khỏi thiết bị này, tức là bạn không thể đọc các tin nhắn được mã hóa trừ khi bạn có khóa cho chúng trong thiết bị khác, hoặc sao lưu chúng lên máy chủ.",
- "skip_key_backup": "Tôi không muốn tin nhắn được mã hóa của mình",
- "use_key_backup": "Bắt đầu sao lưu các khóa"
+ "skip_key_backup": "Tôi không muốn tin nhắn được mã hóa của mình"
},
"misconfigured_body": "Yêu cầu quản trị viên %(brand)s của bạn kiểm tra your config để tìm các mục nhập sai hoặc trùng lặp.",
"misconfigured_title": "Hệ thống %(brand)s của bạn bị thiết lập sai",
@@ -757,7 +756,6 @@
"warning": "Nếu bạn không xóa phương pháp khôi phục, kẻ tấn công có thể đang cố truy cập vào tài khoản của bạn. Thay đổi mật khẩu tài khoản của bạn và đặt phương pháp khôi phục mới ngay lập tức trong Cài đặt."
},
"reset_all_button": "Quên hoặc mất tất cả các phương pháp khôi phục? Đặt lại tất cả Reset all",
- "set_up_toast_description": "Bảo vệ chống mất quyền truy cập vào tin nhắn và dữ liệu được mã hóa",
"set_up_toast_title": "Thiết lập Sao lưu Bảo mật",
"setup_secure_backup": {
"explainer": "Sao lưu chìa khóa của bạn trước khi đăng xuất để tránh mất chúng."
@@ -2077,10 +2075,6 @@
"insert_trailing_colon_mentions": "Chèn dấu hai chấm phía sau các đề cập người dùng ở đầu một tin nhắn",
"jump_to_bottom_on_send": "Chuyển đến cuối dòng thời gian khi bạn gửi tin nhắn",
"key_backup": {
- "backup_in_progress": "Các khóa của bạn đang được sao lưu (bản sao lưu đầu tiên có thể mất vài phút).",
- "backup_success": "Thành công!",
- "cannot_create_backup": "Không thể tạo bản sao lưu khóa",
- "create_title": "Tạo bản sao lưu chính",
"setup_secure_backup": {
"backup_setup_success_title": "Sao lưu bảo mật thành công",
"cancel_warning": "Nếu bạn hủy ngay bây giờ, bạn có thể mất tin nhắn và dữ liệu được mã hóa nếu bạn mất quyền truy cập vào thông tin đăng nhập của mình.",
@@ -2191,7 +2185,6 @@
"ignore_users_empty": "Bạn không có người dùng bị bỏ qua.",
"ignore_users_section": "Người dùng bị bỏ qua",
"key_backup_algorithm": "Thuật toán:",
- "key_backup_connect": "Kết nối phiên này với Khóa Sao lưu",
"message_search_disable_warning": "Nếu bị tắt, tin nhắn từ các phòng được mã hóa sẽ không xuất hiện trong kết quả tìm kiếm.",
"message_search_disabled": "Bộ nhớ cache an toàn các tin nhắn được mã hóa cục bộ để chúng xuất hiện trong kết quả tìm kiếm.",
"message_search_enabled": {
diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json
index 68a7ea94e9..34aa004b68 100644
--- a/src/i18n/strings/zh_Hans.json
+++ b/src/i18n/strings/zh_Hans.json
@@ -216,8 +216,7 @@
"setup_key_backup_title": "你将失去你的加密消息的访问权",
"setup_secure_backup_description_1": "加密消息已使用端到端加密保护。只有你和拥有密钥的收件人可以阅读这些消息。",
"setup_secure_backup_description_2": "当你登出时,这些密钥会从此设备删除。这意味着你将无法查阅已加密消息,除非你在其他设备上有那些消息的密钥,或者已将其备份到服务器。",
- "skip_key_backup": "我不想要我的加密消息",
- "use_key_backup": "开始使用密钥备份"
+ "skip_key_backup": "我不想要我的加密消息"
},
"misconfigured_body": "跟你的%(brand)s管理员确认你的配置不正确或重复的条目。",
"misconfigured_title": "你的 %(brand)s 配置有错误",
@@ -767,7 +766,6 @@
"warning": "如果你没有移除此恢复方式,可能有攻击者正试图侵入你的账户。请立即更改你的账户密码并在设置中设定一个新的恢复方式。"
},
"reset_all_button": "忘记或丢失了所有恢复方式?全部重置",
- "set_up_toast_description": "防止丢失加密消息和数据的访问权",
"set_up_toast_title": "设置安全备份",
"setup_secure_backup": {
"explainer": "在登出之前请备份密钥以免丢失。"
@@ -2079,10 +2077,6 @@
"insert_trailing_colon_mentions": "在消息开头的提及用户的地方后面插入尾随冒号",
"jump_to_bottom_on_send": "发送消息时跳转到时间线底部",
"key_backup": {
- "backup_in_progress": "正在备份你的密钥(第一次备份可能会花费几分钟时间)。",
- "backup_success": "成功!",
- "cannot_create_backup": "无法创建密钥备份",
- "create_title": "创建密钥备份",
"setup_secure_backup": {
"cancel_warning": "如果你现在取消,你可能会丢失加密的消息和数据,如果你丢失了登录信息的话。",
"confirm_security_phrase": "确认你的安全短语",
@@ -2182,7 +2176,6 @@
"ignore_users_empty": "你没有设置忽略用户。",
"ignore_users_section": "已忽略的用户",
"key_backup_algorithm": "算法:",
- "key_backup_connect": "将此会话连接到密钥备份",
"message_search_disable_warning": "如果被禁用,加密房间内的消息不会显示在搜索结果中。",
"message_search_disabled": "在本地安全地缓存加密消息以使其出现在搜索结果中。",
"message_search_enabled": {
diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json
index 2dcf0c1d47..f36e0b279c 100644
--- a/src/i18n/strings/zh_Hant.json
+++ b/src/i18n/strings/zh_Hant.json
@@ -215,8 +215,7 @@
"setup_key_backup_title": "您將會失去您的加密訊息",
"setup_secure_backup_description_1": "加密訊息是使用端對端加密。只有您和接收者才有金鑰可以閱讀這些訊息。",
"setup_secure_backup_description_2": "當您登出時,這些金鑰將會從此裝置被刪除,這代表您將無法再閱讀加密的訊息,除非您在其他裝置上有那些訊息的金鑰,或是將它們備份到伺服器上。",
- "skip_key_backup": "我不要我的加密訊息了",
- "use_key_backup": "開始使用金鑰備份"
+ "skip_key_backup": "我不要我的加密訊息了"
},
"misconfigured_body": "請要求您的 %(brand)s 管理員檢查您的設定是否有不正確或重覆的項目。",
"misconfigured_title": "您的 %(brand)s 沒有設定好",
@@ -825,7 +824,6 @@
"warning": "如果您沒有移除復原方法,攻擊者可能會試圖存取您的帳號。請立刻在設定中變更您帳號的密碼並設定新的復原方式。"
},
"reset_all_button": "忘記或遺失了所有復原方法?重設全部",
- "set_up_toast_description": "避免失去對加密訊息與資料的存取權",
"set_up_toast_title": "設定安全備份",
"setup_secure_backup": {
"explainer": "請在登出前備份您的金鑰,以免遺失。"
@@ -2245,11 +2243,6 @@
"insert_trailing_colon_mentions": "在使用者於訊息開頭提及之後插入跟隨冒號",
"jump_to_bottom_on_send": "傳送訊息時,跳到時間軸底部",
"key_backup": {
- "backup_in_progress": "您的金鑰正在備份(第一次備份會花費數分鐘)。",
- "backup_starting": "正在開始備份…",
- "backup_success": "成功!",
- "cannot_create_backup": "無法建立金鑰備份",
- "create_title": "建立金鑰備份",
"setup_secure_backup": {
"backup_setup_success_description": "您已備份此裝置的金鑰。",
"backup_setup_success_title": "安全備份成功",
@@ -2384,7 +2377,6 @@
"ignore_users_empty": "您沒有忽略的使用者。",
"ignore_users_section": "忽略使用者",
"key_backup_algorithm": "演算法:",
- "key_backup_connect": "將此工作階段連結至金鑰備份",
"message_search_disable_warning": "若停用,從加密聊天室傳來的訊息將不會出現在搜尋結果中。",
"message_search_disabled": "將加密的訊息安全地在本機快取以出現在顯示結果中。",
"message_search_enabled": {
diff --git a/src/languageHandler.tsx b/src/languageHandler.tsx
index 175f9c149f..9ac6cce1c5 100644
--- a/src/languageHandler.tsx
+++ b/src/languageHandler.tsx
@@ -1,49 +1,49 @@
/*
-Copyright 2024 New Vector Ltd.
-Copyright 2019-2022 The Matrix.org Foundation C.I.C.
-Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
-Copyright 2017 MTRNord and Cooperative EITA
-Copyright 2017 Vector Creations Ltd.
-
-SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
-Please see LICENSE files in the repository root for full details.
-*/
+ * 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 counterpart from "counterpart";
-import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { type Optional } from "matrix-events-sdk";
import { MapWithDefault } from "matrix-js-sdk/src/utils";
-import { normalizeLanguageKey, type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
import { type TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
import _ from "lodash";
-import type Translations from "./i18n/strings/en_EN.json";
import SettingsStore from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg";
import { SettingLevel } from "./settings/SettingLevel";
import { retry } from "./utils/promise";
import SdkConfig from "./SdkConfig";
import { ModuleRunner } from "./modules/ModuleRunner";
+import {
+ _t,
+ normalizeLanguageKey,
+ type TranslationKey,
+ type IVariables,
+ KEY_SEPARATOR,
+ getLangsJson,
+} from "./shared-components/i18n";
-// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
-import webpackLangJsonUrl from "$webapp/i18n/languages.json";
-
-export { normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
+export {
+ _t,
+ type IVariables,
+ type Tags,
+ type TranslationKey,
+ type TranslatedString,
+ _td,
+ _tDom,
+ lookupString,
+ sanitizeForTranslation,
+ normalizeLanguageKey,
+ getNormalizedLanguageKeys,
+ substitute,
+} from "./shared-components/i18n";
const i18nFolder = "i18n/";
-// Control whether to also return original, untranslated strings
-// Useful for debugging and testing
-const ANNOTATE_STRINGS = false;
-
-// We use english strings as keys, some of which contain full stops
-counterpart.setSeparator(KEY_SEPARATOR);
-
-// see `translateWithFallback` for an explanation of fallback handling
-const FALLBACK_LOCALE = "en";
-counterpart.setFallbackLocale(FALLBACK_LOCALE);
-
export interface ErrorOptions {
// Because we're mixing the substitution variables and `cause` into the same object
// below, we want them to always explicitly say whether there is an underlying error
@@ -96,353 +96,6 @@ export function getUserLanguage(): string {
}
}
-/**
- * A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
- * @example `common|error` to access `error` within the `common` sub-object.
- * {
- * "common": {
- * "error": "Error"
- * }
- * }
- */
-export type TranslationKey = _TranslationKey;
-
-// Function which only purpose is to mark that a string is translatable
-// Does not actually do anything. It's helpful for automatic extraction of translatable strings
-export function _td(s: TranslationKey): TranslationKey {
- return s;
-}
-
-function isValidTranslation(translated: string): boolean {
- return typeof translated === "string" && !translated.startsWith("missing translation:");
-}
-
-/**
- * to improve screen reader experience translations that are not in the main page language
- * eg a translation that fell back to english from another language
- * should be wrapped with an appropriate `lang='en'` attribute
- * counterpart's `translate` doesn't expose a way to determine if the resulting translation
- * is in the target locale or a fallback locale
- * for this reason, force fallbackLocale === locale in the first call to translate
- * and fallback 'manually' so we can mark fallback strings appropriately
- * */
-const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
- const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
- if (isValidTranslation(translated)) {
- return { translated };
- }
-
- const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
- if (isValidTranslation(fallbackTranslated)) {
- return { translated: fallbackTranslated, isFallback: true };
- }
-
- // Even the translation via FALLBACK_LOCALE failed; this can happen if
- //
- // 1. The string isn't in the translations dictionary, usually because you're in develop
- // and haven't run yarn i18n
- // 2. Loading the translation resources over the network failed, which can happen due to
- // to network or if the client tried to load a translation that's been removed from the
- // server.
- //
- // At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
- // so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
- return { translated: text, isFallback: true };
-};
-
-// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
-// Takes the same arguments as counterpart.translate()
-function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
- // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
- // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
- // It is enough to pass the count variable, but in the future counterpart might make use of other information too
- const options: IVariables & {
- interpolate: boolean;
- } = { ...variables, interpolate: false };
-
- // Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
- // The interpolation library that counterpart uses does not support undefined/null
- // values and instead will throw an error. This is a problem since everywhere else
- // in JS land passing undefined/null will simply stringify instead, and when converting
- // valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
- // if there are no existing null guards. To avoid this making the app completely inoperable,
- // we'll check all the values for undefined/null and stringify them here.
- if (options && typeof options === "object") {
- Object.keys(options).forEach((k) => {
- if (options[k] === undefined) {
- logger.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
- options[k] = "undefined";
- }
- if (options[k] === null) {
- logger.warn("safeCounterpartTranslate called with null interpolation name: " + k);
- options[k] = "null";
- }
- });
- }
- return translateWithFallback(text, options);
-}
-
-/**
- * The value a variable or tag can take for a translation interpolation.
- */
-type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
-
-export interface IVariables {
- count?: number;
- [key: string]: SubstitutionValue;
-}
-
-export type Tags = Record;
-
-export type TranslatedString = string | React.ReactNode;
-
-// For development/testing purposes it is useful to also output the original string
-// Don't do that for release versions
-const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
- if (!ANNOTATE_STRINGS) {
- return result;
- }
-
- if (typeof result === "string") {
- return `@@${translationKey}##${result}@@`;
- } else {
- return (
-
- {result}
-
- );
- }
-};
-
-/*
- * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
- * @param {string} text The untranslated text, e.g "click here now to %(foo)s".
- * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
- * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
- *
- * In both variables and tags, the values to substitute with can be either simple strings, React components,
- * or functions that return the value to use in the substitution (e.g. return a React component). In case of
- * a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
- *
- * Use tag substitutions if you need to translate text between tags (e.g. "Click here!"), otherwise
- * you will end up with literal "" in your output, rather than HTML. Note that you can also use variable
- * substitution to insert React components, but you can't use it to translate text between tags.
- *
- * @return a React component if any non-strings were used in substitutions, otherwise a string
- */
-// eslint-next-line @typescript-eslint/naming-convention
-export function _t(text: TranslationKey, variables?: IVariables): string;
-export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
-export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
- // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
- const { translated } = safeCounterpartTranslate(text, variables);
- const substituted = substitute(translated, variables, tags);
-
- return annotateStrings(substituted, text);
-}
-
-/**
- * Utility function to look up a string by its translation key without resolving variables & tags
- * @param key - the translation key to return the value for
- */
-export function lookupString(key: TranslationKey): string {
- return safeCounterpartTranslate(key, {}).translated;
-}
-
-/*
- * Wraps normal _t function and adds atttribution for translations that used a fallback locale
- * Wraps translations that fell back from active locale to fallback locale with a `>`
- * @param {string} text The untranslated text, e.g "click here now to %(foo)s".
- * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
- * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
- *
- * @return a React component if any non-strings were used in substitutions
- * or translation used a fallback locale, otherwise a string
- */
-// eslint-next-line @typescript-eslint/naming-convention
-export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
-export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
-export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
- // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
- const { translated, isFallback } = safeCounterpartTranslate(text, variables);
- const substituted = substitute(translated, variables, tags);
-
- // wrap en fallback translation with lang attribute for screen readers
- const result = isFallback ? {substituted} : substituted;
-
- return annotateStrings(result, text);
-}
-
-/**
- * Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
- * replaceable by the translation functions.
- * @param {string} text The text to sanitize.
- * @returns {string} The sanitized text.
- */
-export function sanitizeForTranslation(text: string): string {
- // Add a non-breaking space so the regex doesn't trigger when translating.
- return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
-}
-
-/*
- * Similar to _t(), except only does substitutions, and no translation
- * @param {string} text The text, e.g "click here now to %(foo)s".
- * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
- * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
- *
- * The values to substitute with can be either simple strings, or functions that return the value to use in
- * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
- * the argument the text inside the element corresponding to the tag.
- *
- * @return a React component if any non-strings were used in substitutions, otherwise a string
- */
-export function substitute(text: string, variables?: IVariables): string;
-export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
-export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
- let result: React.ReactNode | string = text;
-
- if (variables !== undefined) {
- const regexpMapping: IVariables = {};
- for (const variable in variables) {
- regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
- }
- result = replaceByRegexes(result as string, regexpMapping);
- }
-
- if (tags !== undefined) {
- const regexpMapping: Tags = {};
- for (const tag in tags) {
- regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
- }
- result = replaceByRegexes(result as string, regexpMapping);
- }
-
- return result;
-}
-
-/**
- * Replace parts of a text using regular expressions
- * @param text - The text on which to perform substitutions
- * @param mapping - A mapping from regular expressions in string form to replacement string or a
- * function which will receive as the argument the capture groups defined in the regexp. E.g.
- * { 'Hello (.?) World': (sub) => sub.toUpperCase() }
- *
- * @return a React component if any non-strings were used in substitutions, otherwise a string
- */
-export function replaceByRegexes(text: string, mapping: IVariables): string;
-export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
-export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
- // We initially store our output as an array of strings and objects (e.g. React components).
- // This will then be converted to a string or a at the end
- const output: SubstitutionValue[] = [text];
-
- // If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
- let shouldWrapInSpan = false;
-
- for (const regexpString in mapping) {
- // TODO: Cache regexps
- const regexp = new RegExp(regexpString, "g");
-
- // Loop over what output we have so far and perform replacements
- // We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
- // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
- // Otherwise there would be no need for the splitting and we could do simple replacement.
- let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
- for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
- const inputText = output[outputIndex];
- if (typeof inputText !== "string") {
- // We might have inserted objects earlier, don't try to replace them
- continue;
- }
-
- // process every match in the string
- // starting with the first
- let match = regexp.exec(inputText);
-
- if (!match) continue;
- matchFoundSomewhere = true;
-
- // The textual part before the first match
- const head = inputText.slice(0, match.index);
-
- const parts: SubstitutionValue[] = [];
- // keep track of prevMatch
- let prevMatch;
- while (match) {
- // store prevMatch
- prevMatch = match;
- const capturedGroups = match.slice(2);
-
- let replaced: SubstitutionValue;
- // If substitution is a function, call it
- if (mapping[regexpString] instanceof Function) {
- replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
- } else {
- replaced = mapping[regexpString];
- }
-
- if (typeof replaced === "object") {
- shouldWrapInSpan = true;
- }
-
- // Here we also need to check that it actually is a string before comparing against one
- // The head and tail are always strings
- if (typeof replaced !== "string" || replaced !== "") {
- parts.push(replaced);
- }
-
- // try the next match
- match = regexp.exec(inputText);
-
- // add the text between prevMatch and this one
- // or the end of the string if prevMatch is the last match
- let tail;
- if (match) {
- const startIndex = prevMatch.index + prevMatch[0].length;
- tail = inputText.slice(startIndex, match.index);
- } else {
- tail = inputText.slice(prevMatch.index + prevMatch[0].length);
- }
- if (tail) {
- parts.push(tail);
- }
- }
-
- // Insert in reverse order as splice does insert-before and this way we get the final order correct
- // remove the old element at the same time
- output.splice(outputIndex, 1, ...parts);
-
- if (head !== "") {
- // Don't push empty nodes, they are of no use
- output.splice(outputIndex, 0, head);
- }
- }
- if (!matchFoundSomewhere) {
- if (
- // The current regexp did not match anything in the input. Missing
- // matches is entirely possible because you might choose to show some
- // variables only in the case of e.g. plurals. It's still a bit
- // suspicious, and could be due to an error, so log it. However, not
- // showing count is so common that it's not worth logging. And other
- // commonly unused variables here, if there are any.
- regexpString !== "%\\(count\\)s" &&
- // Ignore the `locale` option which can be used to override the locale
- // in counterpart
- regexpString !== "%\\(locale\\)s"
- ) {
- logger.log(`Could not find ${regexp} in ${text}`);
- }
- }
- }
-
- if (shouldWrapInSpan) {
- return React.createElement("span", null, ...(output as Array));
- } else {
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
- return output.join("");
- }
-}
-
// Allow overriding the text displayed when no translation exists
// Currently only used in unit tests to avoid having to load
// the translations in element-web
@@ -450,10 +103,6 @@ export function setMissingEntryGenerator(f: (value: string) => void): void {
counterpart.setMissingEntryGenerator(f);
}
-type Languages = {
- [lang: string]: string;
-};
-
export async function setLanguage(...preferredLangs: string[]): Promise {
PlatformPeg.get()?.setLanguage(preferredLangs);
@@ -554,24 +203,6 @@ export function pickBestLanguage(langs: string[]): string {
return langs[0];
}
-async function getLangsJson(): Promise {
- let url: string;
- if (typeof webpackLangJsonUrl === "string") {
- // in Jest this 'url' isn't a URL, so just fall through
- url = webpackLangJsonUrl;
- } else {
- url = i18nFolder + "languages.json";
- }
-
- const res = await fetch(url, { method: "GET" });
-
- if (!res.ok) {
- throw new Error(`Failed to load ${url}, got ${res.status}`);
- }
-
- return res.json();
-}
-
interface ICounterpartTranslation {
[key: string]:
| string
diff --git a/src/modules/Api.ts b/src/modules/Api.ts
index 23abadf529..db7dd80334 100644
--- a/src/modules/Api.ts
+++ b/src/modules/Api.ts
@@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
*/
import { createRoot, type Root } from "react-dom/client";
+import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
-import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
import { ModuleRunner } from "./ModuleRunner.ts";
import AliasCustomisations from "../customisations/Alias.ts";
import { RoomListCustomisations } from "../customisations/RoomList.ts";
@@ -21,6 +21,7 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
import { ConfigApi } from "./ConfigApi.ts";
import { I18nApi } from "./I18nApi.ts";
+import { CustomComponentsApi } from "./customComponentApi.ts";
const legacyCustomisationsFactory = (baseCustomisations: T) => {
let used = false;
@@ -58,6 +59,7 @@ class ModuleApi implements Api {
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
+ public readonly customComponents = new CustomComponentsApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public createRoot(element: Element): Root {
diff --git a/src/modules/customComponentApi.ts b/src/modules/customComponentApi.ts
new file mode 100644
index 0000000000..db2f9ab58a
--- /dev/null
+++ b/src/modules/customComponentApi.ts
@@ -0,0 +1,137 @@
+/*
+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 { type MatrixEvent } from "matrix-js-sdk/src/matrix";
+import { logger } from "matrix-js-sdk/src/logger";
+
+import type {
+ CustomComponentsApi as ICustomComponentsApi,
+ CustomMessageRenderFunction,
+ CustomMessageComponentProps as ModuleCustomMessageComponentProps,
+ OriginalComponentProps,
+ CustomMessageRenderHints as ModuleCustomCustomMessageRenderHints,
+ MatrixEvent as ModuleMatrixEvent,
+} from "@element-hq/element-web-module-api";
+import type React from "react";
+
+type EventTypeOrFilter = Parameters[0];
+
+type EventRenderer = {
+ eventTypeOrFilter: EventTypeOrFilter;
+ renderer: CustomMessageRenderFunction;
+ hints: ModuleCustomCustomMessageRenderHints;
+};
+
+interface CustomMessageComponentProps extends Omit {
+ mxEvent: MatrixEvent;
+}
+
+interface CustomMessageRenderHints extends Omit {
+ // Note. This just makes it easier to use this API on Element Web as we already have the moduleized event stored.
+ allowDownloadingMedia?: () => Promise;
+}
+
+export class CustomComponentsApi implements ICustomComponentsApi {
+ /**
+ * Convert a matrix-js-sdk event into a ModuleMatrixEvent.
+ * @param mxEvent
+ * @returns An event object, or `null` if the event was not a message event.
+ */
+ private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
+ const eventId = mxEvent.getId();
+ const roomId = mxEvent.getRoomId();
+ const sender = mxEvent.sender;
+ // Typically we wouldn't expect messages without these keys to be rendered
+ // by the timeline, but for the sake of type safety.
+ if (!eventId || !roomId || !sender) {
+ // Not a message event.
+ return null;
+ }
+ return {
+ content: mxEvent.getContent(),
+ eventId,
+ originServerTs: mxEvent.getTs(),
+ roomId,
+ sender: sender.userId,
+ stateKey: mxEvent.getStateKey(),
+ type: mxEvent.getType(),
+ unsigned: mxEvent.getUnsigned(),
+ };
+ }
+
+ private readonly registeredMessageRenderers: EventRenderer[] = [];
+
+ public registerMessageRenderer(
+ eventTypeOrFilter: EventTypeOrFilter,
+ renderer: CustomMessageRenderFunction,
+ hints: ModuleCustomCustomMessageRenderHints = {},
+ ): void {
+ this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
+ }
+ /**
+ * Select the correct renderer based on the event information.
+ * @param mxEvent The message event being rendered.
+ * @returns The registered renderer.
+ */
+ private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
+ return this.registeredMessageRenderers.find((renderer) => {
+ if (typeof renderer.eventTypeOrFilter === "string") {
+ return renderer.eventTypeOrFilter === mxEvent.type;
+ } else {
+ try {
+ return renderer.eventTypeOrFilter(mxEvent);
+ } catch (ex) {
+ logger.warn("Message renderer failed to process filter", ex);
+ return false; // Skip erroring renderers.
+ }
+ }
+ });
+ }
+
+ /**
+ * Render the component for a message event.
+ * @param props Props to be passed to the custom renderer.
+ * @param originalComponent Function that will be rendered if no custom renderers are present, or as a child of a custom component.
+ * @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
+ */
+ public renderMessage(
+ props: CustomMessageComponentProps,
+ originalComponent?: (props?: OriginalComponentProps) => React.JSX.Element,
+ ): React.JSX.Element | null {
+ const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
+ const renderer = moduleEv && this.selectRenderer(moduleEv);
+ if (renderer) {
+ try {
+ return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
+ } catch (ex) {
+ logger.warn("Message renderer failed to render", ex);
+ // Fall through to original component. If the module encounters an error we still want to display messages to the user!
+ }
+ }
+ return originalComponent?.() ?? null;
+ }
+
+ /**
+ * Get hints about an message before rendering it.
+ * @param mxEvent The message event being rendered.
+ * @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
+ */
+ public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
+ const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
+ const renderer = moduleEv && this.selectRenderer(moduleEv);
+ if (renderer) {
+ return {
+ ...renderer.hints,
+ // Convert from js-sdk style events to module events automatically.
+ allowDownloadingMedia: renderer.hints.allowDownloadingMedia
+ ? () => renderer.hints.allowDownloadingMedia!(moduleEv)
+ : undefined,
+ };
+ }
+ return null;
+ }
+}
diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts
index 2e04d7d9dc..0717ccd56a 100644
--- a/src/rageshake/submit-rageshake.ts
+++ b/src/rageshake/submit-rageshake.ts
@@ -171,7 +171,7 @@ async function collectSynapseSpecific(client: MatrixClient, body: FormData): Pro
} catch {
try {
// If that fails we'll hit any endpoint and look at the server response header
- const res = await window.fetch(client.http.getUrl("/login"), {
+ const res = await fetch(client.http.getUrl("/login"), {
method: "GET",
mode: "cors",
});
@@ -257,7 +257,7 @@ export function collectSettings(body: FormData): void {
body.append("lowBandwidth", "enabled");
}
- body.append("mx_local_settings", localStorage.getItem("mx_local_settings")!);
+ body.append("mx_local_settings", SettingsStore.exportForRageshake());
}
/**
diff --git a/src/sentry.ts b/src/sentry.ts
index 92e8403963..214f4f09e3 100644
--- a/src/sentry.ts
+++ b/src/sentry.ts
@@ -141,7 +141,7 @@ async function getCryptoContext(client: MatrixClient): Promise {
function getDeviceContext(client: MatrixClient): DeviceContext {
const result: DeviceContext = {
device_id: client?.deviceId ?? undefined,
- mx_local_settings: localStorage.getItem("mx_local_settings"),
+ mx_local_settings: SettingsStore.exportForRageshake(),
};
if (window.Modernizr) {
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index 3ca7503cd1..4ed3752372 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -11,7 +11,8 @@ import React, { type ReactNode } from "react";
import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix";
import { type MediaPreviewConfig } from "../@types/media_preview.ts";
-import { _t, _td, type TranslationKey } from "../languageHandler";
+// Import i18n.tsx instead of languageHandler to avoid circular deps
+import { _t, _td, type TranslationKey } from "../shared-components/i18n";
import DeviceIsolationModeController from "./controllers/DeviceIsolationModeController.ts";
import {
NotificationBodyEnabledController,
@@ -173,6 +174,11 @@ export interface IBaseSetting {
// Whether the setting should have a warning sign in the microcopy
shouldWarn?: boolean;
+
+ /**
+ * Whether the setting should be exported in a rageshake report.
+ */
+ shouldExportToRageshake?: boolean;
}
export interface IFeature extends Omit, "isFeature"> {
@@ -441,6 +447,8 @@ export const SETTINGS: Settings = {
controller: new InviteRulesConfigController(),
supportedLevels: [SettingLevel.ACCOUNT],
default: InviteRulesConfigController.default,
+ // Contains server names
+ shouldExportToRageshake: false,
},
"feature_report_to_moderators": {
isFeature: true,
@@ -503,10 +511,14 @@ export const SETTINGS: Settings = {
"mjolnirRooms": {
supportedLevels: [SettingLevel.ACCOUNT],
default: [],
+ // Contains room IDs
+ shouldExportToRageshake: false,
},
"mjolnirPersonalRoom": {
supportedLevels: [SettingLevel.ACCOUNT],
default: null,
+ // Contains room ID
+ shouldExportToRageshake: false,
},
"feature_html_topic": {
isFeature: true,
@@ -797,6 +809,8 @@ export const SETTINGS: Settings = {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td("settings|preferences|user_timezone"),
default: "",
+ // Location leak
+ shouldExportToRageshake: false,
},
"userTimezonePublish": {
// This is per-device so you can avoid having devices overwrite each other.
@@ -913,6 +927,8 @@ export const SETTINGS: Settings = {
"custom_themes": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: [],
+ // Potential privacy leak via theme origin
+ shouldExportToRageshake: false,
},
"use_system_theme": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
@@ -974,26 +990,36 @@ export const SETTINGS: Settings = {
"language": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: "en",
+ // For privacy
+ shouldExportToRageshake: false,
},
"breadcrumb_rooms": {
// not really a setting
supportedLevels: [SettingLevel.ACCOUNT],
default: [],
+ // Contains joined rooms
+ shouldExportToRageshake: false,
},
"recent_emoji": {
// not really a setting
supportedLevels: [SettingLevel.ACCOUNT],
default: [],
+ // For privacy
+ shouldExportToRageshake: false,
},
"SpotlightSearch.recentSearches": {
// not really a setting
supportedLevels: [SettingLevel.ACCOUNT],
default: [], // list of room IDs, most recent first
+ // For privacy
+ shouldExportToRageshake: false,
},
"showMediaEventIds": {
// not really a setting
supportedLevels: [SettingLevel.DEVICE],
default: {}, // List of events => is visible
+ // Exports event IDs
+ shouldExportToRageshake: false,
},
"SpotlightSearch.showNsfwPublicRooms": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
@@ -1003,6 +1029,8 @@ export const SETTINGS: Settings = {
"room_directory_servers": {
supportedLevels: [SettingLevel.ACCOUNT],
default: [],
+ // Contains connected servers for user
+ shouldExportToRageshake: false,
},
"integrationProvisioning": {
supportedLevels: [SettingLevel.ACCOUNT],
@@ -1012,6 +1040,7 @@ export const SETTINGS: Settings = {
supportedLevels: [SettingLevel.ROOM_ACCOUNT, SettingLevel.ROOM_DEVICE],
supportedLevelsAreOrdered: true,
default: {}, // none allowed
+ shouldExportToRageshake: false,
},
// Legacy, kept around for transitionary purposes
"analyticsOptIn": {
@@ -1086,6 +1115,8 @@ export const SETTINGS: Settings = {
"notificationSound": {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: false,
+ // Contains personal information in file name
+ shouldExportToRageshake: false,
},
"notificationBodyEnabled": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
@@ -1112,6 +1143,8 @@ export const SETTINGS: Settings = {
allow: [],
deny: [],
},
+ // Expses widget information
+ shouldExportToRageshake: false,
},
"breadcrumbs": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
@@ -1201,6 +1234,8 @@ export const SETTINGS: Settings = {
// deprecated
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
default: {},
+ // Sensitive information in widget ID
+ shouldExportToRageshake: false,
},
"Widgets.layout": {
supportedLevels: LEVELS_ROOM_OR_ACCOUNT,
@@ -1275,6 +1310,8 @@ export const SETTINGS: Settings = {
"activeCallRoomIds": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: [],
+ // Contains room IDs
+ shouldExportToRageshake: false,
},
/**
* Enable or disable the release announcement feature
@@ -1394,7 +1431,7 @@ export const SETTINGS: Settings = {
},
"Electron.enableContentProtection": {
supportedLevels: [SettingLevel.PLATFORM],
- displayName: _td("settings|preferences|enable_hardware_acceleration"),
+ displayName: _td("settings|preferences|enable_content_protection"),
default: false,
},
"Developer.elementCallUrl": {
diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts
index aaa836b6fc..bce943ab7a 100644
--- a/src/settings/SettingsStore.ts
+++ b/src/settings/SettingsStore.ts
@@ -883,6 +883,21 @@ export default class SettingsStore {
logger.log(`--- END DEBUG`);
}
+ /**
+ * Export all settings as a JSON object, except for settings
+ * blocked from being exported by `shouldExportToRageshake`.
+ * @returns Settings as a JSON object string.
+ */
+ public static exportForRageshake(): string {
+ const settingMap: Record = {};
+ for (const settingKey of (Object.keys(SETTINGS) as SettingKey[]).filter(
+ (s) => SETTINGS[s].shouldExportToRageshake !== false,
+ )) {
+ settingMap[settingKey] = SettingsStore.getValue(settingKey);
+ }
+ return JSON.stringify(settingMap);
+ }
+
private static getHandler(settingName: SettingKey, level: SettingLevel): SettingsHandler | null {
const handlers = SettingsStore.getHandlers(settingName);
if (!handlers[level]) return null;
diff --git a/src/shared-components/MockViewModel.ts b/src/shared-components/MockViewModel.ts
new file mode 100644
index 0000000000..28a1456a5d
--- /dev/null
+++ b/src/shared-components/MockViewModel.ts
@@ -0,0 +1,23 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import { type ViewModel } from "./ViewModel";
+
+/**
+ * A mock view model that returns a static snapshot passed in the constructor, with no updates.
+ */
+export class MockViewModel implements ViewModel {
+ public constructor(private snapshot: T) {}
+
+ public getSnapshot = (): T => {
+ return this.snapshot;
+ };
+
+ public subscribe(listener: () => void): () => void {
+ return () => undefined;
+ }
+}
diff --git a/src/shared-components/ViewModel.ts b/src/shared-components/ViewModel.ts
new file mode 100644
index 0000000000..9f088c4300
--- /dev/null
+++ b/src/shared-components/ViewModel.ts
@@ -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.
+*/
+
+/**
+ * The interface for a generic View Model passed to the shared components.
+ * The snapshot is of type T which is a type specifying a snapshot for the view in question.
+ */
+export interface ViewModel {
+ /**
+ * The current snapshot of the view model.
+ */
+ getSnapshot: () => T;
+
+ /**
+ * Subscribes to changes in the view model.
+ * The listener will be called whenever the snapshot changes.
+ */
+ subscribe: (listener: () => void) => () => void;
+}
diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx b/src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx
new file mode 100644
index 0000000000..1746bc14b2
--- /dev/null
+++ b/src/shared-components/event-tiles/TextualEvent/TextualEvent.stories.tsx
@@ -0,0 +1,25 @@
+/*
+Copyright 2025 New Vector Ltd.
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE files in the repository root for full details.
+*/
+
+import React from "react";
+import { type Meta, type StoryFn } from "@storybook/react-vite";
+
+import { TextualEvent as TextualEventComponent } from "./TextualEvent";
+import { MockViewModel } from "../../MockViewModel";
+
+export default {
+ title: "Event/TextualEvent",
+ component: TextualEventComponent,
+ tags: ["autodocs"],
+ args: {
+ vm: new MockViewModel("Dummy textual event text"),
+ },
+} as Meta;
+
+const Template: StoryFn = (args) => ;
+
+export const Default = Template.bind({});
diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx b/src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx
new file mode 100644
index 0000000000..b1ef5e8f52
--- /dev/null
+++ b/src/shared-components/event-tiles/TextualEvent/TextualEvent.test.tsx
@@ -0,0 +1,21 @@
+/*
+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 { composeStories } from "@storybook/react-vite";
+import { render } from "jest-matrix-react";
+import React from "react";
+
+import * as stories from "./TextualEvent.stories.tsx";
+
+const { Default } = composeStories(stories);
+
+describe("TextualEvent", () => {
+ it("renders a textual event", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/shared-components/event-tiles/TextualEvent/TextualEvent.tsx b/src/shared-components/event-tiles/TextualEvent/TextualEvent.tsx
new file mode 100644
index 0000000000..1dec80905e
--- /dev/null
+++ b/src/shared-components/event-tiles/TextualEvent/TextualEvent.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 React, { type ReactNode, type JSX } from "react";
+
+import { type ViewModel } from "../../ViewModel";
+import { useViewModel } from "../../useViewModel";
+
+export type TextualEventViewSnapshot = string | ReactNode;
+
+export interface Props {
+ vm: ViewModel;
+}
+
+export function TextualEvent({ vm }: Props): JSX.Element {
+ const contents = useViewModel(vm);
+
+ return
{contents}
;
+}
diff --git a/src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap b/src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap
new file mode 100644
index 0000000000..186e5f0afd
--- /dev/null
+++ b/src/shared-components/event-tiles/TextualEvent/__snapshots__/TextualEvent.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TextualEvent renders a textual event 1`] = `
+
+
+ Dummy textual event text
+
+
+`;
diff --git a/src/shared-components/event-tiles/TextualEvent/index.ts b/src/shared-components/event-tiles/TextualEvent/index.ts
new file mode 100644
index 0000000000..96f257fbfa
--- /dev/null
+++ b/src/shared-components/event-tiles/TextualEvent/index.ts
@@ -0,0 +1,8 @@
+/*
+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.
+*/
+
+export { TextualEvent } from "./TextualEvent";
diff --git a/src/shared-components/i18n.tsx b/src/shared-components/i18n.tsx
new file mode 100644
index 0000000000..e23acff831
--- /dev/null
+++ b/src/shared-components/i18n.tsx
@@ -0,0 +1,432 @@
+/*
+ * 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.
+ */
+
+/*
+ * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
+ * @param {string} text The untranslated text, e.g "click here now to %(foo)s".
+ * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
+ * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
+ *
+ * In both variables and tags, the values to substitute with can be either simple strings, React components,
+ * or functions that return the value to use in the substitution (e.g. return a React component). In case of
+ * a tag replacement, the function receives as the argument the text inside the element corresponding to the tag.
+ *
+ * Use tag substitutions if you need to translate text between tags (e.g. "Click here!"), otherwise
+ * you will end up with literal "" in your output, rather than HTML. Note that you can also use variable
+ * substitution to insert React components, but you can't use it to translate text between tags.
+ *
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
+ */
+import React from "react";
+import { type TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
+import counterpart from "counterpart";
+
+import type Translations from "../i18n/strings/en_EN.json";
+
+// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
+import webpackLangJsonUrl from "$webapp/i18n/languages.json";
+
+export { KEY_SEPARATOR, normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
+
+const i18nFolder = "i18n/";
+
+// Control whether to also return original, untranslated strings
+// Useful for debugging and testing
+const ANNOTATE_STRINGS = false;
+
+// We use english strings as keys, some of which contain full stops
+counterpart.setSeparator(KEY_SEPARATOR);
+
+// see `translateWithFallback` for an explanation of fallback handling
+const FALLBACK_LOCALE = "en";
+counterpart.setFallbackLocale(FALLBACK_LOCALE);
+
+/**
+ * A type representing the union of possible keys into the translation file using `|` delimiter to access nested fields.
+ * @example `common|error` to access `error` within the `common` sub-object.
+ * {
+ * "common": {
+ * "error": "Error"
+ * }
+ * }
+ */
+export type TranslationKey = _TranslationKey;
+
+// Function which only purpose is to mark that a string is translatable
+// Does not actually do anything. It's helpful for automatic extraction of translatable strings
+export function _td(s: TranslationKey): TranslationKey {
+ return s;
+}
+
+function isValidTranslation(translated: string): boolean {
+ return typeof translated === "string" && !translated.startsWith("missing translation:");
+}
+
+/**
+ * to improve screen reader experience translations that are not in the main page language
+ * eg a translation that fell back to english from another language
+ * should be wrapped with an appropriate `lang='en'` attribute
+ * counterpart's `translate` doesn't expose a way to determine if the resulting translation
+ * is in the target locale or a fallback locale
+ * for this reason, force fallbackLocale === locale in the first call to translate
+ * and fallback 'manually' so we can mark fallback strings appropriately
+ * */
+const translateWithFallback = (text: string, options?: IVariables): { translated: string; isFallback?: boolean } => {
+ const translated = counterpart.translate(text, { ...options, fallbackLocale: counterpart.getLocale() });
+ if (isValidTranslation(translated)) {
+ return { translated };
+ }
+
+ const fallbackTranslated = counterpart.translate(text, { ...options, locale: FALLBACK_LOCALE });
+ if (isValidTranslation(fallbackTranslated)) {
+ return { translated: fallbackTranslated, isFallback: true };
+ }
+
+ // Even the translation via FALLBACK_LOCALE failed; this can happen if
+ //
+ // 1. The string isn't in the translations dictionary, usually because you're in develop
+ // and haven't run yarn i18n
+ // 2. Loading the translation resources over the network failed, which can happen due to
+ // to network or if the client tried to load a translation that's been removed from the
+ // server.
+ //
+ // At this point, its the lesser evil to show the i18n key which will be in English but not human-friendly,
+ // so the user can still make out *something*, rather than an opaque possibly-untranslated "missing translation" error.
+ return { translated: text, isFallback: true };
+};
+
+// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
+// Takes the same arguments as counterpart.translate()
+function safeCounterpartTranslate(text: string, variables?: IVariables): { translated: string; isFallback?: boolean } {
+ // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
+ // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
+ // It is enough to pass the count variable, but in the future counterpart might make use of other information too
+ const options: IVariables & {
+ interpolate: boolean;
+ } = { ...variables, interpolate: false };
+
+ // Horrible hack to avoid https://github.com/vector-im/element-web/issues/4191
+ // The interpolation library that counterpart uses does not support undefined/null
+ // values and instead will throw an error. This is a problem since everywhere else
+ // in JS land passing undefined/null will simply stringify instead, and when converting
+ // valid ES6 template strings to i18n strings it's extremely easy to pass undefined/null
+ // if there are no existing null guards. To avoid this making the app completely inoperable,
+ // we'll check all the values for undefined/null and stringify them here.
+ if (options && typeof options === "object") {
+ Object.keys(options).forEach((k) => {
+ if (options[k] === undefined) {
+ console.warn("safeCounterpartTranslate called with undefined interpolation name: " + k);
+ options[k] = "undefined";
+ }
+ if (options[k] === null) {
+ console.warn("safeCounterpartTranslate called with null interpolation name: " + k);
+ options[k] = "null";
+ }
+ });
+ }
+ return translateWithFallback(text, options);
+}
+
+/**
+ * The value a variable or tag can take for a translation interpolation.
+ */
+type SubstitutionValue = number | string | React.ReactNode | ((sub: string) => React.ReactNode);
+
+export interface IVariables {
+ count?: number;
+ [key: string]: SubstitutionValue;
+}
+
+export type Tags = Record;
+
+export type TranslatedString = string | React.ReactNode;
+
+// For development/testing purposes it is useful to also output the original string
+// Don't do that for release versions
+const annotateStrings = (result: TranslatedString, translationKey: TranslationKey): TranslatedString => {
+ if (!ANNOTATE_STRINGS) {
+ return result;
+ }
+
+ if (typeof result === "string") {
+ return `@@${translationKey}##${result}@@`;
+ } else {
+ return (
+
+ {result}
+
+ );
+ }
+};
+
+export function _t(text: TranslationKey, variables?: IVariables): string;
+export function _t(text: TranslationKey, variables: IVariables | undefined, tags: Tags): React.ReactNode;
+export function _t(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
+ // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
+ const { translated } = safeCounterpartTranslate(text, variables);
+ const substituted = substitute(translated, variables, tags);
+
+ return annotateStrings(substituted, text);
+}
+
+/**
+ * Utility function to look up a string by its translation key without resolving variables & tags
+ * @param key - the translation key to return the value for
+ */
+export function lookupString(key: TranslationKey): string {
+ return safeCounterpartTranslate(key, {}).translated;
+}
+
+/*
+ * Wraps normal _t function and adds atttribution for translations that used a fallback locale
+ * Wraps translations that fell back from active locale to fallback locale with a `>`
+ * @param {string} text The untranslated text, e.g "click here now to %(foo)s".
+ * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
+ * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
+ *
+ * @return a React component if any non-strings were used in substitutions
+ * or translation used a fallback locale, otherwise a string
+ */
+// eslint-next-line @typescript-eslint/naming-convention
+export function _tDom(text: TranslationKey, variables?: IVariables): TranslatedString;
+export function _tDom(text: TranslationKey, variables: IVariables, tags: Tags): React.ReactNode;
+export function _tDom(text: TranslationKey, variables?: IVariables, tags?: Tags): TranslatedString {
+ // The translation returns text so there's no XSS vector here (no unsafe HTML, no code execution)
+ const { translated, isFallback } = safeCounterpartTranslate(text, variables);
+ const substituted = substitute(translated, variables, tags);
+
+ // wrap en fallback translation with lang attribute for screen readers
+ const result = isFallback ? {substituted} : substituted;
+
+ return annotateStrings(result, text);
+}
+
+/**
+ * Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
+ * replaceable by the translation functions.
+ * @param {string} text The text to sanitize.
+ * @returns {string} The sanitized text.
+ */
+export function sanitizeForTranslation(text: string): string {
+ // Add a non-breaking space so the regex doesn't trigger when translating.
+ return text.replace(/%\(([^)]*)\)/g, "%\xa0($1)");
+}
+
+/*
+ * Similar to _t(), except only does substitutions, and no translation
+ * @param {string} text The text, e.g "click here now to %(foo)s".
+ * @param {object} variables Variable substitutions, e.g { foo: 'bar' }
+ * @param {object} tags Tag substitutions e.g. { 'a': (sub) => {sub} }
+ *
+ * The values to substitute with can be either simple strings, or functions that return the value to use in
+ * the substitution (e.g. return a React component). In case of a tag replacement, the function receives as
+ * the argument the text inside the element corresponding to the tag.
+ *
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
+ */
+export function substitute(text: string, variables?: IVariables): string;
+export function substitute(text: string, variables: IVariables | undefined, tags: Tags | undefined): string;
+export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
+ let result: React.ReactNode | string = text;
+
+ if (variables !== undefined) {
+ const regexpMapping: IVariables = {};
+ for (const variable in variables) {
+ regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
+ }
+ result = replaceByRegexes(result as string, regexpMapping);
+ }
+
+ if (tags !== undefined) {
+ const regexpMapping: Tags = {};
+ for (const tag in tags) {
+ regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
+ }
+ result = replaceByRegexes(result as string, regexpMapping);
+ }
+
+ return result;
+}
+
+/**
+ * Replace parts of a text using regular expressions
+ * @param text - The text on which to perform substitutions
+ * @param mapping - A mapping from regular expressions in string form to replacement string or a
+ * function which will receive as the argument the capture groups defined in the regexp. E.g.
+ * { 'Hello (.?) World': (sub) => sub.toUpperCase() }
+ *
+ * @return a React component if any non-strings were used in substitutions, otherwise a string
+ */
+export function replaceByRegexes(text: string, mapping: IVariables): string;
+export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
+export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
+ // We initially store our output as an array of strings and objects (e.g. React components).
+ // This will then be converted to a string or a at the end
+ const output: SubstitutionValue[] = [text];
+
+ // If we insert any components we need to wrap the output in a span. React doesn't like just an array of components.
+ let shouldWrapInSpan = false;
+
+ for (const regexpString in mapping) {
+ // TODO: Cache regexps
+ const regexp = new RegExp(regexpString, "g");
+
+ // Loop over what output we have so far and perform replacements
+ // We look for matches: if we find one, we get three parts: everything before the match, the replaced part,
+ // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
+ // Otherwise there would be no need for the splitting and we could do simple replacement.
+ let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
+ for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
+ const inputText = output[outputIndex];
+ if (typeof inputText !== "string") {
+ // We might have inserted objects earlier, don't try to replace them
+ continue;
+ }
+
+ // process every match in the string
+ // starting with the first
+ let match = regexp.exec(inputText);
+
+ if (!match) continue;
+ matchFoundSomewhere = true;
+
+ // The textual part before the first match
+ const head = inputText.slice(0, match.index);
+
+ const parts: SubstitutionValue[] = [];
+ // keep track of prevMatch
+ let prevMatch;
+ while (match) {
+ // store prevMatch
+ prevMatch = match;
+ const capturedGroups = match.slice(2);
+
+ let replaced: SubstitutionValue;
+ // If substitution is a function, call it
+ if (mapping[regexpString] instanceof Function) {
+ replaced = ((mapping as Tags)[regexpString] as (...subs: string[]) => string)(...capturedGroups);
+ } else {
+ replaced = mapping[regexpString];
+ }
+
+ if (typeof replaced === "object") {
+ shouldWrapInSpan = true;
+ }
+
+ // Here we also need to check that it actually is a string before comparing against one
+ // The head and tail are always strings
+ if (typeof replaced !== "string" || replaced !== "") {
+ parts.push(replaced);
+ }
+
+ // try the next match
+ match = regexp.exec(inputText);
+
+ // add the text between prevMatch and this one
+ // or the end of the string if prevMatch is the last match
+ let tail;
+ if (match) {
+ const startIndex = prevMatch.index + prevMatch[0].length;
+ tail = inputText.slice(startIndex, match.index);
+ } else {
+ tail = inputText.slice(prevMatch.index + prevMatch[0].length);
+ }
+ if (tail) {
+ parts.push(tail);
+ }
+ }
+
+ // Insert in reverse order as splice does insert-before and this way we get the final order correct
+ // remove the old element at the same time
+ output.splice(outputIndex, 1, ...parts);
+
+ if (head !== "") {
+ // Don't push empty nodes, they are of no use
+ output.splice(outputIndex, 0, head);
+ }
+ }
+ if (!matchFoundSomewhere) {
+ if (
+ // The current regexp did not match anything in the input. Missing
+ // matches is entirely possible because you might choose to show some
+ // variables only in the case of e.g. plurals. It's still a bit
+ // suspicious, and could be due to an error, so log it. However, not
+ // showing count is so common that it's not worth logging. And other
+ // commonly unused variables here, if there are any.
+ regexpString !== "%\\(count\\)s" &&
+ // Ignore the `locale` option which can be used to override the locale
+ // in counterpart
+ regexpString !== "%\\(locale\\)s"
+ ) {
+ console.log(`Could not find ${regexp} in ${text}`);
+ }
+ }
+ }
+
+ if (shouldWrapInSpan) {
+ return React.createElement("span", null, ...(output as Array));
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ return output.join("");
+ }
+}
+
+type Languages = {
+ [lang: string]: string;
+};
+
+/**
+ * Sets the language for the application.
+ * In Element web,`languageHandler.setLanguage` should be used instead.
+ * @param language
+ */
+export async function setLanguage(language: string): Promise {
+ const availableLanguages = await getLangsJson();
+ const chosenLanguage = language in availableLanguages ? language : "en";
+
+ const languageData = await getLanguage(i18nFolder + availableLanguages[chosenLanguage]);
+
+ counterpart.registerTranslations(chosenLanguage, languageData);
+ counterpart.setLocale(chosenLanguage);
+}
+
+interface ICounterpartTranslation {
+ [key: string]:
+ | string
+ | {
+ [pluralisation: string]: string;
+ };
+}
+
+async function getLanguage(langPath: string): Promise {
+ console.log("Loading language from", langPath);
+ const res = await fetch(langPath, { method: "GET" });
+
+ if (!res.ok) {
+ throw new Error(`Failed to load ${langPath}, got ${res.status}`);
+ }
+
+ return res.json();
+}
+
+export async function getLangsJson(): Promise {
+ let url: string;
+ if (typeof webpackLangJsonUrl === "string") {
+ // in Jest this 'url' isn't a URL, so just fall through
+ url = webpackLangJsonUrl;
+ } else {
+ url = i18nFolder + "languages.json";
+ }
+
+ const res = await fetch(url, { method: "GET" });
+
+ if (!res.ok) {
+ throw new Error(`Failed to load ${url}, got ${res.status}`);
+ }
+
+ return res.json();
+}
diff --git a/src/shared-components/useViewModel.ts b/src/shared-components/useViewModel.ts
new file mode 100644
index 0000000000..ef7b8ec0da
--- /dev/null
+++ b/src/shared-components/useViewModel.ts
@@ -0,0 +1,21 @@
+/*
+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 { useSyncExternalStore } from "react";
+
+import { type ViewModel } from "./ViewModel";
+
+/**
+ * A small wrapper around useSyncExternalStore to use a view model in a shared component view
+ * @param vm The view model to use
+ * @returns The current snapshot
+ */
+export function useViewModel(vm: ViewModel): T {
+ // We need to pass the same getSnapshot function as getServerSnapshot as this
+ // is used when making the HTML chat export.
+ return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
+}
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index 672c1b27b4..673ceac20a 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -13,6 +13,7 @@ import {
type MatrixClient,
ClientEvent,
RoomStateEvent,
+ type ReceivedToDeviceMessage,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import {
@@ -360,7 +361,7 @@ export class StopGapWidget extends EventEmitter {
this.client.on(ClientEvent.Event, this.onEvent);
this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.on(RoomStateEvent.Events, this.onStateUpdate);
- this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ this.client.on(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
this.messaging.on(
`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`,
@@ -493,7 +494,7 @@ export class StopGapWidget extends EventEmitter {
this.client.off(ClientEvent.Event, this.onEvent);
this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted);
this.client.off(RoomStateEvent.Events, this.onStateUpdate);
- this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ this.client.off(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage);
}
private onEvent = (ev: MatrixEvent): void => {
@@ -513,10 +514,10 @@ export class StopGapWidget extends EventEmitter {
});
};
- private onToDeviceEvent = async (ev: MatrixEvent): Promise => {
- await this.client.decryptEventIfNeeded(ev);
- if (ev.isDecryptionFailure()) return;
- await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
+ private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise => {
+ const { message, encryptionInfo } = payload;
+ // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent
+ await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null);
};
/**
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index d4c0f8930c..282d6f5d92 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -123,6 +123,9 @@ export class StopGapWidgetDriver extends WidgetDriver {
this.allowedCapabilities.add(MatrixCapabilities.MSC4157SendDelayedEvent);
this.allowedCapabilities.add(MatrixCapabilities.MSC4157UpdateDelayedEvent);
+ this.allowedCapabilities.add(
+ WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomName).raw,
+ );
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
);
diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts
index 802da5b895..f4aef8881f 100644
--- a/src/toasts/SetupEncryptionToast.ts
+++ b/src/toasts/SetupEncryptionToast.ts
@@ -30,13 +30,12 @@ const TOAST_KEY = "setupencryption";
const getTitle = (kind: Kind): string => {
switch (kind) {
- case Kind.SET_UP_ENCRYPTION:
- return _t("encryption|set_up_toast_title");
case Kind.SET_UP_RECOVERY:
return _t("encryption|set_up_recovery");
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_title");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|key_storage_out_of_sync");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage");
@@ -45,12 +44,11 @@ const getTitle = (kind: Kind): string => {
const getIcon = (kind: Kind): string | undefined => {
switch (kind) {
- case Kind.SET_UP_ENCRYPTION:
- return "secure_backup";
case Kind.SET_UP_RECOVERY:
return undefined;
case Kind.VERIFY_THIS_SESSION:
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return "verification_warning";
case Kind.TURN_ON_KEY_STORAGE:
return "key_storage";
@@ -59,13 +57,12 @@ const getIcon = (kind: Kind): string | undefined => {
const getSetupCaption = (kind: Kind): string => {
switch (kind) {
- case Kind.SET_UP_ENCRYPTION:
- return _t("action|continue");
case Kind.SET_UP_RECOVERY:
return _t("action|continue");
case Kind.VERIFY_THIS_SESSION:
return _t("action|verify");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|enter_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|continue");
@@ -79,6 +76,7 @@ const getSetupCaption = (kind: Kind): string => {
const getPrimaryButtonIcon = (kind: Kind): ComponentType> | undefined => {
switch (kind) {
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return KeyIcon;
default:
return;
@@ -88,11 +86,11 @@ const getPrimaryButtonIcon = (kind: Kind): ComponentType {
switch (kind) {
case Kind.SET_UP_RECOVERY:
- return _t("encryption|set_up_recovery_later");
- case Kind.SET_UP_ENCRYPTION:
+ return _t("action|dismiss");
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verification|unverified_sessions_toast_reject");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|forgot_recovery_key");
case Kind.TURN_ON_KEY_STORAGE:
return _t("action|dismiss");
@@ -101,13 +99,12 @@ const getSecondaryButtonLabel = (kind: Kind): string => {
const getDescription = (kind: Kind): string => {
switch (kind) {
- case Kind.SET_UP_ENCRYPTION:
- return _t("encryption|set_up_toast_description");
case Kind.SET_UP_RECOVERY:
return _t("encryption|set_up_recovery_toast_description");
case Kind.VERIFY_THIS_SESSION:
return _t("encryption|verify_toast_description");
case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE:
return _t("encryption|key_storage_out_of_sync_description");
case Kind.TURN_ON_KEY_STORAGE:
return _t("encryption|turn_on_key_storage_description");
@@ -118,10 +115,6 @@ const getDescription = (kind: Kind): string => {
* The kind of toast to show.
*/
export enum Kind {
- /**
- * Prompt the user to set up encryption
- */
- SET_UP_ENCRYPTION = "set_up_encryption",
/**
* Prompt the user to set up a recovery key
*/
@@ -131,9 +124,13 @@ export enum Kind {
*/
VERIFY_THIS_SESSION = "verify_this_session",
/**
- * Prompt the user to enter their recovery key
+ * Prompt the user to enter their recovery key, to retrieve secrets
*/
KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync",
+ /**
+ * Prompt the user to enter their recovery key, to store secrets
+ */
+ KEY_STORAGE_OUT_OF_SYNC_STORE = "key_storage_out_of_sync_store",
/**
* Prompt the user to turn on key storage
*/
@@ -156,53 +153,87 @@ export const showToast = (kind: Kind): void => {
}
const onPrimaryClick = async (): Promise => {
- if (kind === Kind.VERIFY_THIS_SESSION) {
- Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
- } else if (kind == Kind.TURN_ON_KEY_STORAGE) {
- // Open the user settings dialog to the encryption tab
- const payload: OpenToTabPayload = {
- action: Action.ViewUserSettings,
- initialTabId: UserTab.Encryption,
- };
- defaultDispatcher.dispatch(payload);
- } else {
- const modal = Modal.createDialog(
- Spinner,
- undefined,
- "mx_Dialog_spinner",
- /* priority */ false,
- /* static */ true,
- );
- try {
- await accessSecretStorage();
- } catch (error) {
- onAccessSecretStorageFailed(error as Error);
- } finally {
- modal.close();
+ switch (kind) {
+ case Kind.SET_UP_RECOVERY:
+ case Kind.TURN_ON_KEY_STORAGE: {
+ // Open the user settings dialog to the encryption tab
+ const payload: OpenToTabPayload = {
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ };
+ defaultDispatcher.dispatch(payload);
+ break;
+ }
+ case Kind.VERIFY_THIS_SESSION:
+ Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true);
+ break;
+ case Kind.KEY_STORAGE_OUT_OF_SYNC:
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: {
+ const modal = Modal.createDialog(
+ Spinner,
+ undefined,
+ "mx_Dialog_spinner",
+ /* priority */ false,
+ /* static */ true,
+ );
+ try {
+ await accessSecretStorage();
+ } catch (error) {
+ onAccessSecretStorageFailed(kind, error as Error);
+ } finally {
+ modal.close();
+ }
+ break;
}
}
};
const onSecondaryClick = async (): Promise => {
- if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
- // Open the user settings dialog to the encryption tab and start the flow to reset encryption
- const payload: OpenToTabPayload = {
- action: Action.ViewUserSettings,
- initialTabId: UserTab.Encryption,
- props: { initialEncryptionState: "reset_identity_forgot" },
- };
- defaultDispatcher.dispatch(payload);
- } else if (kind === Kind.TURN_ON_KEY_STORAGE) {
- // The user clicked "Dismiss": offer them "Are you sure?"
- const modal = Modal.createDialog(ConfirmKeyStorageOffDialog, undefined, "mx_ConfirmKeyStorageOffDialog");
- const [dismissed] = await modal.finished;
- if (dismissed) {
+ switch (kind) {
+ case Kind.SET_UP_RECOVERY: {
+ // Record that the user doesn't want to set up recovery
const deviceListener = DeviceListener.sharedInstance();
- await deviceListener.recordKeyBackupDisabled();
+ await deviceListener.recordRecoveryDisabled();
deviceListener.dismissEncryptionSetup();
+ break;
}
- } else {
- DeviceListener.sharedInstance().dismissEncryptionSetup();
+ case Kind.KEY_STORAGE_OUT_OF_SYNC: {
+ // Open the user settings dialog to the encryption tab and start the flow to reset encryption
+ const payload: OpenToTabPayload = {
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ props: { initialEncryptionState: "reset_identity_forgot" },
+ };
+ defaultDispatcher.dispatch(payload);
+ break;
+ }
+ case Kind.KEY_STORAGE_OUT_OF_SYNC_STORE: {
+ // Open the user settings dialog to the encryption tab and start the flow to reset 4S
+ const payload: OpenToTabPayload = {
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ props: { initialEncryptionState: "change_recovery_key" },
+ };
+ defaultDispatcher.dispatch(payload);
+ break;
+ }
+ case Kind.TURN_ON_KEY_STORAGE: {
+ // The user clicked "Dismiss": offer them "Are you sure?"
+ const modal = Modal.createDialog(
+ ConfirmKeyStorageOffDialog,
+ undefined,
+ "mx_ConfirmKeyStorageOffDialog",
+ );
+ const [dismissed] = await modal.finished;
+ if (dismissed) {
+ const deviceListener = DeviceListener.sharedInstance();
+ await deviceListener.recordKeyBackupDisabled();
+ deviceListener.dismissEncryptionSetup();
+ }
+ break;
+ }
+ default:
+ DeviceListener.sharedInstance().dismissEncryptionSetup();
}
};
@@ -210,10 +241,16 @@ export const showToast = (kind: Kind): void => {
* We tried to accessSecretStorage, which triggered us to ask for the
* recovery key, but this failed. If the user just gave up, that is fine,
* but if not, that means downloading encryption info from 4S did not fix
- * the problem we identified. Presumably, something is wrong with what
- * they have in 4S: we tell them to reset their identity.
+ * the problem we identified. Presumably, something is wrong with what they
+ * have in 4S. If we were trying to fetch secrets from 4S, we tell them to
+ * reset their identity, to reset everything. If we were trying to store
+ * secrets in 4S, or set up recovery, we tell them to change their recovery
+ * key, to create a new 4S that we can store the secrets in.
*/
- const onAccessSecretStorageFailed = (error: Error): void => {
+ const onAccessSecretStorageFailed = (
+ kind: Kind.KEY_STORAGE_OUT_OF_SYNC | Kind.KEY_STORAGE_OUT_OF_SYNC_STORE,
+ error: Error,
+ ): void => {
if (error instanceof AccessCancelledError) {
// The user cancelled the dialog - just allow it to close
} else {
@@ -221,7 +258,10 @@ export const showToast = (kind: Kind): void => {
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,
- props: { initialEncryptionState: "reset_identity_sync_failed" },
+ props: {
+ initialEncryptionState:
+ kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "reset_identity_sync_failed" : "change_recovery_key",
+ },
};
defaultDispatcher.dispatch(payload);
}
diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts
index 9c387b16b0..ecaa7e06ec 100644
--- a/src/utils/EventUtils.ts
+++ b/src/utils/EventUtils.ts
@@ -30,6 +30,7 @@ import { type TimelineRenderingType } from "../contexts/RoomContext";
import { launchPollEditor } from "../components/views/messages/MPollBody";
import { Action } from "../dispatcher/actions";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
+import ModuleApi from "../modules/Api";
/**
* Returns whether an event should allow actions like reply, reactions, edit, etc.
@@ -77,6 +78,10 @@ export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent)
return false;
}
+ if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) {
+ return false;
+ }
+
const { msgtype, body } = mxEvent.getOriginalContent();
return (
M_POLL_START.matches(mxEvent.getType()) ||
diff --git a/src/vector/mobile_guide/assets/app-store-badge.svg b/src/vector/mobile_guide/assets/app-store-badge.svg
new file mode 100755
index 0000000000..072b425a1a
--- /dev/null
+++ b/src/vector/mobile_guide/assets/app-store-badge.svg
@@ -0,0 +1,46 @@
+
diff --git a/src/vector/mobile_guide/assets/bottom-gradient.svg b/src/vector/mobile_guide/assets/bottom-gradient.svg
new file mode 100644
index 0000000000..0740440946
--- /dev/null
+++ b/src/vector/mobile_guide/assets/bottom-gradient.svg
@@ -0,0 +1,53 @@
+
diff --git a/src/vector/mobile_guide/assets/element-logo.svg b/src/vector/mobile_guide/assets/element-logo.svg
new file mode 100644
index 0000000000..d2cb52e498
--- /dev/null
+++ b/src/vector/mobile_guide/assets/element-logo.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/vector/mobile_guide/assets/google-play-badge.svg b/src/vector/mobile_guide/assets/google-play-badge.svg
new file mode 100644
index 0000000000..fac62d70ed
--- /dev/null
+++ b/src/vector/mobile_guide/assets/google-play-badge.svg
@@ -0,0 +1,23 @@
+
+
+
diff --git a/src/vector/mobile_guide/index.css b/src/vector/mobile_guide/index.css
new file mode 100644
index 0000000000..cb51c3fb24
--- /dev/null
+++ b/src/vector/mobile_guide/index.css
@@ -0,0 +1,183 @@
+/*
+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 url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css");
+
+html {
+ min-height: 100%;
+ position: relative;
+}
+
+body {
+ background: var(--cpd-color-bg-canvas-default);
+ max-width: 680px;
+ margin: var(--cpd-space-0x) auto;
+ padding-bottom: 178px; /* Match the height of mx_BottomGradient */
+ font-family: var(--cpd-font-family-sans);
+ font-size: var(--cpd-font-size-body-lg); /* Design says 16px, this is 17px */
+ color: var(--cpd-color-text-primary);
+}
+
+hr {
+ border: none;
+ height: var(--cpd-border-width-1);
+ background-color: var(
+ --cpd-color-bg-subtle-primary /* Design uses Border token from "Compound Marketing" set, but this matches. */
+ );
+ color: var(
+ --cpd-color-bg-subtle-primary /* Design uses Border token from "Compound Marketing" set, but this matches. */
+ );
+ margin: 0;
+}
+
+p {
+ margin: var(--cpd-space-1x) var(--cpd-space-0x);
+ padding: var(--cpd-space-0x);
+}
+
+.mx_Button {
+ border: 0;
+ border-radius: 100px;
+ min-width: 80px;
+ background-color: var(--cpd-color-bg-action-primary-rest);
+ color: var(--cpd-color-text-on-solid-primary);
+ cursor: pointer;
+ padding: 12px 22px;
+ word-break: break-word;
+ text-decoration: none;
+}
+
+#deep_link_button {
+ margin-top: 12px;
+ display: inline-block;
+ width: auto;
+ box-sizing: border-box;
+}
+
+.mx_StoreLinks {
+ margin: 15px 0 12px 0;
+}
+
+.mx_StoreBadge {
+ text-decoration: none !important;
+ margin: 16px 16px 16px 0px;
+}
+
+#f_droid_link {
+ color: var(--cpd-color-text-action-accent);
+ font-weight: bold;
+ text-decoration: none;
+}
+
+#f_droid_link:visited {
+ color: var(--cpd-color-text-action-accent);
+}
+
+.mx_HomePage_header {
+ color: var(--cpd-color-text-secondary);
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding-top: 48px;
+ padding-bottom: 48px;
+}
+
+.mx_HomePage_header #header_title {
+ margin-top: 8px;
+ margin-bottom: 0px;
+}
+
+.mx_HomePage h3 {
+ margin-top: 30px;
+}
+
+.mx_HomePage_col {
+ display: flex;
+ flex-direction: row;
+}
+
+.mx_HomePage_row {
+ flex: 1 1 0;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ align-items: flex-start;
+}
+
+.mx_HomePage_container {
+ margin: 10px 20px;
+}
+
+.mx_HomePage_errorContainer {
+ display: none; /* shown in JS if needed */
+ margin: 20px;
+ border: var(--cpd-border-width-1) solid var(--cpd-color-border-critical-primary);
+ background-color: var(--cpd-color-bg-critical-subtle);
+ padding: 5px;
+}
+
+.mx_HomePage_container h1,
+.mx_HomePage_container h2,
+.mx_HomePage_container h3,
+.mx_HomePage_container h4 {
+ font-weight: var(--cpd-font-weight-semibold);
+ font-size: var(--cpd-font-size-body-lg); /* Design says 16px, this is 17px */
+ margin-bottom: 8px;
+ margin-top: 4px;
+}
+
+.mx_Spacer {
+ margin-top: 48px;
+}
+
+.mx_DesktopLink {
+ color: var(--cpd-color-text-action-accent);
+ font-weight: var(--cpd-font-weight-semibold);
+ text-decoration: none;
+}
+
+/*
+ * The bottom gradient is a full-width background image that stretches horizontally across the page.
+ * It is positioned pinned to the bottom of the viewport unless the content is taller than the viewport,
+ * in which case it will be pinned to the bottom of the content.
+ */
+.mx_BottomGradient {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ width: 100vw;
+ height: 178px; /* Match the height of assets/bottom-gradient.svg so the gradient only stretches horizontally */
+ background-image: url("./assets/bottom-gradient.svg");
+ background-size: 100% 100%;
+ background-repeat: no-repeat;
+ z-index: -1;
+ margin-left: calc(50% - 50vw); /* Center the gradient regardless of body width */
+}
+
+.mx_HomePage_step_number {
+ display: flex;
+ align-items: flex-start;
+ margin-right: 8px;
+}
+
+.mx_HomePage_step_number span {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: var(--cpd-space-6x);
+ height: var(--cpd-space-6x);
+ border-radius: 50%;
+ border: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary); /* Not a border token, but matches the Design (Border token from the "Compound Marketing" set). */
+ background-color: transparent;
+ color: var(--cpd-color-text-secondary);
+ font-size: var(--cpd-font-size-body-md); /* Design says 14px, this is 15px */
+}
+
+#step2_description {
+ color: var(--cpd-color-text-secondary);
+}
diff --git a/src/vector/mobile_guide/index.html b/src/vector/mobile_guide/index.html
index d58842d6a6..3bd8a8e20d 100644
--- a/src/vector/mobile_guide/index.html
+++ b/src/vector/mobile_guide/index.html
@@ -1,141 +1,13 @@
+
Element Mobile Guide
-
-
-
+
@@ -144,648 +16,90 @@
+
+
diff --git a/src/vector/mobile_guide/index.ts b/src/vector/mobile_guide/index.ts
index ae769039a8..e9732b4035 100644
--- a/src/vector/mobile_guide/index.ts
+++ b/src/vector/mobile_guide/index.ts
@@ -5,9 +5,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 "./index.css";
+import "@fontsource/inter/400.css";
+import "@fontsource/inter/600.css";
+
import { logger } from "matrix-js-sdk/src/logger";
import { getVectorConfig } from "../getconfig";
+import { MobileAppVariant, mobileApps, updateMobilePage } from "./mobile-apps.ts";
function onBackToElementClick(): void {
// Cookie should expire in 4 hours
@@ -38,18 +43,19 @@ function renderConfigError(message: string): void {
}
async function initPage(): Promise {
- document.getElementById("back_to_element_button")!.onclick = onBackToElementClick;
-
const config = await getVectorConfig("..");
// We manually parse the config similar to how validateServerConfig works because
// calling that function pulls in roughly 4mb of JS we don't use.
const wkConfig = config?.["default_server_config"]; // overwritten later under some conditions
- const serverName = config?.["default_server_name"];
+ let serverName = config?.["default_server_name"];
const defaultHsUrl = config?.["default_hs_url"];
const defaultIsUrl = config?.["default_is_url"];
+ const appVariant = (config?.["mobile_guide_app_variant"] as MobileAppVariant) ?? MobileAppVariant.X;
+ const metadata = mobileApps[appVariant] ?? mobileApps[MobileAppVariant.X]; // Additional fallback in case mobile_guide_app_variant has an unexpected value.
+
const incompatibleOptions = [wkConfig, serverName, defaultHsUrl].filter((i) => !!i);
if (defaultHsUrl && (wkConfig || serverName)) {
return renderConfigError(
@@ -66,6 +72,7 @@ async function initPage(): Promise {
if (!serverName && typeof wkConfig?.["m.homeserver"]?.["base_url"] === "string") {
hsUrl = wkConfig["m.homeserver"]["base_url"];
+ serverName = wkConfig["m.homeserver"]["server_name"];
if (typeof wkConfig["m.identity_server"]?.["base_url"] === "string") {
isUrl = wkConfig["m.identity_server"]["base_url"];
@@ -110,21 +117,21 @@ async function initPage(): Promise {
if (hsUrl && !hsUrl.endsWith("/")) hsUrl += "/";
if (isUrl && !isUrl.endsWith("/")) isUrl += "/";
- if (hsUrl !== "https://matrix.org/") {
- let url = "https://mobile.element.io?hs_url=" + encodeURIComponent(hsUrl);
+ let deepLinkUrl = `https://mobile.element.io${metadata.deepLinkPath}`;
+ if (metadata.usesLegacyDeepLink) {
+ deepLinkUrl += `?hs_url=${encodeURIComponent(hsUrl)}`;
if (isUrl) {
- document.getElementById("custom_is")!.style.display = "block";
- document.getElementById("is_url")!.style.display = "block";
- document.getElementById("is_url")!.innerText = isUrl;
- url += "&is_url=" + encodeURIComponent(isUrl ?? "");
+ deepLinkUrl += `&is_url=${encodeURIComponent(isUrl)}`;
}
-
- (document.getElementById("configure_element_button") as HTMLAnchorElement).href = url;
- document.getElementById("step1_heading")!.innerHTML = "1: Install the app";
- document.getElementById("step2_container")!.style.display = "block";
- document.getElementById("hs_url")!.innerText = hsUrl;
+ } else if (serverName) {
+ deepLinkUrl += `?account_provider=${serverName}`;
}
+
+ // Not part of updateMobilePage as the link is only shown on mobile_guide and not on mobile.element.io
+ document.getElementById("back_to_element_button")!.onclick = onBackToElementClick;
+
+ updateMobilePage(metadata, deepLinkUrl, serverName ?? hsUrl);
}
void initPage();
diff --git a/src/vector/mobile_guide/mobile-apps.ts b/src/vector/mobile_guide/mobile-apps.ts
new file mode 100644
index 0000000000..ff3430d6b7
--- /dev/null
+++ b/src/vector/mobile_guide/mobile-apps.ts
@@ -0,0 +1,88 @@
+/*
+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.
+*/
+
+/*
+ * Shared code that is used by the mobile guide and the mobile.element.io site.
+ */
+
+export enum MobileAppVariant {
+ Classic = "element-classic",
+ X = "element",
+ Pro = "element-pro",
+}
+
+export interface MobileAppMetadata {
+ name: string;
+ appleAppId: string;
+ appStoreUrl: string;
+ playStoreUrl: string;
+ fDroidUrl?: string;
+ deepLinkPath: string;
+ usesLegacyDeepLink: boolean;
+ isProApp: boolean;
+}
+
+export const mobileApps: Record = {
+ [MobileAppVariant.Classic]: {
+ name: "Element",
+ appleAppId: "id1083446067",
+ appStoreUrl: "https://apps.apple.com/app/element-messenger/id1083446067",
+ playStoreUrl: "https://play.google.com/store/apps/details?id=im.vector.app",
+ fDroidUrl: "https://f-droid.org/packages/im.vector.app",
+ deepLinkPath: "",
+ usesLegacyDeepLink: true,
+ isProApp: false,
+ },
+ [MobileAppVariant.X]: {
+ name: "Element X",
+ appleAppId: "id1631335820",
+ appStoreUrl: "https://apps.apple.com/app/element-x-secure-chat-call/id1631335820",
+ playStoreUrl: "https://play.google.com/store/apps/details?id=io.element.android.x",
+ fDroidUrl: "https://f-droid.org/packages/io.element.android.x",
+ deepLinkPath: "/element",
+ usesLegacyDeepLink: false,
+ isProApp: false,
+ },
+ [MobileAppVariant.Pro]: {
+ name: "Element Pro",
+ appleAppId: "id6502951615",
+ appStoreUrl: "https://apps.apple.com/app/element-pro-for-work/id6502951615",
+ playStoreUrl: "https://play.google.com/store/apps/details?id=io.element.enterprise",
+ deepLinkPath: "/element-pro",
+ usesLegacyDeepLink: false,
+ isProApp: true,
+ },
+};
+
+export function updateMobilePage(metadata: MobileAppMetadata, deepLinkUrl: string, server: string | undefined): void {
+ const appleMeta = document.querySelector('meta[name="apple-itunes-app"]') as Element;
+ appleMeta.setAttribute("content", `app-id=${metadata.appleAppId}`);
+
+ if (server) {
+ (document.getElementById("header_title") as HTMLHeadingElement).innerText = `Join ${server} on Element`;
+ }
+ (document.getElementById("app_store_link") as HTMLAnchorElement).href = metadata.appStoreUrl;
+ (document.getElementById("play_store_link") as HTMLAnchorElement).href = metadata.playStoreUrl;
+
+ if (metadata.fDroidUrl) {
+ (document.getElementById("f_droid_link") as HTMLAnchorElement).href = metadata.fDroidUrl;
+ } else {
+ document.getElementById("f_droid_section")!.style.display = "none";
+ }
+
+ const step1Heading = document.getElementById("step1_heading")!;
+ step1Heading.innerHTML = step1Heading!.innerHTML.replace("Element", metadata.name);
+
+ // Step 2 is only shown on the mobile guide, not on mobile.element.io
+ if (document.getElementById("step2_container")) {
+ document.getElementById("step2_container")!.style.display = "block";
+ if (metadata.isProApp) {
+ document.getElementById("step2_description")!.innerHTML = "Use your work email to join";
+ }
+ (document.getElementById("deep_link_button") as HTMLAnchorElement).href = deepLinkUrl;
+ }
+}
diff --git a/src/vector/platform/ElectronPlatform.tsx b/src/vector/platform/ElectronPlatform.tsx
index 2bacbe337d..bef544d15d 100644
--- a/src/vector/platform/ElectronPlatform.tsx
+++ b/src/vector/platform/ElectronPlatform.tsx
@@ -1,5 +1,5 @@
/*
-Copyright 2024 New Vector Ltd.
+Copyright 2024-2025 New Vector Ltd.
Copyright 2022 Šimon Brandner
Copyright 2018-2021 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
@@ -42,6 +42,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import { SeshatIndexManager } from "./SeshatIndexManager";
import { IPCManager } from "./IPCManager";
import { _t } from "../../languageHandler";
+import { BadgeOverlayRenderer } from "../../favicon";
interface SquirrelUpdate {
releaseNotes: string;
@@ -87,10 +88,11 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
- private readonly initialised: Promise;
+ public readonly initialised: Promise;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
+ private badgeOverlayRenderer?: BadgeOverlayRenderer;
private config!: IConfigOptions;
private supportedSettings?: Record;
@@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform {
}
private async initialise(): Promise {
- const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
+ const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } =
+ await this.electron.initialise();
this.protocol = protocol;
this.sessionId = sessionId;
this.config = config;
this.supportedSettings = supportedSettings;
+ if (supportsBadgeOverlay) {
+ this.badgeOverlayRenderer = new BadgeOverlayRenderer();
+ }
}
public async getConfig(): Promise {
@@ -249,8 +255,42 @@ export default class ElectronPlatform extends BasePlatform {
public setNotificationCount(count: number): void {
if (this.notificationCount === count) return;
super.setNotificationCount(count);
+ if (this.badgeOverlayRenderer) {
+ this.badgeOverlayRenderer
+ .render(count)
+ .then((buffer) => {
+ this.electron.send("setBadgeCount", count, buffer);
+ })
+ .catch((ex) => {
+ logger.warn("Unable to generate badge overlay", ex);
+ });
+ } else {
+ this.electron.send("setBadgeCount", count);
+ }
+ }
- this.electron.send("setBadgeCount", count);
+ public setErrorStatus(errorDidOccur: boolean): void {
+ if (!this.badgeOverlayRenderer) {
+ super.setErrorStatus(errorDidOccur);
+ return;
+ }
+ // Check before calling super so we don't override the previous state.
+ if (this.errorDidOccur !== errorDidOccur) {
+ super.setErrorStatus(errorDidOccur);
+ let promise: Promise;
+ if (errorDidOccur) {
+ promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00");
+ } else {
+ promise = this.badgeOverlayRenderer.render(this.notificationCount);
+ }
+ promise
+ .then((buffer) => {
+ this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur);
+ })
+ .catch((ex) => {
+ logger.warn("Unable to generate badge overlay", ex);
+ });
+ }
}
public supportsNotifications(): boolean {
diff --git a/src/verification.ts b/src/verification.ts
index 5dc3ea2979..e3e6fb2293 100644
--- a/src/verification.ts
+++ b/src/verification.ts
@@ -7,35 +7,24 @@ Please see LICENSE files in the repository root for full details.
*/
import { type User, type MatrixClient, type RoomMember } from "matrix-js-sdk/src/matrix";
-import { CrossSigningKey, type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
+import { type VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import dis from "./dispatcher/dispatcher";
import { RightPanelPhases } from "./stores/right-panel/RightPanelStorePhases";
-import { accessSecretStorage } from "./SecurityManager";
import RightPanelStore from "./stores/right-panel/RightPanelStore";
import { type IRightPanelCardState } from "./stores/right-panel/RightPanelStoreIPanelState";
import { findDMForUser } from "./utils/dm/findDMForUser";
-async function enable4SIfNeeded(matrixClient: MatrixClient): Promise {
- const crypto = matrixClient.getCrypto();
- if (!crypto) return false;
- const usk = await crypto.getCrossSigningKeyId(CrossSigningKey.UserSigning);
- if (!usk) {
- await accessSecretStorage();
- return false;
- }
-
- return true;
-}
-
+/**
+ * Verify another user.
+ *
+ * Note: cross-signing must be set up before calling this function.
+ */
export async function verifyUser(matrixClient: MatrixClient, user: User): Promise {
if (matrixClient.isGuest()) {
dis.dispatch({ action: "require_registration" });
return;
}
- if (!(await enable4SIfNeeded(matrixClient))) {
- return;
- }
const existingRequest = pendingVerificationRequestForUser(matrixClient, user);
setRightPanel({ member: user, verificationRequest: existingRequest });
}
diff --git a/src/viewmodels/SubscriptionViewModel.ts b/src/viewmodels/SubscriptionViewModel.ts
new file mode 100644
index 0000000000..65147d4cc4
--- /dev/null
+++ b/src/viewmodels/SubscriptionViewModel.ts
@@ -0,0 +1,56 @@
+/*
+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 { type ViewModel } from "../shared-components/ViewModel";
+import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
+
+export abstract class SubscriptionViewModel implements ViewModel {
+ protected subs: ViewModelSubscriptions;
+
+ protected constructor() {
+ this.subs = new ViewModelSubscriptions(
+ this.addDownstreamSubscriptionWrapper,
+ this.removeDownstreamSubscriptionWrapper,
+ );
+ }
+
+ public subscribe = (listener: () => void): (() => void) => {
+ return this.subs.add(listener);
+ };
+
+ /**
+ * Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
+ * has a bound `this` context.
+ */
+ private addDownstreamSubscriptionWrapper = (): void => {
+ this.addDownstreamSubscription();
+ };
+
+ /**
+ * Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
+ * in the constructor.
+ */
+ private removeDownstreamSubscriptionWrapper = (): void => {
+ this.removeDownstreamSubscription();
+ };
+
+ /**
+ * Called when the first listener subscribes: the subclass should set up any necessary subscriptions
+ * to call this.subs.emit() when the snapshot changes.
+ */
+ protected abstract addDownstreamSubscription(): void;
+
+ /**
+ * Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
+ */
+ protected abstract removeDownstreamSubscription(): void;
+
+ /**
+ * Returns the current snapshot of the view model.
+ */
+ public abstract getSnapshot: () => T;
+}
diff --git a/src/viewmodels/ViewModelSubscriptions.ts b/src/viewmodels/ViewModelSubscriptions.ts
new file mode 100644
index 0000000000..97373fcf9f
--- /dev/null
+++ b/src/viewmodels/ViewModelSubscriptions.ts
@@ -0,0 +1,50 @@
+/*
+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.
+*/
+
+/**
+ * Utility class for view models to manage suscriptions to their updates
+ */
+export class ViewModelSubscriptions {
+ private listeners = new Set<() => void>();
+
+ /**
+ * @param subscribeCallback Called when the first listener subscribes.
+ * @param unsubscribeCallback Called when the last listener unsubscribes.
+ */
+ public constructor(
+ private subscribeCallback: () => void,
+ private unsubscribeCallback: () => void,
+ ) {}
+
+ /**
+ * Subscribe to changes in the view model.
+ * @param listener Will be called whenever the snapshot changes.
+ * @returns A function to unsubscribe from the view model updates.
+ */
+ public add = (listener: () => void): (() => void) => {
+ this.listeners.add(listener);
+ if (this.listeners.size === 1) {
+ this.subscribeCallback();
+ }
+
+ return () => {
+ this.listeners.delete(listener);
+ if (this.listeners.size === 0) {
+ this.unsubscribeCallback();
+ }
+ };
+ };
+
+ /**
+ * Emit an update to all subscribed listeners.
+ */
+ public emit = (): void => {
+ for (const listener of this.listeners) {
+ listener();
+ }
+ };
+}
diff --git a/src/viewmodels/event-tiles/TextualEventViewModel.ts b/src/viewmodels/event-tiles/TextualEventViewModel.ts
new file mode 100644
index 0000000000..d2f56482d7
--- /dev/null
+++ b/src/viewmodels/event-tiles/TextualEventViewModel.ts
@@ -0,0 +1,38 @@
+/*
+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 { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
+
+import { type EventTileTypeProps } from "../../events/EventTileFactory";
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+import { textForEvent } from "../../TextForEvent";
+import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
+import { SubscriptionViewModel } from "../SubscriptionViewModel";
+
+export class TextualEventViewModel extends SubscriptionViewModel {
+ public constructor(private eventTileProps: EventTileTypeProps) {
+ super();
+ }
+
+ protected addDownstreamSubscription = (): void => {
+ this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
+ };
+
+ protected removeDownstreamSubscription = (): void => {
+ this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
+ };
+
+ public getSnapshot = (): TextualEventViewSnapshot => {
+ const text = textForEvent(
+ this.eventTileProps.mxEvent,
+ MatrixClientPeg.safeGet(),
+ true,
+ this.eventTileProps.showHiddenEvents,
+ );
+ return text;
+ };
+}
diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts
index cdda7b7dea..658536825b 100644
--- a/test/test-utils/client.ts
+++ b/test/test-utils/client.ts
@@ -153,7 +153,11 @@ export const mockClientMethodsCrypto = (): Partial<
> => ({
isKeyBackupKeyStored: jest.fn(),
getCrossSigningCacheCallbacks: jest.fn().mockReturnValue({ getCrossSigningKeyCache: jest.fn() }),
- secretStorage: { hasKey: jest.fn(), isStored: jest.fn().mockResolvedValue(null) },
+ secretStorage: {
+ hasKey: jest.fn(),
+ isStored: jest.fn().mockResolvedValue(null),
+ getDefaultKeyId: jest.fn().mockResolvedValue(null),
+ },
getCrypto: jest.fn().mockReturnValue({
getUserDeviceInfo: jest.fn(),
getCrossSigningStatus: jest.fn().mockResolvedValue({
diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts
index dd3e5e6a62..fef20bddad 100644
--- a/test/test-utils/utilities.ts
+++ b/test/test-utils/utilities.ts
@@ -188,35 +188,11 @@ export const waitEnoughCyclesForModal = async ({
};
/**
- * A horrible hack necessary to make sure modals don't leak and pollute tests.
- * `jest-matrix-react` automatic cleanup function does not pick up the async modal
- * rendering and the modals don't unmount when the component unmounts. We should strive
- * to fix this.
+ * Clears all modals that are currently open.
*/
export const clearAllModals = async (): Promise => {
// Prevent modals from leaking and polluting other tests
- let keepClosingModals = true;
- while (keepClosingModals) {
- keepClosingModals = await act(() => Modal.closeCurrentModal());
-
- // Then wait for the screen to update (probably React rerender and async/await).
- // Important for tests using Jest fake timers to not get into an infinite loop
- // of removing the same modal because the promises don't flush otherwise.
- //
- // XXX: Maybe in the future with Jest 29.5.0+, we could use `runAllTimersAsync` instead.
-
- // this is called in some places where timers are not faked
- // which causes a lot of noise in the console
- // to make a hack even hackier check if timers are faked using a weird trick from github
- // then call the appropriate promise flusher
- // https://github.com/facebook/jest/issues/10555#issuecomment-1136466942
- const jestTimersFaked = setTimeout.name === "setTimeout";
- if (jestTimersFaked) {
- await flushPromisesWithFakeTimers();
- } else {
- await flushPromises();
- }
- }
+ act(() => Modal.forceCloseAllModals());
};
/** Install a stub object at `navigator.mediaDevices` */
diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts
index 62221d0665..2713423051 100644
--- a/test/unit-tests/DeviceListener-test.ts
+++ b/test/unit-tests/DeviceListener-test.ts
@@ -307,15 +307,6 @@ describe("DeviceListener", () => {
jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true);
});
- it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
- mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
- mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
- mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
-
- await createAndStart();
- expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
- });
-
it("hides setup encryption toast when it is dismissed", async () => {
const instance = await createAndStart();
instance.dismissEncryptionSetup();
@@ -360,7 +351,15 @@ describe("DeviceListener", () => {
);
});
- it("shows an out-of-sync toast when one of the secrets is missing", async () => {
+ it("hides setup encryption toast when cross signing and secret storage are ready", async () => {
+ mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
+ mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
+
+ await createAndStart();
+ expect(SetupEncryptionToast.hideToast).toHaveBeenCalled();
+ });
+
+ it("shows an out-of-sync toast when one of the secrets is missing locally", async () => {
mockCrypto!.getCrossSigningStatus.mockResolvedValue({
publicKeysOnDevice: true,
privateKeysInSecretStorage: true,
@@ -378,7 +377,7 @@ describe("DeviceListener", () => {
);
});
- it("hides the out-of-sync toast when one of the secrets is missing", async () => {
+ it("hides the out-of-sync toast after we receive the missing secrets", async () => {
mockCrypto!.isSecretStorageReady.mockResolvedValue(true);
mockCrypto!.getActiveSessionBackupVersion.mockResolvedValue("1");
@@ -427,6 +426,18 @@ describe("DeviceListener", () => {
SetupEncryptionToast.Kind.SET_UP_RECOVERY,
);
});
+
+ it("shows an out-of-sync toast when one of the secrets is missing from 4S", async () => {
+ mockCrypto.getKeyBackupInfo.mockResolvedValue({} as unknown as KeyBackupInfo);
+ mockCrypto.getActiveSessionBackupVersion.mockResolvedValue("1");
+ mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("foo");
+
+ await createAndStart();
+
+ expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(
+ SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC_STORE,
+ );
+ });
});
});
@@ -448,6 +459,16 @@ describe("DeviceListener", () => {
});
it("dispatches keybackup event when key backup is not enabled", async () => {
+ mockCrypto!.isCrossSigningReady.mockResolvedValue(true);
+
+ // current device is verified
+ mockCrypto!.getDeviceVerificationStatus.mockResolvedValue(
+ new DeviceVerificationStatus({
+ trustCrossSignedDevices: true,
+ crossSigningVerified: true,
+ }),
+ );
+
mockCrypto.getActiveSessionBackupVersion.mockResolvedValue(null);
mockClient.getAccountDataFromServer.mockImplementation((eventType) =>
eventType === BACKUP_DISABLED_ACCOUNT_DATA_KEY ? ({ disabled: true } as any) : null,
@@ -480,6 +501,15 @@ describe("DeviceListener", () => {
});
});
+ it("sets the recovery account data when we call recordRecoveryDisabled", async () => {
+ const instance = await createAndStart();
+ await instance.recordRecoveryDisabled();
+
+ expect(mockClient.setAccountData).toHaveBeenCalledWith("io.element.recovery", {
+ enabled: false,
+ });
+ });
+
describe("when crypto is in use and set up", () => {
beforeEach(() => {
// Encryption is in use
diff --git a/test/unit-tests/SecurityManager-test.ts b/test/unit-tests/SecurityManager-test.ts
index 8c1671b52e..20d93b2c24 100644
--- a/test/unit-tests/SecurityManager-test.ts
+++ b/test/unit-tests/SecurityManager-test.ts
@@ -67,6 +67,17 @@ describe("SecurityManager", () => {
await accessSecretStorage(jest.fn());
}).rejects.toThrow("End-to-end encryption is disabled - unable to access secret storage");
});
+
+ it("throws if there is no 4S", async () => {
+ // Given a client with no default 4S key ID
+ stubClient();
+
+ // When I run accessSecretStorage
+ // Then we throw an error
+ await expect(async () => {
+ await accessSecretStorage(jest.fn());
+ }).rejects.toThrow("Secret storage has not been created yet");
+ });
});
it("should show CreateSecretStorageDialog if forceReset=true", async () => {
diff --git a/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx b/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx
index ec5e714286..8779748b8e 100644
--- a/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx
+++ b/test/unit-tests/async-components/dialogs/security/RecoveryMethodRemovedDialog-test.tsx
@@ -9,7 +9,9 @@ import React from "react";
import { render, screen, fireEvent, waitFor } from "jest-matrix-react";
import RecoveryMethodRemovedDialog from "../../../../../src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog";
-import Modal from "../../../../../src/Modal.tsx";
+import dispatch from "../../../../../src/dispatcher/dispatcher";
+import { Action } from "../../../../../src/dispatcher/actions";
+import { UserTab } from "../../../../../src/components/views/dialogs/UserTab";
describe("", () => {
afterEach(() => {
@@ -18,16 +20,15 @@ describe("", () => {
it("should open CreateKeyBackupDialog on primary action click", async () => {
const onFinished = jest.fn();
- const spy = jest.spyOn(Modal, "createDialog");
- jest.mock("../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog", () => ({
- __test: true,
- __esModule: true,
- default: () => mocked dialog,
- }));
+ jest.spyOn(dispatch, "dispatch");
render();
fireEvent.click(screen.getByRole("button", { name: "Set up Secure Messages" }));
- await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
- expect((spy.mock.lastCall![0] as any)._payload._result).toEqual(expect.objectContaining({ __test: true }));
+ await waitFor(() =>
+ expect(dispatch.dispatch).toHaveBeenCalledWith({
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ }),
+ );
});
});
diff --git a/test/unit-tests/components/structures/FileDropTarget-test.tsx b/test/unit-tests/components/structures/FileDropTarget-test.tsx
new file mode 100644
index 0000000000..20b02f9fa5
--- /dev/null
+++ b/test/unit-tests/components/structures/FileDropTarget-test.tsx
@@ -0,0 +1,59 @@
+/*
+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 { mocked } from "jest-mock";
+import { render, fireEvent } from "jest-matrix-react";
+import { Room } from "matrix-js-sdk/src/matrix";
+
+import FileDropTarget from "../../../../src/components/structures/FileDropTarget.tsx";
+import { stubClient } from "../../../test-utils";
+
+describe("FileDropTarget", () => {
+ let room: Room;
+ beforeEach(() => {
+ const client = stubClient();
+ room = new Room("!roomId:example.com", client, client.getUserId()!);
+ room.currentState.maySendMessage = jest.fn().mockReturnValue(true);
+ });
+
+ it("should render nothing when idle", () => {
+ const element = document.createElement("div");
+ const onFileDrop = jest.fn();
+
+ const { asFragment } = render();
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it("should render drop file prompt on mouse over with file if permissions allow", () => {
+ const element = document.createElement("div");
+ const onFileDrop = jest.fn();
+ mocked(room.currentState.maySendMessage).mockReturnValue(true);
+
+ const { asFragment } = render();
+ fireEvent.dragEnter(element, {
+ dataTransfer: {
+ types: ["Files"],
+ },
+ });
+ expect(asFragment()).toMatchSnapshot();
+ });
+
+ it("should not render drop file prompt on mouse over with file if permissions do not allow", () => {
+ const element = document.createElement("div");
+ const onFileDrop = jest.fn();
+ mocked(room.currentState.maySendMessage).mockReturnValue(false);
+
+ const { asFragment } = render();
+ fireEvent.dragEnter(element, {
+ dataTransfer: {
+ types: ["Files"],
+ },
+ });
+ expect(asFragment()).toMatchSnapshot();
+ });
+});
diff --git a/test/unit-tests/components/structures/MatrixChat-test.tsx b/test/unit-tests/components/structures/MatrixChat-test.tsx
index 94fc9a925a..c813143142 100644
--- a/test/unit-tests/components/structures/MatrixChat-test.tsx
+++ b/test/unit-tests/components/structures/MatrixChat-test.tsx
@@ -1040,7 +1040,7 @@ describe("", () => {
localStorage.removeItem("must_verify_device");
});
- it("should show the complete security screen if unskippable verification is enabled", async () => {
+ it("should show the Complete Security screen if unskippable verification is enabled", async () => {
// Given we have force verification on, and an existing logged-in session
// that is not verified (see beforeEach())
@@ -1053,7 +1053,6 @@ describe("", () => {
// Sanity: we are not racing with another screen update, so this heading stays visible
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
});
-
it("should not open app after cancelling device verify if unskippable verification is on", async () => {
// See https://github.com/element-hq/element-web/issues/29230
// We used to allow bypassing force verification by choosing "Verify with
@@ -1081,6 +1080,50 @@ describe("", () => {
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
});
+ describe("when query params have a loginToken", () => {
+ const loginToken = "test-login-token";
+ const realQueryParams = {
+ loginToken,
+ };
+
+ let loginClient!: ReturnType;
+ const deviceId = "test-device-id";
+ const accessToken = "test-access-token";
+ const clientLoginResponse = {
+ user_id: userId,
+ device_id: deviceId,
+ access_token: accessToken,
+ };
+
+ beforeEach(() => {
+ localStorage.setItem("mx_sso_hs_url", serverConfig.hsUrl);
+ localStorage.setItem("mx_sso_is_url", serverConfig.isUrl);
+ loginClient = getMockClientWithEventEmitter(getMockClientMethods());
+ // this is used to create a temporary client during login
+ jest.spyOn(MatrixJs, "createClient").mockReturnValue(loginClient);
+
+ loginClient.login.mockClear().mockResolvedValue(clientLoginResponse);
+ });
+
+ it("should show the Complete Security screen after OIDC login if unskippable ver. is on", async () => {
+ // Given force_verification is on (outer describe)
+ // And we just logged in via OIDC (inner describe)
+
+ // When we load the page
+ getComponent({ realQueryParams });
+
+ defaultDispatcher.dispatch({
+ action: "will_start_client",
+ });
+ await waitFor(() =>
+ expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }),
+ );
+
+ // Then we are not allowed in - we are being asked to verify
+ await screen.findByRole("heading", { name: "Verify this device", level: 1 });
+ });
+ });
+
function createMockCrypto(): CryptoApi {
return {
getVersion: jest.fn().mockReturnValue("Version 0"),
@@ -1560,7 +1603,8 @@ describe("", () => {
Lifecycle.setSessionLockNotStolen();
});
- it("waits for other tab to stop during startup", async () => {
+ // Flaky test, see https://github.com/element-hq/element-web/issues/30337
+ it.skip("waits for other tab to stop during startup", async () => {
fetchMock.get("/welcome.html", { body: "
Hello
" });
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");
diff --git a/test/unit-tests/components/structures/__snapshots__/FileDropTarget-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/FileDropTarget-test.tsx.snap
new file mode 100644
index 0000000000..367a456a31
--- /dev/null
+++ b/test/unit-tests/components/structures/__snapshots__/FileDropTarget-test.tsx.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FileDropTarget should not render drop file prompt on mouse over with file if permissions do not allow 1`] = ``;
+
+exports[`FileDropTarget should render drop file prompt on mouse over with file if permissions allow 1`] = `
+
+
+
+ Drop file here to upload
+
+
+`;
+
+exports[`FileDropTarget should render nothing when idle 1`] = ``;
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 d33ff73307..1196ce2202 100644
--- a/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap
+++ b/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap
@@ -1809,7 +1809,11 @@ exports[`RoomView should not display the timeline when the room encryption is lo
+ >
+
+
{
+ const defaultRoomId = "!fkfk";
+ const defaultUserId = "@user:example.com";
+
+ const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
+
+ const defaultProps = {
+ devices: [] as Device[],
+ member: defaultMember,
+ };
+ let mockClient: MatrixClient;
+ let mockCrypto: Mocked;
+
+ beforeEach(() => {
+ mockCrypto = {
+ bootstrapSecretStorage: jest.fn(),
+ bootstrapCrossSigning: jest.fn(),
+ getCrossSigningKeyId: jest.fn(),
+ getVerificationRequestsToDeviceInProgress: jest.fn().mockReturnValue([]),
+ getUserDeviceInfo: jest.fn(),
+ getDeviceVerificationStatus: jest.fn(),
+ getUserVerificationStatus: jest.fn(),
+ isDehydrationSupported: jest.fn().mockResolvedValue(false),
+ startDehydration: jest.fn(),
+ getKeyBackupInfo: jest.fn().mockResolvedValue(null),
+ userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
+ } as unknown as Mocked;
+
+ mockClient = createTestClient();
+ jest.spyOn(mockClient, "doesServerSupportUnstableFeature").mockResolvedValue(true);
+ jest.spyOn(mockClient.secretStorage, "hasKey").mockResolvedValue(true);
+ jest.spyOn(mockClient, "getCrypto").mockReturnValue(mockCrypto);
+ jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
+ jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ const renderUserInfoHeaderVerificationHook = (props = defaultProps) => {
+ return renderHook(
+ () => useUserInfoVerificationViewModel(props.member, props.devices),
+ withClientContextRenderOptions(mockClient),
+ );
+ };
+
+ it("should be able to verify user", async () => {
+ const notMeId = "@notMe";
+ const notMetMember = new RoomMember(defaultRoomId, notMeId);
+ const device1 = new Device({
+ deviceId: "d1",
+ userId: notMeId,
+ displayName: "my device",
+ algorithms: [],
+ keys: new Map(),
+ });
+
+ // mock the user as not verified
+ jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
+ new UserVerificationStatus(false, false, false),
+ );
+
+ jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
+
+ // the selected user is not the default user, so he can make user verification
+ const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
+ await waitFor(() => {
+ const canVerify = result.current.canVerify;
+
+ expect(canVerify).toBeTruthy();
+ });
+ });
+
+ it("should not be able to verify user if user is not me", async () => {
+ const device1 = new Device({
+ deviceId: "d1",
+ userId: defaultMember.userId,
+ displayName: "my device",
+ algorithms: [],
+ keys: new Map(),
+ });
+
+ // mock the user as not verified
+ jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
+ new UserVerificationStatus(false, false, false),
+ );
+
+ jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
+
+ const { result } = renderUserInfoHeaderVerificationHook({ member: defaultMember, devices: [device1] });
+ await waitFor(() => {
+ const canVerify = result.current.canVerify;
+
+ expect(canVerify).toBeFalsy();
+ // if we cant verify the user the hasCrossSigningKeys value should also be undefined
+ expect(result.current.hasCrossSigningKeys).toBeUndefined();
+ });
+ });
+
+ it("should not be able to verify user if im already verified", async () => {
+ const notMeId = "@notMe";
+ const notMetMember = new RoomMember(defaultRoomId, notMeId);
+ const device1 = new Device({
+ deviceId: "d1",
+ userId: notMeId,
+ displayName: "my device",
+ algorithms: [],
+ keys: new Map(),
+ });
+
+ // mock the user as already verified
+ jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
+ new UserVerificationStatus(true, true, false),
+ );
+
+ jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
+
+ // the selected user is not the default user, so he can make user verification
+ const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
+ await waitFor(() => {
+ const canVerify = result.current.canVerify;
+
+ expect(canVerify).toBeFalsy();
+ // if we cant verify the user the hasCrossSigningKeys value should also be undefined
+ expect(result.current.hasCrossSigningKeys).toBeUndefined();
+ });
+ });
+
+ it("should not be able to verify user there is no devices", async () => {
+ const notMeId = "@notMe";
+ const notMetMember = new RoomMember(defaultRoomId, notMeId);
+
+ // mock the user as not verified
+ jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
+ new UserVerificationStatus(false, false, false),
+ );
+
+ jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
+
+ // the selected user is not the default user, so he can make user verification
+ const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [] });
+ await waitFor(() => {
+ const canVerify = result.current.canVerify;
+
+ expect(canVerify).toBeFalsy();
+ // if we cant verify the user the hasCrossSigningKeys value should also be undefined
+ expect(result.current.hasCrossSigningKeys).toBeUndefined();
+ });
+ });
+
+ it("should get correct hasCrossSigningKeys values", async () => {
+ const notMeId = "@notMe";
+ const notMetMember = new RoomMember(defaultRoomId, notMeId);
+ const device1 = new Device({
+ deviceId: "d1",
+ userId: notMeId,
+ displayName: "my device",
+ algorithms: [],
+ keys: new Map(),
+ });
+
+ // mock the user as not verified
+ jest.spyOn(mockCrypto, "getUserVerificationStatus").mockResolvedValue(
+ new UserVerificationStatus(false, false, false),
+ );
+
+ jest.spyOn(mockClient, "getUserId").mockReturnValue(defaultMember.userId);
+
+ jest.spyOn(mockCrypto, "userHasCrossSigningKeys").mockResolvedValue(true);
+ const { result } = renderUserInfoHeaderVerificationHook({ member: notMetMember, devices: [device1] });
+ await waitFor(() => {
+ const hasCrossSigningKeys = result.current.hasCrossSigningKeys;
+
+ expect(hasCrossSigningKeys).toBeTruthy();
+ });
+ });
+});
diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx
new file mode 100644
index 0000000000..adab891435
--- /dev/null
+++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel-test.tsx
@@ -0,0 +1,179 @@
+/*
+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 { type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
+import { mocked, type Mocked } from "jest-mock";
+import { type CryptoApi } from "matrix-js-sdk/src/crypto-api";
+import { renderHook } from "jest-matrix-react";
+
+import { withClientContextRenderOptions } from "../../../../../test-utils";
+import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
+import { useUserfoHeaderViewModel } from "../../../../../../src/components/viewmodels/right_panel/user_info/UserInfoHeaderViewModel";
+import * as UseTimezone from "../../../../../../src/hooks/useUserTimezone";
+import SdkConfig from "../../../../../../src/SdkConfig";
+import Modal from "../../../../../../src/Modal";
+import ImageView from "../../../../../../src/components/views/elements/ImageView";
+import * as Media from "../../../../../../src/customisations/Media";
+import { type IConfigOptions } from "../../../../../../src/IConfigOptions";
+
+jest.mock("../../../../../../src/customisations/UserIdentifier", () => {
+ return {
+ getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"),
+ };
+});
+
+describe("useUserInfoHeaderViewModel", () => {
+ const defaultRoomId = "!fkfk";
+ const defaultUserId = "@user:example.com";
+
+ const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
+
+ const defaultProps = {
+ member: defaultMember,
+ roomId: defaultRoomId,
+ };
+
+ let mockClient: Mocked;
+ let mockCrypto: Mocked;
+
+ const mockAvatarUrl = "mock-avatar-url";
+ const oldGet = SdkConfig.get;
+
+ beforeEach(() => {
+ mockCrypto = mocked({
+ getDeviceVerificationStatus: jest.fn(),
+ getUserDeviceInfo: jest.fn(),
+ userHasCrossSigningKeys: jest.fn().mockResolvedValue(false),
+ getUserVerificationStatus: jest.fn(),
+ isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(false),
+ } as unknown as CryptoApi);
+
+ mockClient = mocked({
+ getUser: jest.fn(),
+ isGuest: jest.fn().mockReturnValue(false),
+ isUserIgnored: jest.fn(),
+ getIgnoredUsers: jest.fn(),
+ setIgnoredUsers: jest.fn(),
+ getUserId: jest.fn(),
+ getSafeUserId: jest.fn(),
+ getDomain: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ isSynapseAdministrator: jest.fn().mockResolvedValue(false),
+ doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
+ doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
+ getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
+ mxcUrlToHttp: jest.fn().mockReturnValue(mockAvatarUrl),
+ removeListener: jest.fn(),
+ currentState: {
+ on: jest.fn(),
+ },
+ getRoom: jest.fn(),
+ credentials: {},
+ setPowerLevel: jest.fn(),
+ getCrypto: jest.fn().mockReturnValue(mockCrypto),
+ baseUrl: "homeserver.url",
+ } as unknown as MatrixClient);
+
+ jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
+ jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ const renderUserInfoHeaderViewModelHook = (props = defaultProps) => {
+ return renderHook(() => useUserfoHeaderViewModel(props), withClientContextRenderOptions(mockClient));
+ };
+ it("should give user timezone info", () => {
+ const defaultTZ = { timezone: "FR", friendly: "fr" };
+ jest.spyOn(UseTimezone, "useUserTimezone").mockReturnValue(defaultTZ);
+
+ const { result } = renderUserInfoHeaderViewModelHook();
+ const timezone = result.current.timezoneInfo;
+
+ expect(UseTimezone.useUserTimezone).toHaveBeenCalledWith(mockClient, defaultMember.userId);
+ expect(timezone).toEqual(defaultTZ);
+ });
+
+ it("should give correct showPresence value based on enablePresenceByHsUrl", () => {
+ jest.spyOn(SdkConfig, "get").mockImplementation((key: string) => {
+ if (key === "enable_presence_by_hs_url") {
+ return {
+ [mockClient.baseUrl]: false,
+ };
+ }
+ return oldGet(key as keyof IConfigOptions);
+ });
+ const { result } = renderUserInfoHeaderViewModelHook();
+ const showPresence = result.current.showPresence;
+ expect(showPresence).toBeFalsy();
+ });
+
+ it("should have default value true for showPresence", () => {
+ jest.spyOn(SdkConfig, "get").mockImplementation(() => false);
+ const { result } = renderUserInfoHeaderViewModelHook();
+ const showPresence = result.current.showPresence;
+ expect(showPresence).toBeTruthy();
+ });
+
+ it("should open image dialog when avatar is clicked", () => {
+ const props = Object.assign({}, defaultProps);
+ const spyModale = jest.spyOn(Modal, "createDialog");
+ const spyMedia = jest.spyOn(Media, "mediaFromMxc");
+ jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
+
+ const { result } = renderUserInfoHeaderViewModelHook(props);
+
+ result.current.onMemberAvatarClick();
+
+ expect(spyModale).toHaveBeenCalledWith(
+ ImageView,
+ {
+ src: mockAvatarUrl,
+ name: defaultMember.name,
+ },
+ "mx_Dialog_lightbox",
+ undefined,
+ true,
+ );
+ expect(spyMedia).toHaveBeenCalledWith(mockAvatarUrl);
+ });
+
+ it("should not open image dialog when avatar url is null", () => {
+ const props = Object.assign({}, defaultProps);
+ const spyModale = jest.spyOn(Modal, "createDialog");
+ jest.spyOn(props.member, "getMxcAvatarUrl").mockReturnValue(mockAvatarUrl);
+ jest.spyOn(Media, "mediaFromMxc").mockReturnValue({
+ srcHttp: null,
+ isEncrypted: false,
+ srcMxc: "",
+ thumbnailMxc: undefined,
+ hasThumbnail: false,
+ thumbnailHttp: null,
+ getThumbnailHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
+ throw new Error("Function not implemented.");
+ },
+ getThumbnailOfSourceHttp: function (width: number, height: number, mode?: "scale" | "crop"): string | null {
+ throw new Error("Function not implemented.");
+ },
+ getSquareThumbnailHttp: function (dim: number): string | null {
+ throw new Error("Function not implemented.");
+ },
+ downloadSource: function (): Promise {
+ throw new Error("Function not implemented.");
+ },
+ });
+
+ const { result } = renderUserInfoHeaderViewModelHook(props);
+
+ result.current.onMemberAvatarClick();
+
+ expect(spyModale).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx
new file mode 100644
index 0000000000..d69dc12779
--- /dev/null
+++ b/test/unit-tests/components/viewmodels/right_panel/user_info/UserInfoPowerLevelsViewModel-test.tsx
@@ -0,0 +1,222 @@
+/*
+Copyright 2025 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 { renderHook } from "jest-matrix-react";
+import { type Mocked, mocked } from "jest-mock";
+import { RoomMember, MatrixEvent, type Room, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix";
+
+import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
+import { useUserInfoPowerlevelViewModel } from "../../../../../../src/components/viewmodels/right_panel/UserInfoPowerlevelViewModel";
+import { withClientContextRenderOptions } from "../../../../../test-utils";
+import { type IRoomPermissions } from "../../../../../../src/components/views/right_panel/UserInfo";
+import Modal from "../../../../../../src/Modal";
+import { warnSelfDemote } from "../../../../../../src/components/views/right_panel/UserInfo";
+
+jest.mock("../../../../../../src/Modal", () => ({
+ createDialog: jest.fn(),
+}));
+
+jest.mock("../../../../../../src/components/views/right_panel/UserInfo", () => ({
+ warnSelfDemote: jest.fn(),
+}));
+
+describe("UserInfoAdminPowerlevelViewModel", () => {
+ const defaultRoomId = "!fkfk";
+ const defaultUserId = "@user:example.com";
+ const defaultMeId = "@me:example.com";
+ const selfUser = new RoomMember(defaultRoomId, defaultMeId);
+ const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
+ const startPowerLevel = 50;
+ const changedPowerLevel = 100;
+
+ let mockClient: Mocked;
+ let mockRoom: Mocked;
+ let defaultProps: {
+ user: RoomMember;
+ room: Room;
+ roomPermissions: IRoomPermissions;
+ };
+
+ beforeEach(() => {
+ defaultProps = {
+ user: defaultMember,
+ room: mockRoom,
+ roomPermissions: {
+ modifyLevelMax: 100,
+ canEdit: false,
+ canInvite: false,
+ },
+ };
+
+ mockRoom = mocked({
+ roomId: defaultRoomId,
+ getType: jest.fn().mockReturnValue(undefined),
+ isSpaceRoom: jest.fn().mockReturnValue(false),
+ getMember: jest.fn().mockReturnValue(undefined),
+ getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
+ name: "test room",
+ on: jest.fn(),
+ off: jest.fn(),
+ currentState: {
+ getStateEvents: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ },
+ getEventReadUpTo: jest.fn(),
+ } as unknown as Room);
+
+ mockClient = mocked({
+ getUser: jest.fn(),
+ isGuest: jest.fn().mockReturnValue(false),
+ isUserIgnored: jest.fn(),
+ getIgnoredUsers: jest.fn(),
+ setIgnoredUsers: jest.fn(),
+ getUserId: jest.fn(),
+ getSafeUserId: jest.fn(),
+ getDomain: jest.fn(),
+ on: jest.fn(),
+ off: jest.fn(),
+ isSynapseAdministrator: jest.fn().mockResolvedValue(false),
+ doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
+ doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
+ getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
+ mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
+ removeListener: jest.fn(),
+ currentState: {
+ on: jest.fn(),
+ },
+ getRoom: jest.fn(),
+ credentials: {},
+ setPowerLevel: jest.fn().mockResolvedValueOnce({ event_id: "123" }),
+ } as unknown as MatrixClient);
+
+ jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
+ jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
+
+ (Modal.createDialog as jest.Mock).mockImplementation(() => ({
+ finished: Promise.resolve([true]),
+ }));
+ (warnSelfDemote as jest.Mock).mockResolvedValue(true);
+ });
+
+ const renderComponentHook = (props = defaultProps, client = mockClient) => {
+ return renderHook(
+ () => useUserInfoPowerlevelViewModel(props.user, props.room),
+ withClientContextRenderOptions(client),
+ );
+ };
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("should give default power level", () => {
+ const defaultPowerLevel = 1;
+ const powerLevelEvent = new MatrixEvent({
+ type: EventType.RoomPowerLevels,
+ content: { users: { [defaultUserId]: defaultPowerLevel }, users_default: defaultPowerLevel },
+ });
+ mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom });
+
+ expect(result.current.powerLevelUsersDefault).toBe(defaultPowerLevel);
+ });
+
+ it("handles successful power level change", async () => {
+ const powerLevelEvent = new MatrixEvent({
+ type: EventType.RoomPowerLevels,
+ content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 },
+ });
+ mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
+ mockClient.getSafeUserId.mockReturnValueOnce(defaultUserId);
+ mockClient.getUserId.mockReturnValueOnce(defaultUserId);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
+
+ await result.current.onPowerChange(changedPowerLevel);
+
+ expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1);
+ expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, changedPowerLevel);
+ });
+
+ it("shows warning when promoting user to higher power level", async () => {
+ const powerLevelEvent = new MatrixEvent({
+ type: EventType.RoomPowerLevels,
+ content: {
+ users: {
+ [defaultUserId]: startPowerLevel,
+ [defaultMeId]: startPowerLevel,
+ },
+ users_default: 1,
+ },
+ });
+ mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
+ mockClient.getUserId.mockReturnValue(defaultMeId);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
+
+ await result.current.onPowerChange(changedPowerLevel);
+
+ expect(Modal.createDialog).toHaveBeenCalled();
+ expect(mockClient.setPowerLevel).toHaveBeenCalled();
+ });
+
+ it("shows warning when self-demoting", async () => {
+ const powerLevelEvent = new MatrixEvent({
+ type: EventType.RoomPowerLevels,
+ content: {
+ users: { [defaultMeId]: changedPowerLevel },
+ users_default: 1,
+ },
+ });
+ mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
+ mockClient.getUserId.mockReturnValue(defaultMeId);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom, user: selfUser }, mockClient);
+
+ await result.current.onPowerChange(startPowerLevel);
+
+ expect(warnSelfDemote).toHaveBeenCalled();
+ expect(mockClient.setPowerLevel).toHaveBeenCalled();
+ });
+
+ it("cancels power level change when user declines warning", async () => {
+ (Modal.createDialog as jest.Mock).mockImplementation(() => ({
+ finished: Promise.resolve([false]),
+ }));
+
+ const powerLevelEvent = new MatrixEvent({
+ type: EventType.RoomPowerLevels,
+ content: {
+ users: {
+ [defaultUserId]: startPowerLevel,
+ "@me:example.com": startPowerLevel,
+ },
+ users_default: 1,
+ },
+ });
+ mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent);
+ mockClient.getUserId.mockReturnValue(defaultMeId);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
+
+ await result.current.onPowerChange(changedPowerLevel);
+
+ expect(Modal.createDialog).toHaveBeenCalled();
+ expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
+ });
+
+ it("handles missing power level event", async () => {
+ mockRoom.currentState.getStateEvents.mockReturnValue(null);
+
+ const { result } = renderComponentHook({ ...defaultProps, room: mockRoom }, mockClient);
+
+ await result.current.onPowerChange(changedPowerLevel);
+
+ expect(mockClient.setPowerLevel).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx
index c58ae4168c..96bc53016e 100644
--- a/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx
+++ b/test/unit-tests/components/viewmodels/roomlist/RoomListItemViewModel-test.tsx
@@ -73,6 +73,15 @@ describe("RoomListItemViewModel", () => {
);
});
+ it("should show context menu if user has access to options menu", async () => {
+ mocked(hasAccessToOptionsMenu).mockReturnValue(true);
+ const { result: vm } = renderHook(
+ () => useRoomListItemViewModel(room),
+ withClientContextRenderOptions(room.client),
+ );
+ expect(vm.current.showContextMenu).toBe(true);
+ });
+
it("should show hover menu if user has access to options menu", async () => {
mocked(hasAccessToOptionsMenu).mockReturnValue(true);
const { result: vm } = renderHook(
diff --git a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx
index 411cc5d0e4..4c735bdd8c 100644
--- a/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx
+++ b/test/unit-tests/components/views/context_menus/MessageContextMenu-test.tsx
@@ -356,6 +356,226 @@ describe("MessageContextMenu", () => {
});
});
+ describe("quote button", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("shows quote button when selection is inside one MTextBody and getSelectedText returns text", () => {
+ mocked(getSelectedText).mockReturnValue("quoted text");
+ const isSelectionWithinSingleTextBody = jest
+ .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
+ .mockReturnValue(true);
+
+ createRightClickMenuWithContent(createMessageEventContent("hello"));
+ const quoteButton = document.querySelector('li[aria-label="Quote"]');
+ expect(quoteButton).toBeTruthy();
+
+ isSelectionWithinSingleTextBody.mockRestore();
+ });
+
+ it("does not show quote button when getSelectedText returns empty", () => {
+ mocked(getSelectedText).mockReturnValue("");
+ const isSelectionWithinSingleTextBody = jest
+ .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
+ .mockReturnValue(true);
+
+ createRightClickMenuWithContent(createMessageEventContent("hello"));
+ const quoteButton = document.querySelector('li[aria-label="Quote"]');
+ expect(quoteButton).toBeFalsy();
+
+ isSelectionWithinSingleTextBody.mockRestore();
+ });
+
+ it("does not show quote button when selection is not inside one MTextBody", () => {
+ mocked(getSelectedText).mockReturnValue("quoted text");
+ const isSelectionWithinSingleTextBody = jest
+ .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
+ .mockReturnValue(false);
+
+ createRightClickMenuWithContent(createMessageEventContent("hello"));
+ const quoteButton = document.querySelector('li[aria-label="Quote"]');
+ expect(quoteButton).toBeFalsy();
+
+ isSelectionWithinSingleTextBody.mockRestore();
+ });
+
+ it("dispatches ComposerInsert with quoted text when quote button is clicked", () => {
+ mocked(getSelectedText).mockReturnValue("line1\nline2");
+ const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
+ const isSelectionWithinSingleTextBody = jest
+ .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
+ .mockReturnValue(true);
+
+ createRightClickMenuWithContent(createMessageEventContent("hello"));
+ const quoteButton = document.querySelector('li[aria-label="Quote"]')!;
+ fireEvent.mouseDown(quoteButton);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: Action.ComposerInsert,
+ text: "\n> line1\n> line2\n\n ",
+ }),
+ );
+
+ isSelectionWithinSingleTextBody.mockRestore();
+ });
+
+ it("does not show quote button when getSelectedText returns only whitespace", () => {
+ mocked(getSelectedText).mockReturnValue(" \n\t "); // whitespace only
+ const isSelectionWithinSingleTextBody = jest
+ .spyOn(MessageContextMenu.prototype as any, "isSelectionWithinSingleTextBody")
+ .mockReturnValue(true);
+
+ createRightClickMenuWithContent(createMessageEventContent("hello"));
+ const quoteButton = document.querySelector('li[aria-label="Quote"]');
+ expect(quoteButton).toBeFalsy();
+
+ isSelectionWithinSingleTextBody.mockRestore();
+ });
+ });
+
+ describe("isSelectionWithinSingleTextBody", () => {
+ let mockGetSelection: jest.SpyInstance;
+ let contextMenuInstance: MessageContextMenu;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockGetSelection = jest.spyOn(window, "getSelection");
+
+ const eventContent = createMessageEventContent("hello");
+ const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent });
+
+ contextMenuInstance = new MessageContextMenu({
+ mxEvent,
+ onFinished: jest.fn(),
+ rightClick: true,
+ } as any);
+ });
+
+ afterEach(() => {
+ mockGetSelection.mockRestore();
+ });
+
+ it("returns false when there is no selection", () => {
+ mockGetSelection.mockReturnValue(null);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(false);
+ });
+
+ it("returns false when selection has no ranges", () => {
+ mockGetSelection.mockReturnValue({
+ rangeCount: 0,
+ getRangeAt: jest.fn(),
+ } as any);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(false);
+ });
+
+ it("returns true when selection is within a single mx_MTextBody element", () => {
+ // Create a mock MTextBody element
+ const textBodyElement = document.createElement("div");
+ textBodyElement.classList.add("mx_MTextBody");
+
+ // Create mock text nodes within the MTextBody
+ const startTextNode = document.createTextNode("start");
+ const endTextNode = document.createTextNode("end");
+ textBodyElement.appendChild(startTextNode);
+ textBodyElement.appendChild(endTextNode);
+
+ // Create a mock range with the text nodes
+ const mockRange = {
+ startContainer: startTextNode,
+ endContainer: endTextNode,
+ } as unknown as Range;
+
+ mockGetSelection.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: jest.fn().mockReturnValue(mockRange),
+ } as any);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(true);
+ });
+
+ it("returns false when selection spans multiple mx_MTextBody elements", () => {
+ // Create two different MTextBody elements
+ const textBody1 = document.createElement("div");
+ textBody1.classList.add("mx_MTextBody");
+ const textBody2 = document.createElement("div");
+ textBody2.classList.add("mx_MTextBody");
+
+ const startTextNode = document.createTextNode("start");
+ const endTextNode = document.createTextNode("end");
+ textBody1.appendChild(startTextNode);
+ textBody2.appendChild(endTextNode);
+
+ // Create a mock range spanning different MTextBody elements
+ const mockRange = {
+ startContainer: startTextNode,
+ endContainer: endTextNode,
+ } as unknown as Range;
+
+ mockGetSelection.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: jest.fn().mockReturnValue(mockRange),
+ } as any);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(false);
+ });
+
+ it("returns false when selection is outside any mx_MTextBody element", () => {
+ // Create regular div elements without mx_MTextBody class
+ const regularDiv1 = document.createElement("div");
+ const regularDiv2 = document.createElement("div");
+
+ const startTextNode = document.createTextNode("start");
+ const endTextNode = document.createTextNode("end");
+ regularDiv1.appendChild(startTextNode);
+ regularDiv2.appendChild(endTextNode);
+
+ // Create a mock range outside MTextBody elements
+ const mockRange = {
+ startContainer: startTextNode,
+ endContainer: endTextNode,
+ } as unknown as Range;
+
+ mockGetSelection.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: jest.fn().mockReturnValue(mockRange),
+ } as any);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(false);
+ });
+
+ it("returns true when start and end are the same mx_MTextBody element", () => {
+ const textBodyElement = document.createElement("div");
+ textBodyElement.classList.add("mx_MTextBody");
+
+ const textNode = document.createTextNode("same text");
+ textBodyElement.appendChild(textNode);
+
+ // Create a mock range within the same MTextBody element
+ const mockRange = {
+ startContainer: textNode,
+ endContainer: textNode,
+ } as unknown as Range;
+
+ mockGetSelection.mockReturnValue({
+ rangeCount: 1,
+ getRangeAt: jest.fn().mockReturnValue(mockRange),
+ } as any);
+
+ const result = (contextMenuInstance as any).isSelectionWithinSingleTextBody();
+ expect(result).toBe(true);
+ });
+ });
+
describe("right click", () => {
it("copy button does work as expected", () => {
const text = "hello";
diff --git a/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx b/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
index 955ac64b2d..2cd6d609ea 100644
--- a/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/BugReportDialog-test.tsx
@@ -16,6 +16,7 @@ import BugReportDialog, {
} from "../../../../../src/components/views/dialogs/BugReportDialog";
import SdkConfig from "../../../../../src/SdkConfig";
import { type ConsoleLogger } from "../../../../../src/rageshake/rageshake";
+import SettingsStore from "../../../../../src/settings/SettingsStore";
const BUG_REPORT_URL = "https://example.org/submit";
@@ -32,6 +33,16 @@ describe("BugReportDialog", () => {
bug_report_endpoint_url: BUG_REPORT_URL,
});
+ const originalGetValue = SettingsStore.getValue;
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, ...args) => {
+ // These settings rely on a controller that creates an AudioContext in
+ // order to test whether the setting can be enabled. For the sake of this test, disable that.
+ if (settingName === "notificationsEnabled" || settingName === "notificationBodyEnabled") {
+ return true;
+ }
+ return originalGetValue(settingName, ...args);
+ });
+
const mockConsoleLogger = {
flush: jest.fn(),
consume: jest.fn(),
@@ -55,6 +66,7 @@ describe("BugReportDialog", () => {
});
afterEach(() => {
+ jest.restoreAllMocks();
SdkConfig.reset();
fetchMock.restore();
});
diff --git a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx
index cee615380f..4e8a017ed8 100644
--- a/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/LogoutDialog-test.tsx
@@ -10,10 +10,13 @@ import React from "react";
import { mocked, type MockedObject } from "jest-mock";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type CryptoApi, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
-import { fireEvent, render, type RenderResult, screen } from "jest-matrix-react";
+import { fireEvent, render, type RenderResult, screen, waitFor } from "jest-matrix-react";
import { filterConsole, getMockClientWithEventEmitter, mockClientMethodsCrypto } from "../../../../test-utils";
import LogoutDialog from "../../../../../src/components/views/dialogs/LogoutDialog";
+import dispatch from "../../../../../src/dispatcher/dispatcher";
+import { Action } from "../../../../../src/dispatcher/actions";
+import { UserTab } from "../../../../../src/components/views/dialogs/UserTab";
describe("LogoutDialog", () => {
let mockClient: MockedObject;
@@ -56,17 +59,26 @@ describe("LogoutDialog", () => {
await rendered.findByText("You'll lose access to your encrypted messages");
});
- it("Prompts user to connect backup if there is a backup on the server", async () => {
+ it("Prompts user to go to settings if there is a backup on the server", async () => {
mockCrypto.getKeyBackupInfo.mockResolvedValue({} as KeyBackupInfo);
const rendered = renderComponent();
- await rendered.findByText("Connect this session to Key Backup");
+ await rendered.findByText("Go to Settings");
expect(rendered.container).toMatchSnapshot();
+
+ jest.spyOn(dispatch, "dispatch");
+ fireEvent.click(await screen.findByRole("button", { name: "Go to Settings" }));
+ await waitFor(() =>
+ expect(dispatch.dispatch).toHaveBeenCalledWith({
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Encryption,
+ }),
+ );
});
- it("Prompts user to set up backup if there is no backup on the server", async () => {
+ it("Prompts user to go to settings if there is no backup on the server", async () => {
mockCrypto.getKeyBackupInfo.mockResolvedValue(null);
const rendered = renderComponent();
- await rendered.findByText("Start using Key Backup");
+ await rendered.findByText("Go to Settings");
expect(rendered.container).toMatchSnapshot();
fireEvent.click(await screen.findByRole("button", { name: "Manually export keys" }));
@@ -75,12 +87,12 @@ describe("LogoutDialog", () => {
describe("when there is an error fetching backups", () => {
filterConsole("Unable to fetch key backup status");
- it("prompts user to set up backup", async () => {
+ it("prompts user to go to settings", async () => {
mockCrypto.getKeyBackupInfo.mockImplementation(async () => {
throw new Error("beep");
});
const rendered = renderComponent();
- await rendered.findByText("Start using Key Backup");
+ await rendered.findByText("Go to Settings");
});
});
});
diff --git a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx
index 8ffe9e3de6..6582bd4d3a 100644
--- a/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/UserSettingsDialog-test.tsx
@@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactElement } from "react";
-import { render, screen } from "jest-matrix-react";
+import { render, screen, waitFor } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock";
-import { type MatrixClient } from "matrix-js-sdk/src/matrix";
+import { ClientEvent, MatrixEvent, type MatrixClient } from "matrix-js-sdk/src/matrix";
import SettingsStore, { type CallbackFn } from "../../../../../src/settings/SettingsStore";
import SdkConfig from "../../../../../src/SdkConfig";
@@ -250,4 +250,28 @@ describe("", () => {
// unwatches settings on unmount
expect(mockSettingsStore.unwatchSetting).toHaveBeenCalledWith("mock-watcher-id-feature_mjolnir");
});
+
+ it("displays an indicator when user needs to set up recovery", async () => {
+ // Initially, the user doesn't have secret storage, so it should display
+ // an indicator.
+ mockClient.secretStorage.getDefaultKeyId.mockResolvedValue(null);
+
+ const { container } = render(getComponent());
+
+ await waitFor(() => {
+ expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).toBeInTheDocument();
+ });
+
+ // Test that the handler ignores unknown account data
+ mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "bar" }));
+
+ // The user now has secret storage. Trigger an update and check that
+ // the indicator disappears.
+ mockClient.secretStorage.getDefaultKeyId.mockResolvedValue("foo");
+ mockClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: "m.secret_storage.default_key" }));
+
+ await waitFor(() => {
+ expect(container.querySelector(".mx_SettingsDialog_tabLabelsAlert")).not.toBeInTheDocument();
+ });
+ });
});
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/LogoutDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/LogoutDialog-test.tsx.snap
index c5db254cb2..31d5a09a4f 100644
--- a/test/unit-tests/components/views/dialogs/__snapshots__/LogoutDialog-test.tsx.snap
+++ b/test/unit-tests/components/views/dialogs/__snapshots__/LogoutDialog-test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`LogoutDialog Prompts user to connect backup if there is a backup on the server 1`] = `
+exports[`LogoutDialog Prompts user to go to settings if there is a backup on the server 1`] = `
- Connect this session to Key Backup
+ Go to Settings
@@ -87,7 +87,7 @@ exports[`LogoutDialog Prompts user to connect backup if there is a backup on the
`;
-exports[`LogoutDialog Prompts user to set up backup if there is no backup on the server 1`] = `
+exports[`LogoutDialog Prompts user to go to settings if there is no backup on the server 1`] = `
- Start using Key Backup
+ Go to Settings
diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap
index 2fd5fdd249..b77858899d 100644
--- a/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap
+++ b/test/unit-tests/components/views/dialogs/__snapshots__/UntrustedDeviceDialog-test.tsx.snap
@@ -23,7 +23,11 @@ exports[` should display the dialog for the device of a
+ >
+
+
Not Trusted
@@ -97,7 +101,11 @@ exports[` should display the dialog for the device of t
+ >
+
+
Not Trusted
diff --git a/test/unit-tests/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx
deleted file mode 100644
index b5f965cfa2..0000000000
--- a/test/unit-tests/components/views/dialogs/security/CreateKeyBackupDialog-test.tsx
+++ /dev/null
@@ -1,77 +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 { render, screen, waitFor } from "jest-matrix-react";
-import React from "react";
-import { mocked } from "jest-mock";
-
-import CreateKeyBackupDialog from "../../../../../../src/async-components/views/dialogs/security/CreateKeyBackupDialog";
-import { createTestClient } from "../../../../../test-utils";
-import { MatrixClientPeg } from "../../../../../../src/MatrixClientPeg";
-
-jest.mock("../../../../../../src/SecurityManager", () => ({
- accessSecretStorage: jest.fn().mockResolvedValue(undefined),
- withSecretStorageKeyCache: jest.fn().mockImplementation((fn) => fn()),
-}));
-
-describe("CreateKeyBackupDialog", () => {
- beforeEach(() => {
- MatrixClientPeg.safeGet = MatrixClientPeg.get = () => createTestClient();
- });
-
- it("should display the spinner when creating backup", () => {
- const { asFragment } = render();
-
- // Check if the spinner is displayed
- expect(screen.getByTestId("spinner")).toBeDefined();
- expect(asFragment()).toMatchSnapshot();
- });
-
- it("should display an error message when backup creation failed", async () => {
- const matrixClient = createTestClient();
- jest.spyOn(matrixClient.secretStorage, "hasKey").mockResolvedValue(true);
- mocked(matrixClient.getCrypto()!.resetKeyBackup).mockImplementation(() => {
- throw new Error("failed");
- });
- MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
-
- const { asFragment } = render();
-
- // Check if the error message is displayed
- await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined());
- expect(asFragment()).toMatchSnapshot();
- });
-
- it("should display an error message when there is no Crypto available", async () => {
- const matrixClient = createTestClient();
- jest.spyOn(matrixClient.secretStorage, "hasKey").mockResolvedValue(true);
- mocked(matrixClient.getCrypto).mockReturnValue(undefined);
- MatrixClientPeg.safeGet = MatrixClientPeg.get = () => matrixClient;
-
- render();
-
- // Check if the error message is displayed
- await waitFor(() => expect(screen.getByText("Unable to create key backup")).toBeDefined());
- });
-
- it("should display the success dialog when the key backup is finished", async () => {
- const onFinished = jest.fn();
- const { asFragment } = render();
-
- await waitFor(() =>
- expect(
- screen.getByText("Your keys are being backed up (the first backup could take a few minutes)."),
- ).toBeDefined(),
- );
- expect(asFragment()).toMatchSnapshot();
-
- // Click on the OK button
- screen.getByRole("button", { name: "OK" }).click();
- expect(onFinished).toHaveBeenCalledWith(true);
- });
-});
diff --git a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx
index 000f6efdb4..da68906c63 100644
--- a/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx
+++ b/test/unit-tests/components/views/dialogs/security/CreateSecretStorageDialog-test.tsx
@@ -13,7 +13,7 @@ import { mocked, type MockedObject } from "jest-mock";
import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { sleep } from "matrix-js-sdk/src/utils";
-import { filterConsole, flushPromises, stubClient } from "../../../../../test-utils";
+import { filterConsole, stubClient } from "../../../../../test-utils";
import CreateSecretStorageDialog from "../../../../../../src/async-components/views/dialogs/security/CreateSecretStorageDialog";
describe("CreateSecretStorageDialog", () => {
@@ -97,39 +97,4 @@ describe("CreateSecretStorageDialog", () => {
await screen.findByText("Your keys are now being backed up from this device.");
});
});
-
- it("resets keys in the right order when resetting secret storage and cross-signing", async () => {
- const result = renderComponent({ forceReset: true, resetCrossSigning: true });
-
- await result.findByText(/Set up Secure Backup/);
- jest.spyOn(mockClient.getCrypto()!, "createRecoveryKeyFromPassphrase").mockResolvedValue({
- privateKey: new Uint8Array(),
- encodedPrivateKey: "abcd efgh ijkl",
- });
- result.getByRole("button", { name: "Continue" }).click();
-
- await result.findByText(/Save your Recovery Key/);
- result.getByRole("button", { name: "Copy" }).click();
-
- // Resetting should reset secret storage, cross signing, and key
- // backup. We make sure that all three are reset, and done in the
- // right order.
- const resetFunctionCallLog: string[] = [];
- jest.spyOn(mockClient.getCrypto()!, "bootstrapSecretStorage").mockImplementation(async () => {
- resetFunctionCallLog.push("bootstrapSecretStorage");
- });
- jest.spyOn(mockClient.getCrypto()!, "bootstrapCrossSigning").mockImplementation(async () => {
- resetFunctionCallLog.push("bootstrapCrossSigning");
- });
- jest.spyOn(mockClient.getCrypto()!, "resetKeyBackup").mockImplementation(async () => {
- resetFunctionCallLog.push("resetKeyBackup");
- });
-
- await flushPromises();
- result.getByRole("button", { name: "Continue" }).click();
-
- await result.findByText("Your keys are now being backed up from this device.");
-
- expect(resetFunctionCallLog).toEqual(["bootstrapSecretStorage", "bootstrapCrossSigning", "resetKeyBackup"]);
- });
});
diff --git a/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap
deleted file mode 100644
index 60a051eec9..0000000000
--- a/test/unit-tests/components/views/dialogs/security/__snapshots__/CreateKeyBackupDialog-test.tsx.snap
+++ /dev/null
@@ -1,168 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`CreateKeyBackupDialog should display an error message when backup creation failed 1`] = `
-
-
-
-
-
- Starting backup…
-
-
-
-
-
- Unable to create key backup
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`CreateKeyBackupDialog should display the spinner when creating backup 1`] = `
-
-
-
-
-
- Starting backup…
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`CreateKeyBackupDialog should display the success dialog when the key backup is finished 1`] = `
-
-
-
-
-
- Success!
-
-
-
-
-
- Your keys are being backed up (the first backup could take a few minutes).
-
`;
diff --git a/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx
index 706efb2b90..3286e943b4 100644
--- a/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx
+++ b/test/unit-tests/components/views/settings/encryption/ChangeRecoveryKey-test.tsx
@@ -104,12 +104,14 @@ describe("", () => {
expect(screen.getByText("The recovery key you entered is not correct.")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
+ const setAccountDataSpy = jest.spyOn(matrixClient, "setAccountData");
await userEvent.clear(input);
// If the user enters the correct recovery key, the finish button should be enabled
await userEvent.type(input, "encoded private key");
await waitFor(() => expect(finishButton).not.toHaveAttribute("aria-disabled", "true"));
await user.click(finishButton);
+ expect(setAccountDataSpy).toHaveBeenCalledWith("io.element.recovery", { enabled: true });
expect(onFinish).toHaveBeenCalledWith();
});
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 fda6b29aa1..d283890689 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
@@ -12,7 +12,7 @@ exports[` should allow to change the recovery key when everythi
- Recovery
+ Recovery
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
@@ -51,12 +51,9 @@ exports[` should ask to set up a recovery key when there is no
class="mx_SettingsSection_header"
>
- Recovery
-
- Recommended
-
+ Recovery
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
@@ -97,7 +94,7 @@ exports[` should be in loading state when checking the recovery
- Recovery
+ Recovery
Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.
diff --git a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap
index edf7de5af9..ca4914e198 100644
--- a/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap
+++ b/test/unit-tests/components/views/settings/encryption/__snapshots__/RecoveryPanelOutOfSync-test.tsx.snap
@@ -12,7 +12,7 @@ exports[` should render 1`] = `