1
0
mirror of https://github.com/element-hq/element-web.git synced 2026-10-23 11:09:01 +03:00

Drop support for migrating libolm sessions (#35138)

* Remove the libolm to rust crypto store migration

* Add LegacyCryptoUnsupportedView to shared-components

* Show LegacyCryptoUnsupportedView when an unmigrated libolm crypto store is found

* Replace crypto migration e2e test with legacy crypto store test

* Cleanup loadSession, reduce congivitive complexity.

* Improve centering and spacing of view

* update snap

* changes for review

* lint

* Don't format the dump file

* Re-add missing files
This commit is contained in:
Will Hunt
2026-10-08 11:27:39 +00:00
committed by GitHub
parent 80e525a6dc
commit 56ec209d49
33 changed files with 699 additions and 312 deletions

View File

@@ -43,7 +43,7 @@
// Downloaded and already minified,
"/apps/web/res/jitsi_external_api.min.js",
// This file is also machine-generated,
"/apps/web/playwright/e2e/crypto/test_indexeddb_cryptostore_dump/dump.json",
"/apps/web/playwright/e2e/crypto/test_legacy_indexeddb_cryptostore_dump/dump.json",
"/apps/web/playwright/test-results/",
"/apps/web/playwright/html-report/",
"/apps/web/playwright/logs/",

View File

@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { expect, test } from "../../element-web-test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
test.describe("legacy crypto store", function () {
test.use({
displayName: "Alice",
// Replace the `user` fixture with one which populates the legacy indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_legacy_indexeddb_cryptostore_dump/*`, async (route, request) => {
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
const body = await readFile(resourcePath, { encoding: "utf-8" });
await route.fulfill({ body });
});
await page.goto("/test_legacy_indexeddb_cryptostore_dump/index.html");
await use(credentials);
},
});
test("Should explain that a device with an unmigrated legacy crypto store is too old", async ({
context,
user,
page,
}) => {
await expect(page.getByRole("heading", { name: "This device cannot be used" })).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole("button", { name: "Remove this device" })).toBeVisible();
});
});

View File

@@ -1,66 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023, 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.
*/
import path from "node:path";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { expect, test } from "../../element-web-test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
test.describe("migration", { tag: "@no-webkit" }, function () {
test.use({
displayName: "Alice",
// Replace the `user` fixture with one which populates the indexeddb data before starting the app.
user: async ({ context, pageWithCredentials: page, credentials }, use) => {
await page.route(`/test_indexeddb_cryptostore_dump/*`, async (route, request) => {
const resourcePath = path.join(__dirname, new URL(request.url()).pathname);
const body = await readFile(resourcePath, { encoding: "utf-8" });
await route.fulfill({ body });
});
await page.goto("/test_indexeddb_cryptostore_dump/index.html");
await use(credentials);
},
});
test("Should support migration from legacy crypto", async ({ context, user, page }, workerInfo) => {
test.slow();
// We should see a migration progress bar
await page.getByText("Hang tight.").waitFor({ timeout: 60000 });
// When the progress bar first loads, it should have a high max (one per megolm session to import), and
// a relatively low value.
const progressBar = page.getByRole("progressbar");
const initialProgress = parseFloat((await progressBar.getAttribute("value"))!);
const initialMax = parseFloat((await progressBar.getAttribute("max"))!);
expect(initialMax).toBeGreaterThan(4000);
expect(initialProgress).toBeGreaterThanOrEqual(0);
expect(initialProgress).toBeLessThanOrEqual(500);
// Later, the progress should pass 50%
await expect
.poll(
async () => {
const progressBar = page.getByRole("progressbar");
return (
(parseFloat((await progressBar.getAttribute("value"))!) * 100.0) /
parseFloat((await progressBar.getAttribute("max"))!)
);
},
{ timeout: 60000 },
)
.toBeGreaterThan(50);
// Eventually, we should get a normal matrix chat
await page.waitForSelector(".mx_MatrixChat", { timeout: 120000 });
});
});

View File

