diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index 339e94ac54..cb2fff4b36 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -20,10 +20,10 @@ import { import { type AESEncryptedSecretStoragePayload } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; -import { type IMatrixClientCreds, MatrixClientPeg, type MatrixClientPegAssignOpts } from "./MatrixClientPeg"; +import { MatrixClientPeg, type MatrixClientPegAssignOpts } from "./MatrixClientPeg"; import { ModuleRunner } from "./modules/ModuleRunner"; import EventIndexPeg from "./indexing/EventIndexPeg"; -import createMatrixClient from "./utils/createMatrixClient"; +import { createMatrixClient, createClientWithCreds, type IMatrixClientCreds } from "./utils/createMatrixClient"; import Notifier from "./Notifier"; import UserActivity from "./UserActivity"; import Presence from "./Presence"; @@ -884,7 +884,7 @@ async function doSetLoggedIn( // check the session lock just before creating the new client checkSessionLock(); - MatrixClientPeg.replaceUsingCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher)); + MatrixClientPeg.set(createClientWithCreds(credentials, tokenRefresher?.doRefreshAccessToken.bind(tokenRefresher))); const client = MatrixClientPeg.safeGet(); setSentryUser(credentials.userId); diff --git a/apps/web/src/Login.ts b/apps/web/src/Login.ts index 34b6513ad1..07ba1512f8 100644 --- a/apps/web/src/Login.ts +++ b/apps/web/src/Login.ts @@ -19,7 +19,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { type IMatrixClientCreds } from "./MatrixClientPeg"; +import { type IMatrixClientCreds } from "./utils/createMatrixClient"; import { ModuleRunner } from "./modules/ModuleRunner"; import { getOidcClientId } from "./utils/oidc/registerClient"; import { type IConfigOptions } from "./IConfigOptions"; diff --git a/apps/web/src/MatrixClientPeg.ts b/apps/web/src/MatrixClientPeg.ts index a25c733dc3..03d47b0a33 100644 --- a/apps/web/src/MatrixClientPeg.ts +++ b/apps/web/src/MatrixClientPeg.ts @@ -9,52 +9,24 @@ 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 { - EventTimeline, - EventTimelineSet, - type ICreateClientOpts, - type IStartClientOpts, - type MatrixClient, - MemoryStore, - PendingEventOrdering, - type RoomNameState, - RoomNameType, - type TokenRefreshFunction, -} from "matrix-js-sdk/src/matrix"; -import { VerificationMethod } from "matrix-js-sdk/src/types"; +import { type IStartClientOpts, type MatrixClient, MemoryStore, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; import * as utils from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; -import createMatrixClient from "./utils/createMatrixClient"; import SettingsStore from "./settings/SettingsStore"; import MatrixActionCreators from "./actions/MatrixActionCreators"; import Modal from "./Modal"; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from "./utils/StorageManager"; -import IdentityAuthClient from "./IdentityAuthClient"; -import { crossSigningCallbacks } from "./SecurityManager"; import { SlidingSyncManager } from "./SlidingSyncManager"; import { _t, UserFriendlyError } from "./languageHandler"; import MatrixClientBackedController from "./settings/controllers/MatrixClientBackedController"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import PlatformPeg from "./PlatformPeg"; -import { formatList } from "./utils/FormattingUtils"; import SdkConfig from "./SdkConfig"; import { setDeviceIsolationMode } from "./settings/controllers/DeviceIsolationModeController.ts"; import { initialiseDehydrationIfEnabled } from "./utils/device/dehydration"; -export interface IMatrixClientCreds { - homeserverUrl: string; - identityServerUrl?: string; - userId: string; - deviceId?: string; - accessToken: string; - refreshToken?: string; - guest?: boolean; - pickleKey?: string; - freshLogin?: boolean; -} - export interface MatrixClientPegAssignOpts { /** * If we are using Rust crypto, a key with which to encrypt the indexeddb. @@ -98,6 +70,12 @@ export interface IMatrixClientPeg { */ safeGet(): MatrixClient; + /** + * Sets the current MatrixClient. + * @param client The MatrixClient instance to set + */ + set(client: MatrixClient): void; + /** * Unset the current MatrixClient */ @@ -142,16 +120,6 @@ export interface IMatrixClientPeg { * returns a boolean of whether it was after a given timestamp. */ userRegisteredAfter(date: Date): boolean; - - /** - * Replace this MatrixClientPeg's client with a client instance that has - * homeserver / identity server URLs and active credentials - * - * @param {IMatrixClientCreds} creds The new credentials to use. - * @param {TokenRefreshFunction} tokenRefreshFunction OPTIONAL function used by MatrixClient to attempt token refresh - * see {@link ICreateClientOpts.tokenRefreshFunction} - */ - replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void; } /** @@ -183,6 +151,10 @@ class MatrixClientPegClass implements IMatrixClientPeg { return this.matrixClient; } + public set(client: MatrixClient): void { + this.matrixClient = client; + } + public unset(): void { this.matrixClient = null; @@ -224,10 +196,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { } } - public replaceUsingCreds(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { - this.createClient(creds, tokenRefreshFunction); - } - private onUnexpectedStoreClose = async (): Promise => { if (!this.matrixClient) return; this.matrixClient.stopClient(); // stop the client as the database has failed @@ -377,105 +345,6 @@ class MatrixClientPegClass implements IMatrixClientPeg { await this.matrixClient!.startClient(opts); logger.log(`MatrixClientPeg: MatrixClient started`); } - - private namesToRoomName(names: string[], count: number): string | undefined { - const countWithoutMe = count - 1; - if (!names.length) { - return _t("empty_room"); - } - if (names.length === 1 && countWithoutMe <= 1) { - return names[0]; - } - } - - private memberNamesToRoomName(names: string[], count: number): string { - const name = this.namesToRoomName(names, count); - if (name) return name; - - if (names.length === 2 && count === 2) { - return formatList(names); - } - return formatList(names, 1); - } - - private inviteeNamesToRoomName(names: string[], count: number): string { - const name = this.namesToRoomName(names, count); - if (name) return name; - - if (names.length === 2 && count === 2) { - return _t("inviting_user1_and_user2", { - user1: names[0], - user2: names[1], - }); - } - return _t("inviting_user_and_n_others", { - user: names[0], - count: count - 1, - }); - } - - private createClient(creds: IMatrixClientCreds, tokenRefreshFunction?: TokenRefreshFunction): void { - const opts: ICreateClientOpts = { - baseUrl: creds.homeserverUrl, - idBaseUrl: creds.identityServerUrl, - accessToken: creds.accessToken, - refreshToken: creds.refreshToken, - tokenRefreshFunction, - userId: creds.userId, - deviceId: creds.deviceId, - pickleKey: creds.pickleKey, - timelineSupport: true, - forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"), - fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"), - // Gather up to 20 ICE candidates when a call arrives: this should be more than we'd - // ever normally need, so effectively this should make all the gathering happen when - // the call arrives. - iceCandidatePoolSize: 20, - verificationMethods: [ - VerificationMethod.Sas, - VerificationMethod.ShowQrCode, - VerificationMethod.Reciprocate, - ], - identityServer: new IdentityAuthClient(), - // These are always installed regardless of the labs flag so that cross-signing features - // can toggle on without reloading and also be accessed immediately after login. - cryptoCallbacks: { ...crossSigningCallbacks }, - enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"), - unstableMSC1763Retention: SettingsStore.getValue("feature_retention"), - roomNameGenerator: (_: string, state: RoomNameState) => { - switch (state.type) { - case RoomNameType.Generated: - switch (state.subtype) { - case "Inviting": - return this.inviteeNamesToRoomName(state.names, state.count); - default: - return this.memberNamesToRoomName(state.names, state.count); - } - case RoomNameType.EmptyRoom: - if (state.oldName) { - return _t("empty_room_was_name", { - oldName: state.oldName, - }); - } else { - return _t("empty_room"); - } - default: - return null; - } - }, - }; - - this.matrixClient = createMatrixClient(opts); - this.matrixClient.setGuest(Boolean(creds.guest)); - - const notifTimelineSet = new EventTimelineSet(undefined, { - timelineSupport: true, - pendingEvents: false, - }); - // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. - notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); - this.matrixClient.setNotifTimelineSet(notifTimelineSet); - } } /** diff --git a/apps/web/src/components/structures/LoggedInView.tsx b/apps/web/src/components/structures/LoggedInView.tsx index 4a53007527..1de5d2e31c 100644 --- a/apps/web/src/components/structures/LoggedInView.tsx +++ b/apps/web/src/components/structures/LoggedInView.tsx @@ -28,7 +28,7 @@ import { isOnlyCtrlOrCmdKeyEvent, Key } from "../../Keyboard"; import PageTypes from "../../PageTypes"; import MediaDeviceHandler from "../../MediaDeviceHandler"; import dis from "../../dispatcher/dispatcher"; -import { type IMatrixClientCreds } from "../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../utils/createMatrixClient"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; import PlatformPeg from "../../PlatformPeg"; diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index e27ffadb56..71e8740c53 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -32,7 +32,8 @@ import { LockSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icon import PosthogTrackers from "../../PosthogTrackers"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; -import { type IMatrixClientCreds, MatrixClientPeg } from "../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../utils/createMatrixClient"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig, { type ConfigOptions } from "../../SdkConfig"; import dis from "../../dispatcher/dispatcher"; diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index 1f6d3cfb9f..aace83f8f7 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -72,7 +72,7 @@ import AccessibleButton, { type ButtonEvent } from "../views/elements/Accessible import { TimelineRenderingType, MainSplitContentType } from "../../contexts/RoomContext"; import { E2EStatus, shieldStatusForRoom } from "../../utils/ShieldUtils"; import { Action } from "../../dispatcher/actions"; -import { type IMatrixClientCreds } from "../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../utils/createMatrixClient"; import { useMatrixClientContext } from "../../contexts/MatrixClientContext"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index ce354d5a4b..d4cf86cb65 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -20,7 +20,7 @@ import AuthPage from "../../views/auth/AuthPage"; import PlatformPeg from "../../../PlatformPeg"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; -import { type IMatrixClientCreds } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../../utils/createMatrixClient"; import PasswordLogin from "../../views/auth/PasswordLogin"; import InlineSpinner from "../../views/elements/InlineSpinner"; import Spinner from "../../views/elements/Spinner"; diff --git a/apps/web/src/components/structures/auth/Registration.tsx b/apps/web/src/components/structures/auth/Registration.tsx index 23be09eff3..ce4c55342f 100644 --- a/apps/web/src/components/structures/auth/Registration.tsx +++ b/apps/web/src/components/structures/auth/Registration.tsx @@ -29,7 +29,8 @@ import { _t } from "../../../languageHandler"; import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../../../Lifecycle"; -import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../../utils/createMatrixClient"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; import Login, { type OidcNativeFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; diff --git a/apps/web/src/components/structures/auth/SoftLogout.tsx b/apps/web/src/components/structures/auth/SoftLogout.tsx index 95d83e9d2b..1793b96927 100644 --- a/apps/web/src/components/structures/auth/SoftLogout.tsx +++ b/apps/web/src/components/structures/auth/SoftLogout.tsx @@ -14,7 +14,8 @@ import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import * as Lifecycle from "../../../Lifecycle"; import Modal from "../../../Modal"; -import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../../utils/createMatrixClient"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; diff --git a/apps/web/src/dispatcher/payloads/OverwriteLoginPayload.ts b/apps/web/src/dispatcher/payloads/OverwriteLoginPayload.ts index b617611d2d..8f157bfe59 100644 --- a/apps/web/src/dispatcher/payloads/OverwriteLoginPayload.ts +++ b/apps/web/src/dispatcher/payloads/OverwriteLoginPayload.ts @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { type ActionPayload } from "../payloads"; import { type Action } from "../actions"; -import { type IMatrixClientCreds } from "../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../utils/createMatrixClient"; export interface OverwriteLoginPayload extends ActionPayload { action: Action.OverwriteLogin; diff --git a/apps/web/src/utils/FormattingUtils.ts b/apps/web/src/utils/FormattingUtils.ts index 217ee4ac39..5d3405936f 100644 --- a/apps/web/src/utils/FormattingUtils.ts +++ b/apps/web/src/utils/FormattingUtils.ts @@ -16,32 +16,38 @@ import { jsxJoin } from "./ReactUtils"; export { formatBytes } from "@element-hq/web-shared-components"; -const locale = getCurrentLanguage(); +let cachedFormatter: Intl.NumberFormat | undefined; +let cachedCompactFormatter: Intl.NumberFormat | undefined; // It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do // it in every function call -const compactFormatter = new Intl.NumberFormat(locale, { - notation: "compact", -}); +const getCachedCompactFormatter = (): Intl.NumberFormat => { + if (!cachedCompactFormatter) + cachedCompactFormatter = new Intl.NumberFormat(getCurrentLanguage(), { notation: "compact" }); + return cachedCompactFormatter; +}; /** * formats and rounds numbers to fit into ~3 characters, suitable for badge counts * e.g: 999, 10K, 99K, 1M, 10M, 99M, 1B, 10B, ... */ export function formatCount(count: number): string { - return compactFormatter.format(count); + return getCachedCompactFormatter().format(count); } // It's quite costly to instanciate `Intl.NumberFormat`, hence why we do not do // it in every function call -const formatter = new Intl.NumberFormat(locale); +const getCachedFormatter = (): Intl.NumberFormat => { + if (!cachedFormatter) cachedFormatter = new Intl.NumberFormat(getCurrentLanguage()); + return cachedFormatter; +}; /** * Format a count showing the whole number but making it a bit more readable. * e.g: 1000 => 1,000 */ export function formatCountLong(count: number): string { - return formatter.format(count); + return getCachedFormatter().format(count); } export function getUserNameColorClass(userId: string): string { diff --git a/apps/web/src/utils/createMatrixClient.test.ts b/apps/web/src/utils/createMatrixClient.test.ts new file mode 100644 index 0000000000..a261255df7 --- /dev/null +++ b/apps/web/src/utils/createMatrixClient.test.ts @@ -0,0 +1,153 @@ +/* +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 { vi, describe, beforeEach, it, expect } from "vitest"; +import { type MatrixClient, RoomNameType } from "matrix-js-sdk/src/matrix"; + +import { createClientWithCreds } from "./createMatrixClient"; + +describe("createMatrixClient", () => { + let client: MatrixClient; + + beforeEach(() => { + vi.stubGlobal("localStorage", { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + + client = createClientWithCreds({ + homeserverUrl: "https://test.dummy", + userId: "@user:test.dummy", + accessToken: "access_token", + }); + }); + + describe("room name generator", () => { + it("should return empty room for an empty room", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.EmptyRoom, + }); + expect(roomName).toBe("Empty room"); + }); + + it("should include the old name for an empty room that used to have a name", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.EmptyRoom, + oldName: "Old Room", + }); + expect(roomName).toBe("Empty room (was Old Room)"); + }); + + it("should return null for an actual room name", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Actual, + name: "Some name", + }); + expect(roomName).toBeNull(); + }); + + describe("generated room names", () => { + it("should return empty room when there are no members", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + names: [], + count: 0, + }); + expect(roomName).toBe("Empty room"); + }); + + it("should return the single member name when there is only one other member", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + names: ["Alice"], + count: 2, + }); + expect(roomName).toBe("Alice"); + }); + + it("should join two member names with 'and'", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + names: ["Alice", "Bob"], + count: 2, + }); + expect(roomName).toBe("Alice and Bob"); + }); + + it("should name the first member and count the rest when there is one other member not named", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + names: ["Alice", "Bob"], + count: 3, + }); + expect(roomName).toBe("Alice and one other"); + }); + + it("should name the first member and count the rest when there are multiple members not named", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + names: ["Alice", "Bob", "Carol"], + count: 3, + }); + expect(roomName).toBe("Alice and 2 others"); + }); + + describe("when inviting", () => { + it("should return empty room when there are no invitees", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + subtype: "Inviting", + names: [], + count: 0, + }); + expect(roomName).toBe("Empty room"); + }); + + it("should return the single invitee name when there is only one invitee", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + subtype: "Inviting", + names: ["Alice"], + count: 1, + }); + expect(roomName).toBe("Alice"); + }); + + it("should say who is being invited when there are two invitees", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + subtype: "Inviting", + names: ["Alice", "Bob"], + count: 2, + }); + expect(roomName).toBe("Inviting Alice and Bob"); + }); + + it("should name the first invitee and count the rest when there are more than two invitees", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + subtype: "Inviting", + names: ["Alice", "Bob", "Carol"], + count: 3, + }); + expect(roomName).toBe("Inviting Alice and 2 others"); + }); + + it("should count uninvited members separately from named invitees", () => { + const roomName = client.roomNameGenerator?.("", { + type: RoomNameType.Generated, + subtype: "Inviting", + names: ["Alice", "Bob"], + count: 4, + }); + expect(roomName).toBe("Inviting Alice and 3 others"); + }); + }); + }); + }); +}); diff --git a/apps/web/src/utils/createMatrixClient.ts b/apps/web/src/utils/createMatrixClient.ts index 3cbc7a62f3..67483568d7 100644 --- a/apps/web/src/utils/createMatrixClient.ts +++ b/apps/web/src/utils/createMatrixClient.ts @@ -1,4 +1,5 @@ /* +Copyright 2026 Element Creations Ltd. Copyright 2024 New Vector Ltd. Copyright 2017-2021 The Matrix.org Foundation C.I.C. @@ -14,9 +15,20 @@ import { IndexedDBCryptoStore, IndexedDBStore, LocalStorageCryptoStore, + RoomNameType, + type RoomNameState, + type TokenRefreshFunction, + EventTimelineSet, + EventTimeline, } from "matrix-js-sdk/src/matrix"; +import { VerificationMethod } from "matrix-js-sdk/src/types"; import indexeddbWorkerFactory from "../workers/indexeddbWorkerFactory"; +import SettingsStore from "../settings/SettingsStore"; +import { crossSigningCallbacks } from "../SecurityManager"; +import IdentityAuthClient from "../IdentityAuthClient"; +import { _t } from "../languageHandler"; +import { formatList } from "./FormattingUtils"; const localStorage = window.localStorage; @@ -27,6 +39,131 @@ try { indexedDB = window.indexedDB; } catch {} +/** + * Credentials used to create a MatrixClient with `createClientWithCreds`. + */ +export interface IMatrixClientCreds { + homeserverUrl: string; + identityServerUrl?: string; + userId: string; + deviceId?: string; + accessToken: string; + refreshToken?: string; + guest?: boolean; + pickleKey?: string; + freshLogin?: boolean; +} + +function namesToRoomName(names: string[], count: number): string | undefined { + const countWithoutMe = count - 1; + if (!names.length) { + return _t("empty_room"); + } + if (names.length === 1 && countWithoutMe <= 1) { + return names[0]; + } +} + +function memberNamesToRoomName(names: string[], count: number): string { + const name = namesToRoomName(names, count); + if (name) return name; + + if (names.length === 2 && count === 2) { + return formatList(names); + } + return formatList(names, 1); +} + +function inviteeNamesToRoomName(names: string[], count: number): string { + const name = namesToRoomName(names, count); + if (name) return name; + + if (names.length === 2 && count === 2) { + return _t("inviting_user1_and_user2", { + user1: names[0], + user2: names[1], + }); + } + return _t("inviting_user_and_n_others", { + user: names[0], + count: count - 1, + }); +} + +function roomNameGenerator(_: string, state: RoomNameState): string | null { + switch (state.type) { + case RoomNameType.Generated: + switch (state.subtype) { + case "Inviting": + return inviteeNamesToRoomName(state.names, state.count); + default: + return memberNamesToRoomName(state.names, state.count); + } + case RoomNameType.EmptyRoom: + if (state.oldName) { + return _t("empty_room_was_name", { + oldName: state.oldName, + }); + } else { + return _t("empty_room"); + } + default: + return null; + } +} + +/** + * Create a new matrix client from credentials with all the options needed. + * + * @param creds The credentials to create the client with + * @param tokenRefreshFunction Optional function to call when the access token is expired + * + * @returns {MatrixClient} the newly-created MatrixClient + */ +export function createClientWithCreds( + creds: IMatrixClientCreds, + tokenRefreshFunction?: TokenRefreshFunction, +): MatrixClient { + const opts: ICreateClientOpts = { + baseUrl: creds.homeserverUrl, + idBaseUrl: creds.identityServerUrl, + accessToken: creds.accessToken, + refreshToken: creds.refreshToken, + tokenRefreshFunction, + userId: creds.userId, + deviceId: creds.deviceId, + pickleKey: creds.pickleKey, + timelineSupport: true, + forceTURN: !SettingsStore.getValue("webRtcAllowPeerToPeer"), + fallbackICEServerAllowed: !!SettingsStore.getValue("fallbackICEServerAllowed"), + // Gather up to 20 ICE candidates when a call arrives: this should be more than we'd + // ever normally need, so effectively this should make all the gathering happen when + // the call arrives. + iceCandidatePoolSize: 20, + verificationMethods: [VerificationMethod.Sas, VerificationMethod.ShowQrCode, VerificationMethod.Reciprocate], + identityServer: new IdentityAuthClient(), + // These are always installed regardless of the labs flag so that cross-signing features + // can toggle on without reloading and also be accessed immediately after login. + cryptoCallbacks: { ...crossSigningCallbacks }, + enableEncryptedStateEvents: SettingsStore.getValue("feature_msc4362_encrypted_state_events"), + unstableMSC1763Retention: SettingsStore.getValue("feature_retention"), + roomNameGenerator, + }; + + const newCli = createMatrixClient(opts); + newCli.setGuest(Boolean(creds.guest)); + + const notifTimelineSet = new EventTimelineSet(undefined, { + timelineSupport: true, + pendingEvents: false, + }); + // XXX: what is our initial pagination token?! it somehow needs to be synchronised with /sync. + notifTimelineSet.getLiveTimeline().setPaginationToken("", EventTimeline.BACKWARDS); + newCli.setNotifTimelineSet(notifTimelineSet); + + return newCli; +} + /** * Create a new matrix client, with the persistent stores set up appropriately * (using localstorage/indexeddb, etc) @@ -36,7 +173,7 @@ try { * * @returns {MatrixClient} the newly-created MatrixClient */ -export default function createMatrixClient(opts: ICreateClientOpts): MatrixClient { +export function createMatrixClient(opts: ICreateClientOpts): MatrixClient { const storeOpts: Partial = { useAuthorizationHeader: true, }; diff --git a/apps/web/test/test-utils/test-utils.ts b/apps/web/test/test-utils/test-utils.ts index 9223c2413e..e0afa17ee1 100644 --- a/apps/web/test/test-utils/test-utils.ts +++ b/apps/web/test/test-utils/test-utils.ts @@ -70,7 +70,7 @@ export function stubClient(): MatrixClient { vi.spyOn(peg, "get"); vi.spyOn(peg, "safeGet"); vi.spyOn(peg, "unset"); - vi.spyOn(peg, "replaceUsingCreds"); + vi.spyOn(peg, "set"); // MatrixClientPeg.safeGet() is called a /lot/, so implement it with our own // fast stub function rather than a sinon stub peg.get = () => client; diff --git a/apps/web/test/unit-tests/Lifecycle-test.ts b/apps/web/test/unit-tests/Lifecycle-test.ts index c336838497..3b15100410 100644 --- a/apps/web/test/unit-tests/Lifecycle-test.ts +++ b/apps/web/test/unit-tests/Lifecycle-test.ts @@ -29,6 +29,7 @@ import { persistAccessTokenInStorage, persistRefreshTokenInStorage } from "../.. import { encryptPickleKey } from "../../src/utils/tokens/pickling"; import * as StorageManager from "../../src/utils/StorageManager.ts"; import type BasePlatform from "../../src/BasePlatform.ts"; +import * as createMatrixClientModule from "../../src/utils/createMatrixClient"; const { logout, restoreSessionFromStorage, setLoggedIn } = Lifecycle; @@ -71,9 +72,11 @@ describe("Lifecycle", () => { logout: jest.fn().mockResolvedValue(undefined), getAccessToken: jest.fn(), getRefreshToken: jest.fn(), + setGuest: jest.fn(), + setNotifTimelineSet: jest.fn(), }); // stub this - jest.spyOn(MatrixClientPeg, "replaceUsingCreds").mockImplementation(() => {}); + jest.spyOn(MatrixClientPeg, "set").mockImplementation(() => {}); jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined); // reset any mocking @@ -187,6 +190,7 @@ describe("Lifecycle", () => { jest.spyOn(logger, "log").mockClear(); jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient); + jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient); // stub this out jest.spyOn(Modal, "createDialog").mockReturnValue( @@ -235,7 +239,7 @@ describe("Lifecycle", () => { it("should restore guest accounts when ignoreGuest is false", async () => { expect(await restoreSessionFromStorage({ ignoreGuest: false })).toEqual(true); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( expect.objectContaining({ userId, guest: true, @@ -279,7 +283,7 @@ describe("Lifecycle", () => { it("should create and start new matrix client with credentials", async () => { expect(await restoreSessionFromStorage()).toEqual(true); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, accessToken, @@ -328,7 +332,7 @@ describe("Lifecycle", () => { it("should create new matrix client with credentials", async () => { expect(await restoreSessionFromStorage()).toEqual(true); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, accessToken, @@ -410,7 +414,7 @@ describe("Lifecycle", () => { expect(await restoreSessionFromStorage()).toEqual(true); // Ensure that the expected calls were made - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, // decrypted accessToken @@ -448,7 +452,7 @@ describe("Lifecycle", () => { it("should create new matrix client with credentials", async () => { expect(await restoreSessionFromStorage()).toEqual(true); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, accessToken, @@ -501,7 +505,7 @@ describe("Lifecycle", () => { expect(await restoreSessionFromStorage()).toEqual(true); // Ensure that the expected calls were made - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, // decrypted accessToken @@ -614,6 +618,7 @@ describe("Lifecycle", () => { describe("without a pickle key", () => { beforeEach(() => { jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null); + jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient); }); it("should persist credentials", async () => { @@ -667,7 +672,7 @@ describe("Lifecycle", () => { it("should create new matrix client with credentials", async () => { expect(await setLoggedIn(credentials)).toEqual(mockClient); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, accessToken, @@ -763,9 +768,10 @@ describe("Lifecycle", () => { }); it("should create new matrix client with credentials", async () => { + jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient); expect(await setLoggedIn(credentials)).toEqual(mockClient); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( { userId, accessToken, @@ -856,7 +862,7 @@ describe("Lifecycle", () => { }), ).toEqual(mockClient); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( expect.objectContaining({ accessToken, refreshToken, @@ -877,7 +883,7 @@ describe("Lifecycle", () => { }), ).toEqual(mockClient); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( expect.objectContaining({ accessToken, refreshToken, @@ -939,6 +945,7 @@ describe("Lifecycle", () => { it("should replace the current login with a new one", async () => { const stopSpy = jest.spyOn(mockClient, "stopClient").mockReturnValue(undefined); + jest.spyOn(createMatrixClientModule, "createClientWithCreds").mockReturnValue(mockClient); const dis = window.mxDispatcher; const firstLoginEvent: Promise = new Promise((resolve) => { @@ -958,7 +965,7 @@ describe("Lifecycle", () => { // So spy on it and make sure it's not called. jest.spyOn(MatrixClientPeg, "unset").mockReturnValue(undefined); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( expect.objectContaining({ userId, }), @@ -992,7 +999,7 @@ describe("Lifecycle", () => { // the client should have been stopped expect(stopSpy).toHaveBeenCalledTimes(2); - expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith( + expect(createMatrixClientModule.createClientWithCreds).toHaveBeenCalledWith( expect.objectContaining({ userId: otherCredentials.userId, }), diff --git a/apps/web/test/unit-tests/MatrixClientPeg-test.ts b/apps/web/test/unit-tests/MatrixClientPeg-test.ts index aaee99fd61..ff8c904c43 100644 --- a/apps/web/test/unit-tests/MatrixClientPeg-test.ts +++ b/apps/web/test/unit-tests/MatrixClientPeg-test.ts @@ -7,9 +7,10 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import fetchMock from "@fetch-mock/jest"; -import { advanceDateAndTime, stubClient } from "../test-utils"; +import { advanceDateAndTime, createTestClient, stubClient } from "../test-utils"; import { type IMatrixClientPeg, MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; jest.useFakeTimers(); @@ -70,12 +71,11 @@ describe("MatrixClientPeg", () => { // instantiate a MatrixClientPegClass instance, with a new MatrixClient testPeg = new PegClass(); fetchMock.get("http://example.com/_matrix/client/versions", {}); - testPeg.replaceUsingCreds({ - accessToken: "SEKRET", - homeserverUrl: "http://example.com", - userId: "@user:example.com", - deviceId: "TEST_DEVICE_ID", - }); + + const mockClient = createTestClient(); + mockClient.initRustCrypto = jest.fn(); + mockClient.startClient = jest.fn(); + testPeg.set(mockClient as unknown as MatrixClient); }); it("should initialise the rust crypto library by default", async () => { diff --git a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx index 683c932845..9b848acca2 100644 --- a/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx +++ b/apps/web/test/unit-tests/components/structures/MatrixChat-test.tsx @@ -6,7 +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. */ -import "fake-indexeddb/auto"; import React, { type ComponentProps, createRef, type RefObject } from "react"; import { fireEvent, render, type RenderResult, screen, waitFor, within, act } from "jest-matrix-react"; import { type Mocked, mocked } from "jest-mock-vitest-adapter"; @@ -474,7 +473,7 @@ describe("", () => { const tokenResponse: BearerTokenResponse = { access_token: accessToken, - refresh_token: "def456", + refresh_token: undefined, id_token: "ghi789", scope: "test", token_type: "Bearer", @@ -644,12 +643,6 @@ describe("", () => { }); describe("when login succeeds", () => { - beforeEach(() => { - jest.spyOn(StorageAccess, "idbLoad").mockImplementation( - async (_table: string, key: string | string[]) => (key === "mx_access_token" ? accessToken : null), - ); - }); - afterEach(() => { SettingsStore.reset(); }); @@ -1366,7 +1359,7 @@ describe("", () => { // but as the exception was swallowed, the test was passing (see in `initClientCrypto`). // There are several uses of the peg in the app, so during all these tests you might end-up // with a real client instead of the mocked one. Not sure how reliable all these tests are. - jest.spyOn(MatrixClientPeg, "replaceUsingCreds"); + jest.spyOn(MatrixClientPeg, "set"); jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); const result = getComponent();