@@ -6,23 +6,6 @@ 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.
*/
.mx_LoginSplashView_migrationProgress {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
.mx_ProgressBar {
height: 8px;
width: 600px;
@mixin ProgressBarBorderRadius 8px;
}
}
.mx_LoginSplashView_splashButtons {
text-align: center;
width: 100%;

View File

@@ -26,6 +26,7 @@ import ActiveWidgetStore from "./stores/ActiveWidgetStore";
import PlatformPeg from "./PlatformPeg";
import { sendLoginRequest } from "./Login";
import * as StorageManager from "./utils/StorageManager";
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
import * as StorageAccess from "./utils/StorageAccess";
import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel";
@@ -133,15 +134,105 @@ function checkSessionLock(): void {
class SessionLockStolenError extends Error {}
interface ILoadSessionOpts {
/** Set to true to enable guest access tokens and auto-guest registrations. */
enableGuest?: boolean;
/** Homeserver URL. Only used if `enableGuest` is true; defines the HS to register against. */
guestHsUrl?: string;
/** Identity server URL. Only used if `enableGuest` is true; defines the IS to use. */
guestIsUrl?: string;
/** If the stored session is a guest account, ignore it and don't load it. */
ignoreGuest?: boolean;
/** Default display name to use when registering as a guest. */
defaultDeviceDisplayName?: string;
/** The parameters read in at app load time from the URL. */
urlParams?: URLParams;
/**
* Signal that is aborted if the server logs this session out while we are loading it (for example because the
* access token expired). If loading then fails, the error is ignored and `loadSession` resolves to `false`.
*/
abortSignal?: AbortSignal;
}
/**
* Get the homeserver URL to use for guest access.
*
* @returns the URL, or undefined if guest access is disabled or no homeserver URL was given.
*/
function getGuestHsUrl(opts: ILoadSessionOpts): string | undefined {
if (!opts.enableGuest) return undefined;
if (!opts.guestHsUrl) {
logger.warn("Cannot enable guest access: can't determine HS URL to use");
return undefined;
}
return opts.guestHsUrl;
}
/**
* Attempt to load the session, taking into account whether guest
* parameters were provided or the session lock had been used.
*/
async function tryLoadSession(opts: ILoadSessionOpts): Promise<boolean> {
const guestHsUrl = getGuestHsUrl(opts);
const guestCreds = opts.urlParams?.guest;
if (guestHsUrl && guestCreds?.guest_user_id && guestCreds.guest_access_token) {
logger.log("Using guest access credentials");
await doSetLoggedIn(
{
userId: guestCreds.guest_user_id,
accessToken: guestCreds.guest_access_token,
homeserverUrl: guestHsUrl,
identityServerUrl: opts.guestIsUrl,
guest: true,
},
true,
false,
);
return true;
}
if (await restoreSessionFromStorage({ ignoreGuest: Boolean(opts.ignoreGuest) })) {
return true;
}
if (sessionLockStolen) {
return false;
}
if (guestHsUrl) {
return registerAsGuest(guestHsUrl, opts.guestIsUrl, opts.defaultDeviceDisplayName);
}
// fall back to welcome screen
return false;
}
/**
* Handle an error thrown by {@link tryLoadSession}.
*
* @returns `false` to fall back to the welcome screen, or the result of {@link handleLoadSessionFailure}.
* @throws {@link LegacyCryptoStoreError} so that MatrixChat can show a dedicated error screen for it.
*/
async function handleLoadSessionError(e: unknown, opts: ILoadSessionOpts): Promise<boolean> {
// We may be aborted e.g. because our token expired, so don't show an error here
if (opts.abortSignal?.aborted) {
return false;
}
if (e instanceof LegacyCryptoStoreError) {
// This session predates the rust crypto stack and cannot be migrated. Let this
// propagate up to MatrixChat, which shows a dedicated error screen for it.
throw e;
}
// If we're aborting login because of a storage inconsistency, or the session lock was stolen while we were
// trying to start, we don't need to show the general failure dialog. Instead, just go back to welcome.
if (e instanceof AbortLoginAndRebuildStorage || sessionLockStolen) {
return false;
}
return handleLoadSessionFailure(e, opts);
}
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
* a number of things:
@@ -155,85 +246,15 @@ interface ILoadSessionOpts {
* If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in
* turn will raise on_logged_in and will_start_client events.
*
* @param {object} [opts]
* @param {object} [opts.fragmentQueryParams]: string->string map of the
* query-parameters extracted from the #-fragment of the starting URI.
* @param {boolean} [opts.enableGuest]: set to true to enable guest access
* tokens and auto-guest registrations.
* @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the HS to register against.
* @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest
* is true; defines the IS to use.
* @param {bool} [opts.ignoreGuest]: If the stored session is a guest account,
* ignore it and don't load it.
* @param {string} [opts.defaultDeviceDisplayName]: Default display name to use
* when registering as a guest.
* @returns {Promise} a promise which resolves when the above process completes.
* @returns A promise which resolves when the above process completes.
* Resolves to `true` if we ended up starting a session, or `false` if we
* failed.
*/
export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean> {
try {
let enableGuest = opts.enableGuest || false;
const guestHsUrl = opts.guestHsUrl;
const guestIsUrl = opts.guestIsUrl;
const urlParams = opts.urlParams;
const defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
if (enableGuest && !guestHsUrl) {
logger.warn("Cannot enable guest access: can't determine HS URL to use");
enableGuest = false;
}
if (enableGuest && guestHsUrl && urlParams?.guest?.guest_user_id && urlParams?.guest?.guest_access_token) {
logger.log("Using guest access credentials");
await doSetLoggedIn(
{
userId: urlParams.guest.guest_user_id,
accessToken: urlParams.guest.guest_access_token,
homeserverUrl: guestHsUrl,
identityServerUrl: guestIsUrl,
guest: true,
},
true,
false,
);
return true;
}
const success = await restoreSessionFromStorage({
ignoreGuest: Boolean(opts.ignoreGuest),
});
if (success) {
return true;
}
if (sessionLockStolen) {
return false;
}
if (enableGuest && guestHsUrl) {
return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName);
}
// fall back to welcome screen
return false;
return await tryLoadSession(opts);
} catch (e) {
// We may be aborted e.g. because our token expired, so don't show an error here
if (opts.abortSignal?.aborted) {
return false;
}
if (e instanceof AbortLoginAndRebuildStorage) {
// If we're aborting login because of a storage inconsistency, we don't
// need to show the general failure dialog. Instead, just go back to welcome.
return false;
}
// likewise, if the session lock has been stolen while we've been trying to start
if (sessionLockStolen) {
return false;
}
return handleLoadSessionFailure(e, opts);
return handleLoadSessionError(e, opts);
}
}

View File

@@ -17,6 +17,8 @@ import { advanceDateAndTime, stubClient, createTestClient } from "test-utils";
import { type IMatrixClientPeg, MatrixClientPeg as peg } from "./MatrixClientPeg";
import SdkConfig from "./SdkConfig";
import { createClientWithCreds } from "./utils/createMatrixClient";
import * as StorageManager from "./utils/StorageManager";
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
vi.useFakeTimers();
@@ -112,13 +114,21 @@ describe("MatrixClientPeg", () => {
expect(mockStartDehydration).toHaveBeenCalledWith({ onlyIfKeyCached: true, rehydrate: false });
});
it("Should migrate existing login", async () => {
it("should initialise the rust crypto library for an existing login", async () => {
const mockInitRustCrypto = vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
await testPeg.start();
expect(mockInitRustCrypto).toHaveBeenCalledTimes(1);
});
it("should refuse to start a session which still has an unmigrated legacy crypto store", async () => {
vi.spyOn(StorageManager, "hasUnmigratedLegacyCryptoStore").mockResolvedValue(true);
const mockInitRustCrypto = vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
await expect(testPeg.start()).rejects.toThrow(LegacyCryptoStoreError);
expect(mockInitRustCrypto).not.toHaveBeenCalled();
});
it("should poll the client well-known by default", async () => {
vi.spyOn(testPeg.safeGet(), "initRustCrypto").mockResolvedValue(undefined);
const startClient = vi.spyOn(testPeg.safeGet(), "startClient").mockResolvedValue(undefined);

View File

@@ -27,6 +27,7 @@ import PlatformPeg from "./PlatformPeg";
import SdkConfig from "./SdkConfig";
import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts";
import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration";
import { LegacyCryptoStoreError } from "./utils/LegacyCryptoStoreError.ts";
export interface MatrixClientPegAssignOpts {
/**
@@ -311,6 +312,12 @@ class MatrixClientPegClass implements IMatrixClientPeg {
logger.error("Warning! Not using an encryption key for rust crypto store.");
}
// Sessions which still hold a legacy (libolm) crypto store were never migrated to the rust
// crypto stack, and we no longer have any way to migrate them. Bail out.
if (await StorageManager.hasUnmigratedLegacyCryptoStore()) {
throw new LegacyCryptoStoreError();
}
await this.matrixClient.initRustCrypto({
storageKey: opts.rustCryptoStoreKey,
storagePassword: opts.rustCryptoStorePassword,

View File

@@ -25,7 +25,9 @@ import { LruCache } from "./utils/LruCache";
export type ScreenName = ScreenEvent["$current_url"];
export type InteractionName = InteractionEvent["name"];
const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN>, ScreenName> = {
// `Views.LEGACY_CRYPTO_UNSUPPORTED` is omitted: the analytics schema has no screen name for it,
// and analytics are not initialised for a session we refuse to start anyway.
const notLoggedInMap: Record<Exclude<Views, Views.LOGGED_IN | Views.LEGACY_CRYPTO_UNSUPPORTED>, ScreenName> = {
[Views.LOADING]: "Loading",
[Views.CONFIRM_LOCK_THEFT]: "ConfirmStartup",
[Views.WELCOME]: "Welcome",
@@ -75,6 +77,8 @@ export default class PosthogTrackers {
}
private trackPage(durationMs?: number): void {
if (this.view === Views.LEGACY_CRYPTO_UNSUPPORTED) return;
const screenName =
this.view === Views.LOGGED_IN ? loggedInPageTypeMap[this.pageType!] : notLoggedInMap[this.view];
PosthogAnalytics.instance.trackEvent<ScreenEvent>({

View File

@@ -85,6 +85,15 @@ Please see LICENSE files in the repository root for full details.
* │ LOCK_STOLEN │
* │ │
* └─────────────────┘
*
* (from LOADING)
* │
* │ Un-migratable legacy crypto store found
* ▼
* ┌───────────────────────────┐
* │ LEGACY_CRYPTO_UNSUPPORTED │
* │ │
* └───────────────────────────┘
*/
enum Views {
// a special initial state which is only used at startup, while we are
@@ -128,6 +137,13 @@ enum Views {
// Another instance of the application has started up. We just show an error page.
LOCK_STOLEN,
/**
* We found a crypto store created by the legacy (libolm) crypto stack which was never migrated
* to the rust stack, and which we can no longer migrate. We just show an error page inviting
* the user to sign out and back in.
*/
LEGACY_CRYPTO_UNSUPPORTED,
}
export default Views;

View File

@@ -62,6 +62,7 @@ import PlatformPeg from "../../PlatformPeg";
import EventIndexPeg from "../../indexing/EventIndexPeg";
import MediaDeviceHandler from "../../MediaDeviceHandler";
import * as Lifecycle from "../../Lifecycle";
import { LegacyCryptoStoreError } from "../../utils/LegacyCryptoStoreError.ts";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../BasePlatform";
import SettingsStore from "../../settings/SettingsStore";
import { SettingLevel } from "../../settings/SettingLevel";
@@ -355,6 +356,19 @@ describe("<MatrixChat />", () => {
expect(container).toMatchSnapshot();
});
it("should show the 'This device cannot be used' screen if the device was never migrated off legacy crypto", async () => {
// Starting the client is what runs crypto init; make it fail the way MatrixClientPeg does
// for a device which still holds an un-migrated legacy crypto store.
vi.spyOn(Lifecycle, "loadSession").mockRejectedValue(new LegacyCryptoStoreError());
vi.spyOn(logger, "error").mockImplementation(() => {});
getComponent();
await waitFor(() => {
expect(screen.getByRole("heading", { name: "This device cannot be used" })).toBeInTheDocument();
});
});
it("should fire to focus the message composer", async () => {
getComponent();
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "composer" });

View File

@@ -30,7 +30,13 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility
import "what-input";
import { sanitizeHtml } from "@element-hq/element-web-shared-utils";
import { I18nContext, LinkedTextContext, LinkedText, GenericToast } from "@element-hq/web-shared-components";
import {
I18nContext,
LinkedTextContext,
LinkedText,
GenericToast,
LegacyCryptoUnsupportedView,
} from "@element-hq/web-shared-components";
import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import PosthogTrackers from "../../PosthogTrackers";
@@ -129,6 +135,7 @@ import { Filter } from "../views/dialogs/spotlight/Filter";
import { SessionLockStolenView } from "./auth/SessionLockStolenView";
import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView";
import { LoginSplashView } from "./auth/LoginSplashView";
import { LegacyCryptoStoreError } from "../../utils/LegacyCryptoStoreError.ts";
import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
import { InitialCryptoSetupStore } from "../../stores/InitialCryptoSetupStore";
import { setTheme } from "../../theme";
@@ -142,6 +149,7 @@ import { type IScreen } from "../../vector/routing.ts";
import { type URLParams } from "../../vector/url_utils.ts";
import { type QrLoginCredentials } from "../views/auth/LoginWithQR.tsx";
import { configureFromCompletedOAuthLogin } from "../../Lifecycle";
import { LegacyCryptoUnsupportedViewModel } from "../../viewmodels/crypto/legacyCryptoUnsupportedViewModel.ts";
const AUTH_SCREENS = ["register", "mobile_register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
@@ -292,8 +300,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const initProm = this.initSession();
initProm.catch((err) => {
// TODO: show an error screen, rather than a spinner of doom
logger.error("Error initialising Matrix session", err);
if (err instanceof LegacyCryptoStoreError) {
this.setState({ view: Views.LEGACY_CRYPTO_UNSUPPORTED });
}
});
};
@@ -2220,13 +2231,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return <E2eSetup onCancelled={this.onCompleteSecurityE2eSetupFinished} />;
case Views.PENDING_CLIENT_START:
// we think we are logged in, but are still waiting for the /sync to complete
return (
<LoginSplashView
matrixClient={MatrixClientPeg.safeGet()}
onLogoutClick={this.onLogoutClick}
syncError={this.state.syncError}
/>
);
return <LoginSplashView onLogoutClick={this.onLogoutClick} syncError={this.state.syncError} />;
case Views.LOGGED_IN:
// `ready` and `view==LOGGED_IN` may be set before `page_type` (because the
// latter is set via the dispatcher). If we don't yet have a `page_type`,
@@ -2248,13 +2253,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
} else {
// we think we are logged in, but are still waiting for the /sync to complete
return (
<LoginSplashView
matrixClient={MatrixClientPeg.safeGet()}
onLogoutClick={this.onLogoutClick}
syncError={this.state.syncError}
/>
);
return <LoginSplashView onLogoutClick={this.onLogoutClick} syncError={this.state.syncError} />;
}
case Views.WELCOME:
return <Welcome {...this.getServerProperties()} />;
@@ -2310,6 +2309,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
case Views.LOCK_STOLEN:
return <SessionLockStolenView />;
case Views.LEGACY_CRYPTO_UNSUPPORTED:
return (
<LegacyCryptoUnsupportedView
vm={
new LegacyCryptoUnsupportedViewModel({
dispatcher: dis,
brand: SdkConfig.get().brand,
})
}
/>
);
}
}

View File

@@ -8,25 +8,15 @@ Please see LICENSE files in the repository root for full details.
// @vitest-environment happy-dom
import { vi, describe, it, expect, beforeEach } from "vitest";
import { act, render, type RenderResult } from "test-utils-rtl";
import { vi, describe, it, expect } from "vitest";
import { render, type RenderResult } from "test-utils-rtl";
import React, { type ComponentProps } from "react";
import EventEmitter from "node:events";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { sleep } from "matrix-js-sdk/src/utils";
import { LoginSplashView } from "./LoginSplashView";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
describe("<LoginSplashView />", () => {
let matrixClient: MatrixClient;
beforeEach(() => {
matrixClient = new EventEmitter() as unknown as MatrixClient;
});
function getComponent(props: Partial<ComponentProps<typeof LoginSplashView>> = {}): RenderResult {
const defaultProps = {
matrixClient,
onLogoutClick: () => {},
syncError: null,
};
@@ -51,20 +41,4 @@ describe("<LoginSplashView />", () => {
rendered.getByRole("button", { name: "Logout" }).click();
expect(onLogoutClick).toHaveBeenCalled();
});
it("Shows migration progress", async () => {
const rendered = getComponent();
act(() => {
matrixClient.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, 5, 10);
});
rendered.getByText("Hang tight.", { exact: false });
// Wait for the animation to update
await act(() => sleep(500));
const progress = rendered.getByRole("progressbar");
expect(progress.getAttribute("value")).toEqual("5");
expect(progress.getAttribute("max")).toEqual("10");
});
});

View File

@@ -7,21 +7,13 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { messageForSyncError } from "../../../utils/ErrorUtils";
import Spinner from "../../views/elements/Spinner";
import ProgressBar from "../../views/elements/ProgressBar";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import { _t } from "../../../languageHandler";
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import SdkConfig from "../../../SdkConfig";
interface Props {
/** The matrix client which is logging in */
matrixClient: MatrixClient;
/**
* A callback function. Will be called if the user clicks the "logout" button on the splash screen.
*
@@ -35,42 +27,19 @@ interface Props {
syncError: Error | null;
}
type MigrationState = {
progress: number;
totalSteps: number;
};
/**
* The view that is displayed after we have logged in, before the first /sync is completed.
*/
export function LoginSplashView(props: Props): JSX.Element {
const migrationState = useTypedEventEmitterState(
props.matrixClient,
CryptoEvent.LegacyCryptoStoreMigrationProgress,
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
);
let errorBox: JSX.Element | undefined;
if (props.syncError) {
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
}
// If we are migrating the crypto data, show a progress bar. Otherwise, show a normal spinner.
let spinnerOrProgress;
if (migrationState.totalSteps !== -1) {
spinnerOrProgress = (
<div className="mx_LoginSplashView_migrationProgress">
<p>{_t("migrating_crypto", { brand: SdkConfig.get().brand })}</p>
<ProgressBar value={migrationState.progress} max={migrationState.totalSteps} />
</div>
);
} else {
spinnerOrProgress = <Spinner />;
}
return (
<div className="mx_MatrixChat_splash">
{errorBox}
{spinnerOrProgress}
<Spinner />
<div className="mx_LoginSplashView_splashButtons">
<AccessibleButton kind="link_inline" onClick={props.onLogoutClick}>
{_t("action|logout")}

View File

@@ -1704,7 +1704,6 @@
},
"member_list_back_action_label": "Room members",
"message_edit_dialog_title": "Message edits",
"migrating_crypto": "Hang tight. We are updating %(brand)s to make encryption faster and more reliable.",
"mobile_guide": {
"toast_accept": "Use app",
"toast_description": "%(brand)s is experimental on a mobile web browser. For a better experience and the latest features, use our free native app.",

View File

@@ -0,0 +1,22 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/**
* Error thrown when we find a crypto store which was created by a version of the application using
* the legacy (libolm) crypto stack, and which was never migrated to the Rust crypto stack.
*
* Thrown by `MatrixClientPeg` when setting up the client's crypto, and rethrown by `Lifecycle.loadSession`. `MatrixChat`
* catches it and switches to `Views.LEGACY_CRYPTO_UNSUPPORTED`, which explains the problem to the user.
*/
export class LegacyCryptoStoreError extends Error {
public constructor() {
super(
"This session was created by a version of the application which used the legacy crypto stack, " +
"and cannot be migrated. The user must sign out and sign in again.",
);
}
}

View File

@@ -12,7 +12,6 @@ import "fake-indexeddb/auto";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { IDBFactory } from "fake-indexeddb";
import { IndexedDBCryptoStore } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as StorageManager from "./StorageManager";
@@ -42,20 +41,6 @@ describe("StorageManager", () => {
});
}
async function populateLegacyStore(migrationState: number | undefined) {
const db = await createDB(LEGACY_CRYPTO_STORE_NAME, [IndexedDBCryptoStore.STORE_ACCOUNT]);
if (migrationState) {
const transaction = db.transaction([IndexedDBCryptoStore.STORE_ACCOUNT], "readwrite");
const store = transaction.objectStore(IndexedDBCryptoStore.STORE_ACCOUNT);
store.put(migrationState, "migrationState");
await new Promise((resolve, reject) => {
transaction.oncomplete = resolve;
transaction.onerror = reject;
});
}
}
beforeEach(() => {
global.structuredClone = (v) => JSON.parse(JSON.stringify(v));
});
@@ -85,39 +70,60 @@ describe("StorageManager", () => {
expect(result.dataInCryptoStore).toBe(true);
});
describe("without rust store", () => {
it("should be ok if there is non migrated legacy crypto store", async () => {
await populateLegacyStore(undefined);
it("should not be ok if a legacy crypto store is the only crypto store", async () => {
await createDB(LEGACY_CRYPTO_STORE_NAME);
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(true);
});
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(false);
});
it("should be ok if legacy store in MigrationState `NOT_STARTED`", async () => {
await populateLegacyStore(0 /* MigrationState.NOT_STARTED*/);
it("should not be healthy if no indexeddb", async () => {
indexedDB = {} as IDBFactory;
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(true);
});
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(false);
it("should not be ok if MigrationState greater than `NOT_STARTED`", async () => {
await populateLegacyStore(1 /*INITIAL_DATA_MIGRATED*/);
indexedDB = new IDBFactory();
});
});
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(true);
expect(result.dataInCryptoStore).toBe(false);
});
describe("hasUnmigratedLegacyCryptoStore", () => {
beforeEach(() => {
indexedDB = new IDBFactory();
});
it("should not be healthy if no indexeddb", async () => {
indexedDB = {} as IDBFactory;
it("should be false when there are no crypto stores", async () => {
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
});
const result = await StorageManager.checkConsistency();
expect(result.healthy).toBe(false);
it("should be false when there is only a rust crypto store", async () => {
await createDB(RUST_CRYPTO_STORE_NAME);
indexedDB = new IDBFactory();
});
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
});
it("should be true when there is only a legacy crypto store", async () => {
await createDB(LEGACY_CRYPTO_STORE_NAME);
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(true);
});
it("should be false when a migrated session has both stores", async () => {
await createDB(LEGACY_CRYPTO_STORE_NAME);
await createDB(RUST_CRYPTO_STORE_NAME);
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
});
it("should be false if indexeddb is inaccessible", async () => {
vi.spyOn(logger, "error").mockImplementation(() => {});
indexedDB = {} as IDBFactory;
await expect(StorageManager.hasUnmigratedLegacyCryptoStore()).resolves.toBe(false);
indexedDB = new IDBFactory();
vi.restoreAllMocks();
});
});

View File

@@ -179,37 +179,45 @@ async function checkSyncStore(): Promise<StoreCheck> {
}
async function checkCryptoStore(): Promise<StoreCheck> {
// check first if there is a rust crypto store
try {
const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME);
log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`);
if (rustDbExists) {
// There was an existing rust database, so consider it healthy.
return { exists: true, healthy: true };
} else {
// No rust store, so let's check if there is a legacy store not yet migrated.
try {
const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated(
getIDBFactory()!,
LEGACY_CRYPTO_STORE_NAME,
);
log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`);
return { exists: legacyIdbExists, healthy: true };
} catch (e) {
error("Legacy crypto store using IndexedDB inaccessible", e);
}
// No need to check local storage or memory as rust stack doesn't support them.
// Given that rust stack requires indexeddb, set healthy to false.
return { exists: false, healthy: false };
}
// No need to check local storage or memory as the rust stack doesn't support them.
// A missing store is not in itself unhealthy: that is the normal state before the first
// login. `checkConsistency` separately flags the case where we expected data to be there.
return { exists: rustDbExists, healthy: true };
} catch (e) {
error("Rust crypto store using IndexedDB inaccessible", e);
return { exists: false, healthy: false };
}
}
/**
* Check whether this session was created by a version of the application which used the legacy
* (libolm) crypto stack, and was never migrated to the Rust crypto stack.
*
* The rust store is checked first, because a session which *was* migrated successfully still has
* the legacy database sitting on disk alongside the rust one.
*
* @returns true if there is a legacy crypto store and no rust crypto store.
*/
export async function hasUnmigratedLegacyCryptoStore(): Promise<boolean> {
const idb = getIDBFactory();
if (!idb) return false;
try {
if (await IndexedDBCryptoStore.exists(idb, RUST_CRYPTO_STORE_NAME)) return false;
const legacyDbExists = await IndexedDBCryptoStore.exists(idb, LEGACY_CRYPTO_STORE_NAME);
log(`Legacy crypto store using IndexedDB contains data? ${legacyDbExists}`);
return legacyDbExists;
} catch (e) {
error("Crypto stores using IndexedDB inaccessible", e);
return false;
}
}
/**
* Sets whether crypto has ever been successfully
* initialised on this client.

View File

@@ -10,11 +10,8 @@ import {
type MatrixClient,
createClient,
type ICreateClientOpts,
MemoryCryptoStore,
MemoryStore,
IndexedDBCryptoStore,
IndexedDBStore,
LocalStorageCryptoStore,
RoomNameType,
type RoomNameState,
EventTimelineSet,
@@ -134,7 +131,6 @@ export function createClientWithCreds(creds: IMatrixClientCreds, oauthClientId?:
oauthClientId,
userId: creds.userId,
deviceId: creds.deviceId,
pickleKey: creds.pickleKey,
timelineSupport: true,
forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"),
fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"),
@@ -170,10 +166,10 @@ export function createClientWithCreds(creds: IMatrixClientCreds, oauthClientId?:
* Create a new matrix client, with the persistent stores set up appropriately
* (using localstorage/indexeddb, etc)
*
* @param {Object} opts options to pass to Matrix.createClient. This will be
* extended with `sessionStore` and `store` members.
* @param opts - options to pass to Matrix.createClient. This will be
* extended with `store` and `useAuthorizationHeader` members.
*
* @returns {MatrixClient} the newly-created MatrixClient
* @returns the newly-created MatrixClient
*/
export function createMatrixClient(opts: ICreateClientOpts): MatrixClient {
const storeOpts: Partial<ICreateClientOpts> = {
@@ -191,14 +187,6 @@ export function createMatrixClient(opts: ICreateClientOpts): MatrixClient {
storeOpts.store = new MemoryStore({ localStorage });
}
if (indexedDB) {
storeOpts.cryptoStore = new IndexedDBCryptoStore(indexedDB, "matrix-js-sdk:crypto");
} else if (localStorage) {
storeOpts.cryptoStore = new LocalStorageCryptoStore(localStorage);
} else {
storeOpts.cryptoStore = new MemoryCryptoStore();
}
return createClient({
...storeOpts,
...opts,

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
import { LAST_LEGACY_CRYPTO_VERSION, LegacyCryptoUnsupportedViewModel } from "./legacyCryptoUnsupportedViewModel";
import { MatrixDispatcher } from "../../dispatcher/dispatcher";
describe("LegacyCryptoUnsupportedViewModel", () => {
let dispatcher: MatrixDispatcher;
beforeEach(() => {
dispatcher = new MatrixDispatcher();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("exposes the brand and the last supported version in the snapshot", () => {
const vm = new LegacyCryptoUnsupportedViewModel({ dispatcher, brand: "Acme Chat" });
expect(vm.getSnapshot()).toEqual({ brand: "Acme Chat", version: LAST_LEGACY_CRYPTO_VERSION });
});
it("dispatches a logout when the sign out action is invoked", () => {
const dispatch = vi.spyOn(dispatcher, "dispatch");
const vm = new LegacyCryptoUnsupportedViewModel({ dispatcher, brand: "Element" });
vm.onSignOutClick();
expect(dispatch).toHaveBeenCalledWith({ action: "logout" });
});
});

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
import {
BaseViewModel,
type LegacyCryptoUnsupportedViewSnapshot,
type LegacyCryptoUnsupportedViewModel as LegacyCryptoUnsupportedViewModelInterface,
} from "@element-hq/web-shared-components";
import type { MatrixDispatcher } from "../../dispatcher/dispatcher";
/**
* The last release that could still migrate a libolm crypto store. Users on an older session must
* downgrade to this version to migrate, or remove the device and sign in again.
*/
export const LAST_LEGACY_CRYPTO_VERSION = "v1.12.30";
interface LegacyCryptoUnsupportedViewModelProps {
/**
* Dispatcher used to trigger the sign out.
*/
dispatcher: MatrixDispatcher;
/**
* The brand name for the client.
*/
brand: string;
}
/**
* ViewModel for the screen shown when the session's crypto store predates the current crypto stack
* and cannot be migrated. The only action offered is signing out.
*/
export class LegacyCryptoUnsupportedViewModel
extends BaseViewModel<LegacyCryptoUnsupportedViewSnapshot, LegacyCryptoUnsupportedViewModelProps>
implements LegacyCryptoUnsupportedViewModelInterface
{
public constructor(props: LegacyCryptoUnsupportedViewModelProps) {
super(props, {
brand: props.brand,
version: LAST_LEGACY_CRYPTO_VERSION,
});
}
/**
* Signs the user out. The crypto store cannot be migrated, so removing the device is the only way forward.
*/
public onSignOutClick = (): void => {
this.props.dispatcher.dispatch({ action: "logout" });
};
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
.container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.body {
max-width: 400px;
text-align: center;
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
LegacyCryptoUnsupportedView,
type LegacyCryptoUnsupportedViewActions,
type LegacyCryptoUnsupportedViewSnapshot,
} from "./LegacyCryptoUnsupportedView";
import { useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
type WrapperProps = LegacyCryptoUnsupportedViewSnapshot & LegacyCryptoUnsupportedViewActions;
const LegacyCryptoUnsupportedViewWrapperImpl = ({ onSignOutClick, ...snapshotProps }: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, { onSignOutClick });
return <LegacyCryptoUnsupportedView vm={vm} />;
};
const LegacyCryptoUnsupportedViewWrapper = withViewDocs(
LegacyCryptoUnsupportedViewWrapperImpl,
LegacyCryptoUnsupportedView,
);
const meta = {
title: "Crypto/LegacyCryptoUnsupportedView",
component: LegacyCryptoUnsupportedViewWrapper,
tags: ["autodocs"],
args: {
brand: "Element",
version: "v1.12.30",
onSignOutClick: fn(),
},
parameters: {
layout: "fullscreen",
},
// The view fills its parent, so give it a full-height parent as it has in the app.
decorators: [
(Story) => (
<div style={{ height: "100vh" }}>
<Story />
</div>
),
],
} satisfies Meta<typeof LegacyCryptoUnsupportedViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
import { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import userEvent from "@testing-library/user-event";
import { describe, it, vi, afterEach, expect } from "vitest";
import * as stories from "./LegacyCryptoUnsupportedView.stories";
import {
LegacyCryptoUnsupportedView,
type LegacyCryptoUnsupportedViewActions,
type LegacyCryptoUnsupportedViewSnapshot,
} from "./LegacyCryptoUnsupportedView";
import { MockViewModel } from "../../core/viewmodel/MockViewModel";
const { Default } = composeStories(stories);
describe("LegacyCryptoUnsupportedView", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("Storybook snapshots", () => {
it("renders the default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});
describe("User interactions", () => {
const onSignOutClick = vi.fn();
class TestViewModel
extends MockViewModel<LegacyCryptoUnsupportedViewSnapshot>
implements LegacyCryptoUnsupportedViewActions
{
public onSignOutClick = onSignOutClick;
}
it("should call onSignOutClick when the remove device button is clicked", async () => {
const user = userEvent.setup();
const vm = new TestViewModel({ brand: "Element", version: "v1.12.30" });
render(<LegacyCryptoUnsupportedView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Remove this device" }));
expect(onSignOutClick).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler } from "react";
import { Button, Heading, Text } from "@vector-im/compound-web";
import SignOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/sign-out";
import styles from "./LegacyCryptoUnsupportedView.module.css";
import { type ViewModel, useViewModel } from "../../core/viewmodel";
import { Flex } from "../../core/utils/Flex";
import { useI18n } from "../../core/i18n/i18nContext";
/** Snapshot data for the legacy crypto unsupported screen. */
export interface LegacyCryptoUnsupportedViewSnapshot {
/** The application brand name, for example "Element". */
brand: string;
/** The last application version that still supports migrating this session, for example "v1.12.30". */
version: string;
}
/** User actions emitted by the legacy crypto unsupported screen. */
export interface LegacyCryptoUnsupportedViewActions {
/** Invoked when the sign out button is clicked. */
onSignOutClick: MouseEventHandler<HTMLButtonElement>;
}
/** View model contract for the legacy crypto unsupported screen. */
export type LegacyCryptoUnsupportedViewModel = ViewModel<
LegacyCryptoUnsupportedViewSnapshot,
LegacyCryptoUnsupportedViewActions
>;
interface LegacyCryptoUnsupportedViewProps {
/**
* The view model for the legacy crypto unsupported screen.
*/
vm: LegacyCryptoUnsupportedViewModel;
}
/**
* A full-screen message shown when the session predates the current crypto stack and cannot be migrated.
*
* The only way forward is to sign out and back in, so the view offers a sign out button and nothing else.
*
* @example
* ```tsx
* <LegacyCryptoUnsupportedView vm={legacyCryptoUnsupportedViewModel} />
* ```
*/
export function LegacyCryptoUnsupportedView({ vm }: Readonly<LegacyCryptoUnsupportedViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { brand, version } = useViewModel(vm);
return (
<div className={styles.container}>
<Flex className={styles.body} direction="column" align="center" gap="var(--cpd-space-2x)">
<Heading as="h1" weight="semibold" size="md">
{_t("error|legacy_crypto_unsupported|title")}
</Heading>
<Text size="lg">{_t("error|legacy_crypto_unsupported|description", { brand })}</Text>
<Text size="lg">
{_t("error|legacy_crypto_unsupported|actions", { version }, { b: (t) => <strong>{t}</strong> })}
</Text>
<Button kind="destructive" Icon={SignOutIcon} onClick={vm.onSignOutClick}>
{_t("action|remove_this_device")}
</Button>
</Flex>
</div>
);
}

View File

@@ -0,0 +1,61 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LegacyCryptoUnsupportedView > Storybook snapshots > renders the default state 1`] = `
<div>
<div
style="height: 100vh;"
>
<div
class="LegacyCryptoUnsupportedView-module_container"
>
<div
class="Flex-module_flex LegacyCryptoUnsupportedView-module_body"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
This device cannot be used
</h1>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
Element was updated from a very old version and cannot migrate your encryption store to a new format.
</p>
<p
class="_typography_6v6n8_153 _font-body-lg-regular_6v6n8_69"
>
<span>
Please either downgrade to
<strong>
v1.12.30
</strong>
first or remove this device.
</span>
</p>
<button
class="_button_1nw83_8 _has-icon_1nw83_60 _destructive_1nw83_110"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 12.031q0-.424.288-.712A.97.97 0 0 1 10 11.03h7.15l-1.875-1.875a.96.96 0 0 1-.3-.7q0-.4.325-.725a.93.93 0 0 1 .712-.287.98.98 0 0 1 .688.287l3.6 3.6q.15.15.212.325.063.175.063.375 0 .201-.062.375a.9.9 0 0 1-.213.325l-3.6 3.6q-.3.3-.712.288a.98.98 0 0 1-.688-.288 1.02 1.02 0 0 1-.312-.712.93.93 0 0 1 .287-.713l1.875-1.875H10a.97.97 0 0 1-.712-.287A.97.97 0 0 1 9 12.03m-6-7q0-.824.588-1.412A1.93 1.93 0 0 1 5 3.03h6q.424 0 .713.288.287.287.287.712t-.287.713A.97.97 0 0 1 11 5.03H5v14h6q.424 0 .713.288.287.287.287.712t-.287.713a.97.97 0 0 1-.713.287H5q-.824 0-1.412-.587A1.93 1.93 0 0 1 3 19.03z"
/>
</svg>
Remove this device
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE in the repository root for full details.
*/
export type {
LegacyCryptoUnsupportedViewModel,
LegacyCryptoUnsupportedViewSnapshot,
LegacyCryptoUnsupportedViewActions,
} from "./LegacyCryptoUnsupportedView";
export { LegacyCryptoUnsupportedView } from "./LegacyCryptoUnsupportedView";

View File

@@ -27,6 +27,7 @@
"play": "Play",
"react": "React",
"remove": "Remove",
"remove_this_device": "Remove this device",
"reply": "Reply",
"reply_in_thread": "Reply in thread",
"retry": "Retry",
@@ -94,6 +95,13 @@
"cancel_search_label": "Cancel search",
"emoji_picker": "Emoji picker"
},
"error": {
"legacy_crypto_unsupported": {
"actions": "Please either downgrade to <b>%(version)s</b> first or remove this device.",
"description": "%(brand)s was updated from a very old version and cannot migrate your encryption store to a new format.",
"title": "This device cannot be used"
}
},
"keyboard": {
"shift": "Shift"
},

View File

@@ -19,6 +19,7 @@ export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
export * from "./room/composer/MessageComposerUrlPreview/index.ts";
export * from "./crypto/SasEmoji";
export * from "./crypto/LegacyCryptoUnsupportedView";
export * from "./menus/UserMenu";
export * from "./notifications/NotificationBadgeView";
export * from "./room/timeline/ReadMarker";