summaryrefslogtreecommitdiff
path: root/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
diff options
context:
space:
mode:
authorRaindropsSys <contact@minteck.org>2023-04-24 14:03:36 +0200
committerRaindropsSys <contact@minteck.org>2023-04-24 14:03:36 +0200
commit633c92eae865e957121e08de634aeee11a8b3992 (patch)
tree09d881bee1dae0b6eee49db1dfaf0f500240606c /includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
parentc4657e4509733699c0f26a3c900bab47e915d5a0 (diff)
downloadpluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.gz
pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.bz2
pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.zip
Updated 18 files, added 1692 files and deleted includes/system/compare.inc (automated)
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts')
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts9680
1 files changed, 9680 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
new file mode 100644
index 0000000..0e47ff6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
@@ -0,0 +1,9680 @@
+/*
+Copyright 2015-2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link MatrixClient} for the public class.
+ */
+
+import { Optional } from "matrix-events-sdk";
+
+import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto";
+import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
+import {
+ EventStatus,
+ IContent,
+ IDecryptOptions,
+ IEvent,
+ MatrixEvent,
+ MatrixEventEvent,
+ MatrixEventHandlerMap,
+} from "./models/event";
+import { StubStore } from "./store/stub";
+import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
+import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
+import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
+import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
+import * as utils from "./utils";
+import { replaceParam, QueryDict, sleep, noUnsafeEventProps } from "./utils";
+import { Direction, EventTimeline } from "./models/event-timeline";
+import { IActionsObject, PushProcessor } from "./pushprocessor";
+import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
+import * as olmlib from "./crypto/olmlib";
+import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
+import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
+import { IOlmDevice } from "./crypto/algorithms/megolm";
+import { TypedReEmitter } from "./ReEmitter";
+import { IRoomEncryption, RoomList } from "./crypto/RoomList";
+import { logger } from "./logger";
+import { SERVICE_TYPES } from "./service-types";
+import {
+ HttpApiEvent,
+ HttpApiEventHandlerMap,
+ Upload,
+ UploadOpts,
+ MatrixError,
+ MatrixHttpApi,
+ Method,
+ retryNetworkOperation,
+ ClientPrefix,
+ MediaPrefix,
+ IdentityPrefix,
+ IHttpOpts,
+ FileType,
+ UploadResponse,
+ HTTPError,
+ IRequestOpts,
+} from "./http-api";
+import {
+ Crypto,
+ CryptoEvent,
+ CryptoEventHandlerMap,
+ fixBackupKey,
+ ICryptoCallbacks,
+ IBootstrapCrossSigningOpts,
+ ICheckOwnCrossSigningTrustOpts,
+ isCryptoAvailable,
+ VerificationMethod,
+ IRoomKeyRequestBody,
+} from "./crypto";
+import { DeviceInfo } from "./crypto/deviceinfo";
+import { decodeRecoveryKey } from "./crypto/recoverykey";
+import { keyFromAuthData } from "./crypto/key_passphrase";
+import { User, UserEvent, UserEventHandlerMap } from "./models/user";
+import { getHttpUriForMxc } from "./content-repo";
+import { SearchResult } from "./models/search-result";
+import { DEHYDRATION_ALGORITHM, IDehydratedDevice, IDehydratedDeviceKeyInfo } from "./crypto/dehydration";
+import {
+ IKeyBackupInfo,
+ IKeyBackupPrepareOpts,
+ IKeyBackupRestoreOpts,
+ IKeyBackupRestoreResult,
+ IKeyBackupRoomSessions,
+ IKeyBackupSession,
+} from "./crypto/keybackup";
+import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
+import { MatrixScheduler } from "./scheduler";
+import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
+import { IAuthData, IAuthDict } from "./interactive-auth";
+import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
+import {
+ CrossSigningKey,
+ IAddSecretStorageKeyOpts,
+ ICreateSecretStorageOpts,
+ IEncryptedEventInfo,
+ IImportRoomKeysOpts,
+ IRecoveryKey,
+} from "./crypto/api";
+import { EventTimelineSet } from "./models/event-timeline-set";
+import { VerificationRequest } from "./crypto/verification/request/VerificationRequest";
+import { VerificationBase as Verification } from "./crypto/verification/Base";
+import * as ContentHelpers from "./content-helpers";
+import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
+import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
+import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
+import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
+import {
+ IAddThreePidOnlyBody,
+ IBindThreePidBody,
+ IContextResponse,
+ ICreateRoomOpts,
+ IEventSearchOpts,
+ IGuestAccessOpts,
+ IJoinRoomOpts,
+ IPaginateOpts,
+ IPresenceOpts,
+ IRedactOpts,
+ IRelationsRequestOpts,
+ IRelationsResponse,
+ IRoomDirectoryOptions,
+ ISearchOpts,
+ ISendEventResponse,
+ INotificationsResponse,
+ IFilterResponse,
+ ITagsResponse,
+ IStatusResponse,
+} from "./@types/requests";
+import {
+ EventType,
+ LOCAL_NOTIFICATION_SETTINGS_PREFIX,
+ MsgType,
+ PUSHER_ENABLED,
+ RelationType,
+ RoomCreateTypeField,
+ RoomType,
+ UNSTABLE_MSC3088_ENABLED,
+ UNSTABLE_MSC3088_PURPOSE,
+ UNSTABLE_MSC3089_TREE_SUBTYPE,
+ MSC3912_RELATION_BASED_REDACTIONS_PROP,
+} from "./@types/event";
+import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
+import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
+import { randomString } from "./randomstring";
+import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
+import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace";
+import { ISignatures } from "./@types/signed";
+import { IStore } from "./store";
+import { ISecretRequest } from "./crypto/SecretStorage";
+import {
+ IEventWithRoomId,
+ ISearchRequestBody,
+ ISearchResponse,
+ ISearchResults,
+ IStateEventWithRoomId,
+ SearchOrderBy,
+} from "./@types/search";
+import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse";
+import { IHierarchyRoom } from "./@types/spaces";
+import {
+ IPusher,
+ IPusherRequest,
+ IPushRule,
+ IPushRules,
+ PushRuleAction,
+ PushRuleActionName,
+ PushRuleKind,
+ RuleId,
+} from "./@types/PushRules";
+import { IThreepid } from "./@types/threepids";
+import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
+import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
+import { MediaHandler } from "./webrtc/mediaHandler";
+import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
+import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
+import { TypedEventEmitter } from "./models/typed-event-emitter";
+import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
+import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
+import { SlidingSyncSdk } from "./sliding-sync-sdk";
+import {
+ FeatureSupport,
+ Thread,
+ THREAD_RELATION_TYPE,
+ determineFeatureSupport,
+ ThreadFilterType,
+ threadFilterTypeToFilter,
+} from "./models/thread";
+import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
+import { UnstableValue } from "./NamespacedValue";
+import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
+import { ToDeviceBatch } from "./models/ToDeviceMessage";
+import { IgnoredInvites } from "./models/invites-ignorer";
+import { UIARequest, UIAResponse } from "./@types/uia";
+import { LocalNotificationSettings } from "./@types/local_notifications";
+import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
+import { CryptoBackend } from "./common-crypto/CryptoBackend";
+import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
+import { CryptoApi } from "./crypto-api";
+import { DeviceInfoMap } from "./crypto/DeviceList";
+import { SecretStorageKeyDescription } from "./secret-storage";
+
+export type Store = IStore;
+
+export type ResetTimelineCallback = (roomId: string) => boolean;
+
+const SCROLLBACK_DELAY_MS = 3000;
+export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
+const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
+const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
+
+export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
+ "last_seen_user_agent",
+ "org.matrix.msc3852.last_seen_user_agent",
+);
+
+interface IExportedDevice {
+ olmDevice: IExportedOlmDevice;
+ userId: string;
+ deviceId: string;
+}
+
+export interface IKeysUploadResponse {
+ one_time_key_counts: {
+ // eslint-disable-line camelcase
+ [algorithm: string]: number;
+ };
+}
+
+export interface ICreateClientOpts {
+ baseUrl: string;
+
+ idBaseUrl?: string;
+
+ /**
+ * The data store used for sync data from the homeserver. If not specified,
+ * this client will not store any HTTP responses. The `createClient` helper
+ * will create a default store if needed.
+ */
+ store?: Store;
+
+ /**
+ * A store to be used for end-to-end crypto session data. If not specified,
+ * end-to-end crypto will be disabled. The `createClient` helper will create
+ * a default store if needed. Calls the factory supplied to
+ * {@link setCryptoStoreFactory} if unspecified; or if no factory has been
+ * specified, uses a default implementation (indexeddb in the browser,
+ * in-memory otherwise).
+ */
+ cryptoStore?: CryptoStore;
+
+ /**
+ * The scheduler to use. If not
+ * specified, this client will not retry requests on failure. This client
+ * will supply its own processing function to
+ * {@link MatrixScheduler#setProcessFunction}.
+ */
+ scheduler?: MatrixScheduler;
+
+ /**
+ * The function to invoke for HTTP requests.
+ * Most supported environments have a global `fetch` registered to which this will fall back.
+ */
+ fetchFn?: typeof global.fetch;
+
+ userId?: string;
+
+ /**
+ * A unique identifier for this device; used for tracking things like crypto
+ * keys and access tokens. If not specified, end-to-end encryption will be
+ * disabled.
+ */
+ deviceId?: string;
+
+ accessToken?: string;
+
+ /**
+ * Identity server provider to retrieve the user's access token when accessing
+ * the identity server. See also https://github.com/vector-im/element-web/issues/10615
+ * which seeks to replace the previous approach of manual access tokens params
+ * with this callback throughout the SDK.
+ */
+ identityServer?: IIdentityServerProvider;
+
+ /**
+ * The default maximum amount of
+ * time to wait before timing out HTTP requests. If not specified, there is no timeout.
+ */
+ localTimeoutMs?: number;
+
+ /**
+ * Set to true to use
+ * Authorization header instead of query param to send the access token to the server.
+ *
+ * Default false.
+ */
+ useAuthorizationHeader?: boolean;
+
+ /**
+ * Set to true to enable
+ * improved timeline support, see {@link MatrixClient#getEventTimeline}.
+ * It is disabled by default for compatibility with older clients - in particular to
+ * maintain support for back-paginating the live timeline after a '/sync'
+ * result with a gap.
+ */
+ timelineSupport?: boolean;
+
+ /**
+ * Extra query parameters to append
+ * to all requests with this client. Useful for application services which require
+ * `?user_id=`.
+ */
+ queryParams?: Record<string, string>;
+
+ /**
+ * Device data exported with
+ * "exportDevice" method that must be imported to recreate this device.
+ * Should only be useful for devices with end-to-end crypto enabled.
+ * If provided, deviceId and userId should **NOT** be provided at the top
+ * level (they are present in the exported data).
+ */
+ deviceToImport?: IExportedDevice;
+
+ /**
+ * Key used to pickle olm objects or other sensitive data.
+ */
+ pickleKey?: string;
+
+ verificationMethods?: Array<VerificationMethod>;
+
+ /**
+ * Whether relaying calls through a TURN server should be forced. Default false.
+ */
+ forceTURN?: boolean;
+
+ /**
+ * Up to this many ICE candidates will be gathered when an incoming call arrives.
+ * Gathering does not send data to the caller, but will communicate with the configured TURN
+ * server. Default 0.
+ */
+ iceCandidatePoolSize?: number;
+
+ /**
+ * True to advertise support for call transfers to other parties on Matrix calls. Default false.
+ */
+ supportsCallTransfer?: boolean;
+
+ /**
+ * Whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to false.
+ */
+ fallbackICEServerAllowed?: boolean;
+
+ /**
+ * If true, to-device signalling for group calls will be encrypted
+ * with Olm. Default: true.
+ */
+ useE2eForGroupCall?: boolean;
+
+ cryptoCallbacks?: ICryptoCallbacks;
+
+ /**
+ * Method to generate room names for empty rooms and rooms names based on membership.
+ * Defaults to a built-in English handler with basic pluralisation.
+ */
+ roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null;
+
+ /**
+ * If true, participant can join group call without video and audio this has to be allowed. By default, a local
+ * media stream is needed to establish a group call.
+ * Default: false.
+ */
+ isVoipWithNoMediaAllowed?: boolean;
+}
+
+export interface IMatrixClientCreateOpts extends ICreateClientOpts {
+ /**
+ * Whether to allow sending messages to encrypted rooms when encryption
+ * is not available internally within this SDK. This is useful if you are using an external
+ * E2E proxy, for example. Defaults to false.
+ */
+ usingExternalCrypto?: boolean;
+}
+
+export enum PendingEventOrdering {
+ Chronological = "chronological",
+ Detached = "detached",
+}
+
+export interface IStartClientOpts {
+ /**
+ * The event `limit=` to apply to initial sync. Default: 8.
+ */
+ initialSyncLimit?: number;
+
+ /**
+ * True to put `archived=true</code> on the <code>/initialSync` request. Default: false.
+ */
+ includeArchivedRooms?: boolean;
+
+ /**
+ * True to do /profile requests on every invite event if the displayname/avatar_url is not known for this user ID. Default: false.
+ */
+ resolveInvitesToProfiles?: boolean;
+
+ /**
+ * Controls where pending messages appear in a room's timeline. If "<b>chronological</b>", messages will
+ * appear in the timeline when the call to `sendEvent` was made. If "<b>detached</b>",
+ * pending messages will appear in a separate list, accessbile via {@link Room#getPendingEvents}.
+ * Default: "chronological".
+ */
+ pendingEventOrdering?: PendingEventOrdering;
+
+ /**
+ * The number of milliseconds to wait on /sync. Default: 30000 (30 seconds).
+ */
+ pollTimeout?: number;
+
+ /**
+ * The filter to apply to /sync calls.
+ */
+ filter?: Filter;
+
+ /**
+ * True to perform syncing without automatically updating presence.
+ */
+ disablePresence?: boolean;
+
+ /**
+ * True to not load all membership events during initial sync but fetch them when needed by calling
+ * `loadOutOfBandMembers` This will override the filter option at this moment.
+ */
+ lazyLoadMembers?: boolean;
+
+ /**
+ * The number of seconds between polls to /.well-known/matrix/client, undefined to disable.
+ * This should be in the order of hours. Default: undefined.
+ */
+ clientWellKnownPollPeriod?: number;
+
+ /**
+ * @deprecated use `threadSupport` instead
+ */
+ experimentalThreadSupport?: boolean;
+
+ /**
+ * Will organises events in threaded conversations when
+ * a thread relation is encountered
+ */
+ threadSupport?: boolean;
+
+ /**
+ * @experimental
+ */
+ slidingSync?: SlidingSync;
+
+ /**
+ * @experimental
+ */
+ intentionalMentions?: boolean;
+}
+
+export interface IStoredClientOpts extends IStartClientOpts {}
+
+export enum RoomVersionStability {
+ Stable = "stable",
+ Unstable = "unstable",
+}
+
+export interface IRoomVersionsCapability {
+ default: string;
+ available: Record<string, RoomVersionStability>;
+}
+
+export interface ICapability {
+ enabled: boolean;
+}
+
+export interface IChangePasswordCapability extends ICapability {}
+
+export interface IThreadsCapability extends ICapability {}
+
+interface ICapabilities {
+ [key: string]: any;
+ "m.change_password"?: IChangePasswordCapability;
+ "m.room_versions"?: IRoomVersionsCapability;
+ "io.element.thread"?: IThreadsCapability;
+}
+
+/* eslint-disable camelcase */
+export interface ICrossSigningKey {
+ keys: { [algorithm: string]: string };
+ signatures?: ISignatures;
+ usage: string[];
+ user_id: string;
+}
+
+enum CrossSigningKeyType {
+ MasterKey = "master_key",
+ SelfSigningKey = "self_signing_key",
+ UserSigningKey = "user_signing_key",
+}
+
+export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
+
+export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>;
+
+export interface ISignedKey {
+ keys: Record<string, string>;
+ signatures: ISignatures;
+ user_id: string;
+ algorithms: string[];
+ device_id: string;
+}
+
+export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>;
+export interface IUploadKeySignaturesResponse {
+ failures: Record<
+ string,
+ Record<
+ string,
+ {
+ errcode: string;
+ error: string;
+ }
+ >
+ >;
+}
+
+export interface IPreviewUrlResponse {
+ [key: string]: undefined | string | number;
+ "og:title": string;
+ "og:type": string;
+ "og:url": string;
+ "og:image"?: string;
+ "og:image:type"?: string;
+ "og:image:height"?: number;
+ "og:image:width"?: number;
+ "og:description"?: string;
+ "matrix:image:size"?: number;
+}
+
+export interface ITurnServerResponse {
+ uris: string[];
+ username: string;
+ password: string;
+ ttl: number;
+}
+
+export interface ITurnServer {
+ urls: string[];
+ username: string;
+ credential: string;
+}
+
+export interface IServerVersions {
+ versions: string[];
+ unstable_features: Record<string, boolean>;
+}
+
+export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
+
+export interface IClientWellKnown {
+ [key: string]: any;
+ "m.homeserver"?: IWellKnownConfig;
+ "m.identity_server"?: IWellKnownConfig;
+ [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
+}
+
+export interface IWellKnownConfig {
+ raw?: IClientWellKnown;
+ action?: AutoDiscoveryAction;
+ reason?: string;
+ error?: Error | string;
+ // eslint-disable-next-line
+ base_url?: string | null;
+ // XXX: this is undocumented
+ server_name?: string;
+}
+
+export interface IDelegatedAuthConfig {
+ // MSC2965
+ /** The OIDC Provider/issuer the client should use */
+ issuer: string;
+ /** The optional URL of the web UI where the user can manage their account */
+ account?: string;
+}
+
+interface IKeyBackupPath {
+ path: string;
+ queryData?: {
+ version: string;
+ };
+}
+
+interface IMediaConfig {
+ [key: string]: any; // extensible
+ "m.upload.size"?: number;
+}
+
+interface IThirdPartySigned {
+ sender: string;
+ mxid: string;
+ token: string;
+ signatures: ISignatures;
+}
+
+interface IJoinRequestBody {
+ third_party_signed?: IThirdPartySigned;
+}
+
+interface ITagMetadata {
+ [key: string]: any;
+ order: number;
+}
+
+interface IMessagesResponse {
+ start?: string;
+ end?: string;
+ chunk: IRoomEvent[];
+ state?: IStateEvent[];
+}
+
+interface IThreadedMessagesResponse {
+ prev_batch: string;
+ next_batch: string;
+ chunk: IRoomEvent[];
+ state: IStateEvent[];
+}
+
+export interface IRequestTokenResponse {
+ sid: string;
+ submit_url?: string;
+}
+
+export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse {
+ msisdn: string;
+ success: boolean;
+ intl_fmt: string;
+}
+
+export interface IUploadKeysRequest {
+ "device_keys"?: Required<IDeviceKeys>;
+ "one_time_keys"?: Record<string, IOneTimeKey>;
+ "org.matrix.msc2732.fallback_keys"?: Record<string, IOneTimeKey>;
+}
+
+export interface IQueryKeysRequest {
+ device_keys: { [userId: string]: string[] };
+ timeout?: number;
+ token?: string;
+}
+
+export interface IClaimKeysRequest {
+ one_time_keys: { [userId: string]: { [deviceId: string]: string } };
+ timeout?: number;
+}
+
+export interface IOpenIDToken {
+ access_token: string;
+ token_type: "Bearer" | string;
+ matrix_server_name: string;
+ expires_in: number;
+}
+
+interface IRoomInitialSyncResponse {
+ room_id: string;
+ membership: "invite" | "join" | "leave" | "ban";
+ messages?: {
+ start?: string;
+ end?: string;
+ chunk: IEventWithRoomId[];
+ };
+ state?: IStateEventWithRoomId[];
+ visibility: Visibility;
+ account_data?: IMinimalEvent[];
+ presence: Partial<IEvent>; // legacy and undocumented, api is deprecated so this won't get attention
+}
+
+interface IJoinedRoomsResponse {
+ joined_rooms: string[];
+}
+
+interface IJoinedMembersResponse {
+ joined: {
+ [userId: string]: {
+ display_name: string;
+ avatar_url: string;
+ };
+ };
+}
+
+export interface IRegisterRequestParams {
+ auth?: IAuthData;
+ username?: string;
+ password?: string;
+ refresh_token?: boolean;
+ guest_access_token?: string;
+ x_show_msisdn?: boolean;
+ bind_msisdn?: boolean;
+ bind_email?: boolean;
+ inhibit_login?: boolean;
+ initial_device_display_name?: string;
+}
+
+export interface IPublicRoomsChunkRoom {
+ room_id: string;
+ name?: string;
+ avatar_url?: string;
+ topic?: string;
+ canonical_alias?: string;
+ aliases?: string[];
+ world_readable: boolean;
+ guest_can_join: boolean;
+ num_joined_members: number;
+ room_type?: RoomType | string; // Added by MSC3827
+}
+
+interface IPublicRoomsResponse {
+ chunk: IPublicRoomsChunkRoom[];
+ next_batch?: string;
+ prev_batch?: string;
+ total_room_count_estimate?: number;
+}
+
+interface IUserDirectoryResponse {
+ results: {
+ user_id: string;
+ display_name?: string;
+ avatar_url?: string;
+ }[];
+ limited: boolean;
+}
+
+export interface IMyDevice {
+ "device_id": string;
+ "display_name"?: string;
+ "last_seen_ip"?: string;
+ "last_seen_ts"?: number;
+ // UNSTABLE_MSC3852_LAST_SEEN_UA
+ "last_seen_user_agent"?: string;
+ "org.matrix.msc3852.last_seen_user_agent"?: string;
+}
+
+export interface Keys {
+ keys: { [keyId: string]: string };
+ usage: string[];
+ user_id: string;
+}
+
+export interface SigningKeys extends Keys {
+ signatures: ISignatures;
+}
+
+export interface DeviceKeys {
+ [deviceId: string]: IDeviceKeys & {
+ unsigned?: {
+ device_display_name: string;
+ };
+ };
+}
+
+export interface IDownloadKeyResult {
+ failures: { [serverName: string]: object };
+ device_keys: { [userId: string]: DeviceKeys };
+ // the following three fields were added in 1.1
+ master_keys?: { [userId: string]: Keys };
+ self_signing_keys?: { [userId: string]: SigningKeys };
+ user_signing_keys?: { [userId: string]: SigningKeys };
+}
+
+export interface IClaimOTKsResult {
+ failures: { [serverName: string]: object };
+ one_time_keys: {
+ [userId: string]: {
+ [deviceId: string]: {
+ [keyId: string]: {
+ key: string;
+ signatures: ISignatures;
+ };
+ };
+ };
+ };
+}
+
+export interface IFieldType {
+ regexp: string;
+ placeholder: string;
+}
+
+export interface IInstance {
+ desc: string;
+ icon?: string;
+ fields: object;
+ network_id: string;
+ // XXX: this is undocumented but we rely on it: https://github.com/matrix-org/matrix-doc/issues/3203
+ instance_id: string;
+}
+
+export interface IProtocol {
+ user_fields: string[];
+ location_fields: string[];
+ icon: string;
+ field_types: Record<string, IFieldType>;
+ instances: IInstance[];
+}
+
+interface IThirdPartyLocation {
+ alias: string;
+ protocol: string;
+ fields: object;
+}
+
+interface IThirdPartyUser {
+ userid: string;
+ protocol: string;
+ fields: object;
+}
+
+interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
+ room_type?: RoomType;
+ membership?: string;
+ is_encrypted: boolean;
+}
+
+interface IRoomKeysResponse {
+ sessions: IKeyBackupRoomSessions;
+}
+
+interface IRoomsKeysResponse {
+ rooms: Record<string, IRoomKeysResponse>;
+}
+
+interface IRoomHierarchy {
+ rooms: IHierarchyRoom[];
+ next_batch?: string;
+}
+
+export interface TimestampToEventResponse {
+ event_id: string;
+ origin_server_ts: string;
+}
+
+interface IWhoamiResponse {
+ user_id: string;
+ device_id?: string;
+}
+/* eslint-enable camelcase */
+
+// We're using this constant for methods overloading and inspect whether a variable
+// contains an eventId or not. This was required to ensure backwards compatibility
+// of methods for threads
+// Probably not the most graceful solution but does a good enough job for now
+const EVENT_ID_PREFIX = "$";
+
+export enum ClientEvent {
+ Sync = "sync",
+ Event = "event",
+ ToDeviceEvent = "toDeviceEvent",
+ AccountData = "accountData",
+ Room = "Room",
+ DeleteRoom = "deleteRoom",
+ SyncUnexpectedError = "sync.unexpectedError",
+ ClientWellKnown = "WellKnown.client",
+ ReceivedVoipEvent = "received_voip_event",
+ UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
+ TurnServers = "turnServers",
+ TurnServersError = "turnServers.error",
+}
+
+type RoomEvents =
+ | RoomEvent.Name
+ | RoomEvent.Redaction
+ | RoomEvent.RedactionCancelled
+ | RoomEvent.Receipt
+ | RoomEvent.Tags
+ | RoomEvent.LocalEchoUpdated
+ | RoomEvent.HistoryImportedWithinTimeline
+ | RoomEvent.AccountData
+ | RoomEvent.MyMembership
+ | RoomEvent.Timeline
+ | RoomEvent.TimelineReset;
+
+type RoomStateEvents =
+ | RoomStateEvent.Events
+ | RoomStateEvent.Members
+ | RoomStateEvent.NewMember
+ | RoomStateEvent.Update
+ | RoomStateEvent.Marker;
+
+type CryptoEvents =
+ | CryptoEvent.KeySignatureUploadFailure
+ | CryptoEvent.KeyBackupStatus
+ | CryptoEvent.KeyBackupFailed
+ | CryptoEvent.KeyBackupSessionsRemaining
+ | CryptoEvent.RoomKeyRequest
+ | CryptoEvent.RoomKeyRequestCancellation
+ | CryptoEvent.VerificationRequest
+ | CryptoEvent.DeviceVerificationChanged
+ | CryptoEvent.UserTrustStatusChanged
+ | CryptoEvent.KeysChanged
+ | CryptoEvent.Warning
+ | CryptoEvent.DevicesUpdated
+ | CryptoEvent.WillUpdateDevices;
+
+type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
+
+type RoomMemberEvents =
+ | RoomMemberEvent.Name
+ | RoomMemberEvent.Typing
+ | RoomMemberEvent.PowerLevel
+ | RoomMemberEvent.Membership;
+
+type UserEvents =
+ | UserEvent.AvatarUrl
+ | UserEvent.DisplayName
+ | UserEvent.Presence
+ | UserEvent.CurrentlyActive
+ | UserEvent.LastPresenceTs;
+
+export type EmittedEvents =
+ | ClientEvent
+ | RoomEvents
+ | RoomStateEvents
+ | CryptoEvents
+ | MatrixEventEvents
+ | RoomMemberEvents
+ | UserEvents
+ | CallEvent // re-emitted by call.ts using Object.values
+ | CallEventHandlerEvent.Incoming
+ | GroupCallEventHandlerEvent.Incoming
+ | GroupCallEventHandlerEvent.Outgoing
+ | GroupCallEventHandlerEvent.Ended
+ | GroupCallEventHandlerEvent.Participants
+ | HttpApiEvent.SessionLoggedOut
+ | HttpApiEvent.NoConsent
+ | BeaconEvent;
+
+export type ClientEventHandlerMap = {
+ /**
+ * Fires whenever the SDK's syncing state is updated. The state can be one of:
+ * <ul>
+ *
+ * <li>PREPARED: The client has synced with the server at least once and is
+ * ready for methods to be called on it. This will be immediately followed by
+ * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
+ * previous API.</i></li>
+ *
+ * <li>CATCHUP: The client has detected the connection to the server might be
+ * available again and will now try to do a sync again. As this sync might take
+ * a long time (depending how long ago was last synced, and general server
+ * performance) the client is put in this mode so the UI can reflect trying
+ * to catch up with the server after losing connection.</li>
+ *
+ * <li>SYNCING : The client is currently polling for new events from the server.
+ * This will be called <i>after</i> processing latest events from a sync.</li>
+ *
+ * <li>ERROR : The client has had a problem syncing with the server. If this is
+ * called <i>before</i> PREPARED then there was a problem performing the initial
+ * sync. If this is called <i>after</i> PREPARED then there was a problem polling
+ * the server for updates. This may be called multiple times even if the state is
+ * already ERROR. <i>This is the equivalent of "syncError" in the previous
+ * API.</i></li>
+ *
+ * <li>RECONNECTING: The sync connection has dropped, but not (yet) in a way that
+ * should be considered erroneous.
+ * </li>
+ *
+ * <li>STOPPED: The client has stopped syncing with server due to stopClient
+ * being called.
+ * </li>
+ * </ul>
+ * State transition diagram:
+ * ```
+ * +---->STOPPED
+ * |
+ * +----->PREPARED -------> SYNCING <--+
+ * | ^ | ^ |
+ * | CATCHUP ----------+ | | |
+ * | ^ V | |
+ * null ------+ | +------- RECONNECTING |
+ * | V V |
+ * +------->ERROR ---------------------+
+ *
+ * NB: 'null' will never be emitted by this event.
+ *
+ * ```
+ * Transitions:
+ * <ul>
+ *
+ * <li>`null -> PREPARED` : Occurs when the initial sync is completed
+ * first time. This involves setting up filters and obtaining push rules.
+ *
+ * <li>`null -> ERROR` : Occurs when the initial sync failed first time.
+ *
+ * <li>`ERROR -> PREPARED` : Occurs when the initial sync succeeds
+ * after previously failing.
+ *
+ * <li>`PREPARED -> SYNCING` : Occurs immediately after transitioning
+ * to PREPARED. Starts listening for live updates rather than catching up.
+ *
+ * <li>`SYNCING -> RECONNECTING` : Occurs when the live update fails.
+ *
+ * <li>`RECONNECTING -> RECONNECTING` : Can occur if the update calls
+ * continue to fail, but the keepalive calls (to /versions) succeed.
+ *
+ * <li>`RECONNECTING -> ERROR` : Occurs when the keepalive call also fails
+ *
+ * <li>`ERROR -> SYNCING` : Occurs when the client has performed a
+ * live update after having previously failed.
+ *
+ * <li>`ERROR -> ERROR` : Occurs when the client has failed to keepalive
+ * for a second time or more.</li>
+ *
+ * <li>`SYNCING -> SYNCING` : Occurs when the client has performed a live
+ * update. This is called <i>after</i> processing.</li>
+ *
+ * <li>`* -> STOPPED` : Occurs once the client has stopped syncing or
+ * trying to sync after stopClient has been called.</li>
+ * </ul>
+ *
+ * @param state - An enum representing the syncing state. One of "PREPARED",
+ * "SYNCING", "ERROR", "STOPPED".
+ *
+ * @param prevState - An enum representing the previous syncing state.
+ * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
+ *
+ * @param data - Data about this transition.
+ *
+ * @example
+ * ```
+ * matrixClient.on("sync", function(state, prevState, data) {
+ * switch (state) {
+ * case "ERROR":
+ * // update UI to say "Connection Lost"
+ * break;
+ * case "SYNCING":
+ * // update UI to remove any "Connection Lost" message
+ * break;
+ * case "PREPARED":
+ * // the client instance is ready to be queried.
+ * var rooms = matrixClient.getRooms();
+ * break;
+ * }
+ * });
+ * ```
+ */
+ [ClientEvent.Sync]: (state: SyncState, lastState: SyncState | null, data?: ISyncStateData) => void;
+ /**
+ * Fires whenever the SDK receives a new event.
+ * <p>
+ * This is only fired for live events received via /sync - it is not fired for
+ * events received over context, search, or pagination APIs.
+ *
+ * @param event - The matrix event which caused this event to fire.
+ * @example
+ * ```
+ * matrixClient.on("event", function(event){
+ * var sender = event.getSender();
+ * });
+ * ```
+ */
+ [ClientEvent.Event]: (event: MatrixEvent) => void;
+ /**
+ * Fires whenever the SDK receives a new to-device event.
+ * @param event - The matrix event which caused this event to fire.
+ * @example
+ * ```
+ * matrixClient.on("toDeviceEvent", function(event){
+ * var sender = event.getSender();
+ * });
+ * ```
+ */
+ [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
+ /**
+ * Fires if a to-device event is received that cannot be decrypted.
+ * Encrypted to-device events will (generally) use plain Olm encryption,
+ * in which case decryption failures are fatal: the event will never be
+ * decryptable, unlike Megolm encrypted events where the key may simply
+ * arrive later.
+ *
+ * An undecryptable to-device event is therefore likley to indicate problems.
+ *
+ * @param event - The undecyptable to-device event
+ */
+ [ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
+ /**
+ * Fires whenever new user-scoped account_data is added.
+ * @param event - The event describing the account_data just added
+ * @param event - The previous account data, if known.
+ * @example
+ * ```
+ * matrixClient.on("accountData", function(event, oldEvent){
+ * myAccountData[event.type] = event.content;
+ * });
+ * ```
+ */
+ [ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
+ /**
+ * Fires whenever a new Room is added. This will fire when you are invited to a
+ * room, as well as when you join a room. <strong>This event is experimental and
+ * may change.</strong>
+ * @param room - The newly created, fully populated room.
+ * @example
+ * ```
+ * matrixClient.on("Room", function(room){
+ * var roomId = room.roomId;
+ * });
+ * ```
+ */
+ [ClientEvent.Room]: (room: Room) => void;
+ /**
+ * Fires whenever a Room is removed. This will fire when you forget a room.
+ * <strong>This event is experimental and may change.</strong>
+ * @param roomId - The deleted room ID.
+ * @example
+ * ```
+ * matrixClient.on("deleteRoom", function(roomId){
+ * // update UI from getRooms()
+ * });
+ * ```
+ */
+ [ClientEvent.DeleteRoom]: (roomId: string) => void;
+ [ClientEvent.SyncUnexpectedError]: (error: Error) => void;
+ /**
+ * Fires when the client .well-known info is fetched.
+ *
+ * @param data - The JSON object returned by the server
+ */
+ [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
+ [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
+ [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
+ [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
+} & RoomEventHandlerMap &
+ RoomStateEventHandlerMap &
+ CryptoEventHandlerMap &
+ MatrixEventHandlerMap &
+ RoomMemberEventHandlerMap &
+ UserEventHandlerMap &
+ CallEventHandlerEventHandlerMap &
+ GroupCallEventHandlerEventHandlerMap &
+ CallEventHandlerMap &
+ HttpApiEventHandlerMap &
+ BeaconEventHandlerMap;
+
+const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action");
+
+/**
+ * Represents a Matrix Client. Only directly construct this if you want to use
+ * custom modules. Normally, {@link createClient} should be used
+ * as it specifies 'sensible' defaults for these modules.
+ */
+export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> {
+ public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY";
+
+ public reEmitter = new TypedReEmitter<EmittedEvents, ClientEventHandlerMap>(this);
+ public olmVersion: [number, number, number] | null = null; // populated after initCrypto
+ public usingExternalCrypto = false;
+ public store: Store;
+ public deviceId: string | null;
+ public credentials: { userId: string | null };
+ public pickleKey?: string;
+ public scheduler?: MatrixScheduler;
+ public clientRunning = false;
+ public timelineSupport = false;
+ public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
+ public identityServer?: IIdentityServerProvider;
+ public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
+
+ /**
+ * The libolm crypto implementation, if it is in use.
+ *
+ * @deprecated This should not be used. Instead, use the methods exposed directly on this class or
+ * (where they are available) via {@link getCrypto}.
+ */
+ public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend
+
+ private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
+ public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
+ public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
+ public groupCallEventHandler?: GroupCallEventHandler;
+ public supportsCallTransfer = false; // XXX: Intended private, used in code.
+ public forceTURN = false; // XXX: Intended private, used in code.
+ public iceCandidatePoolSize = 0; // XXX: Intended private, used in code.
+ public idBaseUrl?: string;
+ public baseUrl: string;
+ public readonly isVoipWithNoMediaAllowed;
+
+ // Note: these are all `protected` to let downstream consumers make mistakes if they want to.
+ // We don't technically support this usage, but have reasons to do this.
+
+ protected canSupportVoip = false;
+ protected peekSync: SyncApi | null = null;
+ protected isGuestAccount = false;
+ protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise<Room>; errorTs?: number } } = {};
+ protected notifTimelineSet: EventTimelineSet | null = null;
+ protected cryptoStore?: CryptoStore;
+ protected verificationMethods?: VerificationMethod[];
+ protected fallbackICEServerAllowed = false;
+ protected roomList: RoomList;
+ protected syncApi?: SlidingSyncSdk | SyncApi;
+ public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
+ public pushRules?: IPushRules;
+ protected syncLeftRoomsPromise?: Promise<Room[]>;
+ protected syncedLeftRooms = false;
+ protected clientOpts?: IStoredClientOpts;
+ protected clientWellKnownIntervalID?: ReturnType<typeof setInterval>;
+ protected canResetTimelineCallback?: ResetTimelineCallback;
+
+ public canSupport = new Map<Feature, ServerSupport>();
+
+ // The pushprocessor caches useful things, so keep one and re-use it
+ protected pushProcessor = new PushProcessor(this);
+
+ // Promise to a response of the server's /versions response
+ // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
+ protected serverVersionsPromise?: Promise<IServerVersions>;
+
+ public cachedCapabilities?: {
+ capabilities: ICapabilities;
+ expiration: number;
+ };
+ protected clientWellKnown?: IClientWellKnown;
+ protected clientWellKnownPromise?: Promise<IClientWellKnown>;
+ protected turnServers: ITurnServer[] = [];
+ protected turnServersExpiry = 0;
+ protected checkTurnServersIntervalID?: ReturnType<typeof setInterval>;
+ protected exportedOlmDeviceToImport?: IExportedOlmDevice;
+ protected txnCtr = 0;
+ protected mediaHandler = new MediaHandler(this);
+ protected sessionId: string;
+ protected pendingEventEncryption = new Map<string, Promise<void>>();
+
+ private useE2eForGroupCall = true;
+ private toDeviceMessageQueue: ToDeviceMessageQueue;
+
+ // A manager for determining which invites should be ignored.
+ public readonly ignoredInvites: IgnoredInvites;
+
+ public constructor(opts: IMatrixClientCreateOpts) {
+ super();
+
+ opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
+ opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);
+
+ this.baseUrl = opts.baseUrl;
+ this.idBaseUrl = opts.idBaseUrl;
+ this.identityServer = opts.identityServer;
+
+ this.usingExternalCrypto = opts.usingExternalCrypto ?? false;
+ this.store = opts.store || new StubStore();
+ this.deviceId = opts.deviceId || null;
+ this.sessionId = randomString(10);
+
+ const userId = opts.userId || null;
+ this.credentials = { userId };
+
+ this.http = new MatrixHttpApi(this as ConstructorParameters<typeof MatrixHttpApi>[0], {
+ fetchFn: opts.fetchFn,
+ baseUrl: opts.baseUrl,
+ idBaseUrl: opts.idBaseUrl,
+ accessToken: opts.accessToken,
+ prefix: ClientPrefix.R0,
+ onlyData: true,
+ extraParams: opts.queryParams,
+ localTimeoutMs: opts.localTimeoutMs,
+ useAuthorizationHeader: opts.useAuthorizationHeader,
+ });
+
+ if (opts.deviceToImport) {
+ if (this.deviceId) {
+ logger.warn(
+ "not importing device because device ID is provided to " +
+ "constructor independently of exported data",
+ );
+ } else if (this.credentials.userId) {
+ logger.warn(
+ "not importing device because user ID is provided to " +
+ "constructor independently of exported data",
+ );
+ } else if (!opts.deviceToImport.deviceId) {
+ logger.warn("not importing device because no device ID in exported data");
+ } else {
+ this.deviceId = opts.deviceToImport.deviceId;
+ this.credentials.userId = opts.deviceToImport.userId;
+ // will be used during async initialization of the crypto
+ this.exportedOlmDeviceToImport = opts.deviceToImport.olmDevice;
+ }
+ } else if (opts.pickleKey) {
+ this.pickleKey = opts.pickleKey;
+ }
+
+ this.scheduler = opts.scheduler;
+ if (this.scheduler) {
+ this.scheduler.setProcessFunction(async (eventToSend: MatrixEvent) => {
+ const room = this.getRoom(eventToSend.getRoomId());
+ if (eventToSend.status !== EventStatus.SENDING) {
+ this.updatePendingEventStatus(room, eventToSend, EventStatus.SENDING);
+ }
+ const res = await this.sendEventHttpRequest(eventToSend);
+ if (room) {
+ // ensure we update pending event before the next scheduler run so that any listeners to event id
+ // updates on the synchronous event emitter get a chance to run first.
+ room.updatePendingEvent(eventToSend, EventStatus.SENT, res.event_id);
+ }
+ return res;
+ });
+ }
+
+ if (supportsMatrixCall()) {
+ this.callEventHandler = new CallEventHandler(this);
+ this.groupCallEventHandler = new GroupCallEventHandler(this);
+ this.canSupportVoip = true;
+ // Start listening for calls after the initial sync is done
+ // We do not need to backfill the call event buffer
+ // with encrypted events that might never get decrypted
+ this.on(ClientEvent.Sync, this.startCallEventHandler);
+ }
+
+ this.on(ClientEvent.Sync, this.fixupRoomNotifications);
+
+ this.timelineSupport = Boolean(opts.timelineSupport);
+
+ this.cryptoStore = opts.cryptoStore;
+ this.verificationMethods = opts.verificationMethods;
+ this.cryptoCallbacks = opts.cryptoCallbacks || {};
+
+ this.forceTURN = opts.forceTURN || false;
+ this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
+ this.supportsCallTransfer = opts.supportsCallTransfer || false;
+ this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
+ this.isVoipWithNoMediaAllowed = opts.isVoipWithNoMediaAllowed || false;
+
+ if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
+
+ // List of which rooms have encryption enabled: separate from crypto because
+ // we still want to know which rooms are encrypted even if crypto is disabled:
+ // we don't want to start sending unencrypted events to them.
+ this.roomList = new RoomList(this.cryptoStore);
+ this.roomNameGenerator = opts.roomNameGenerator;
+
+ this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
+
+ // The SDK doesn't really provide a clean way for events to recalculate the push
+ // actions for themselves, so we have to kinda help them out when they are encrypted.
+ // We do this so that push rules are correctly executed on events in their decrypted
+ // state, such as highlights when the user's name is mentioned.
+ this.on(MatrixEventEvent.Decrypted, (event) => {
+ fixNotificationCountOnDecryption(this, event);
+ });
+
+ // Like above, we have to listen for read receipts from ourselves in order to
+ // correctly handle notification counts on encrypted rooms.
+ // This fixes https://github.com/vector-im/element-web/issues/9421
+ this.on(RoomEvent.Receipt, (event, room) => {
+ if (room && this.isRoomEncrypted(room.roomId)) {
+ // Figure out if we've read something or if it's just informational
+ const content = event.getContent();
+ const isSelf =
+ Object.keys(content).filter((eid) => {
+ for (const [key, value] of Object.entries(content[eid])) {
+ if (!utils.isSupportedReceiptType(key)) continue;
+ if (!value) continue;
+
+ if (Object.keys(value).includes(this.getUserId()!)) return true;
+ }
+
+ return false;
+ }).length > 0;
+
+ if (!isSelf) return;
+
+ // Work backwards to determine how many events are unread. We also set
+ // a limit for how back we'll look to avoid spinning CPU for too long.
+ // If we hit the limit, we assume the count is unchanged.
+ const maxHistory = 20;
+ const events = room.getLiveTimeline().getEvents();
+
+ let highlightCount = 0;
+
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (i === events.length - maxHistory) return; // limit reached
+
+ const event = events[i];
+
+ if (room.hasUserReadEvent(this.getUserId()!, event.getId()!)) {
+ // If the user has read the event, then the counting is done.
+ break;
+ }
+
+ const pushActions = this.getPushActionsForEvent(event);
+ highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
+ }
+
+ // Note: we don't need to handle 'total' notifications because the counts
+ // will come from the server.
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
+ }
+ });
+
+ this.ignoredInvites = new IgnoredInvites(this);
+ }
+
+ /**
+ * High level helper method to begin syncing and poll for new events. To listen for these
+ * events, add a listener for {@link ClientEvent.Event}
+ * via {@link MatrixClient#on}. Alternatively, listen for specific
+ * state change events.
+ * @param opts - Options to apply when syncing.
+ */
+ public async startClient(opts?: IStartClientOpts): Promise<void> {
+ if (this.clientRunning) {
+ // client is already running.
+ return;
+ }
+ this.clientRunning = true;
+ // backwards compat for when 'opts' was 'historyLen'.
+ if (typeof opts === "number") {
+ opts = {
+ initialSyncLimit: opts,
+ };
+ }
+
+ // Create our own user object artificially (instead of waiting for sync)
+ // so it's always available, even if the user is not in any rooms etc.
+ const userId = this.getUserId();
+ if (userId) {
+ this.store.storeUser(new User(userId));
+ }
+
+ // periodically poll for turn servers if we support voip
+ if (this.canSupportVoip) {
+ this.checkTurnServersIntervalID = setInterval(() => {
+ this.checkTurnServers();
+ }, TURN_CHECK_INTERVAL);
+ // noinspection ES6MissingAwait
+ this.checkTurnServers();
+ }
+
+ if (this.syncApi) {
+ // This shouldn't happen since we thought the client was not running
+ logger.error("Still have sync object whilst not running: stopping old one");
+ this.syncApi.stop();
+ }
+
+ try {
+ await this.getVersions();
+
+ // This should be done with `canSupport`
+ // TODO: https://github.com/vector-im/element-web/issues/23643
+ const { threads, list, fwdPagination } = await this.doesServerSupportThread();
+ Thread.setServerSideSupport(threads);
+ Thread.setServerSideListSupport(list);
+ Thread.setServerSideFwdPaginationSupport(fwdPagination);
+ } catch (e) {
+ logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
+ }
+
+ this.clientOpts = opts ?? {};
+ if (this.clientOpts.slidingSync) {
+ this.syncApi = new SlidingSyncSdk(
+ this.clientOpts.slidingSync,
+ this,
+ this.clientOpts,
+ this.buildSyncApiOptions(),
+ );
+ } else {
+ this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ }
+
+ if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
+ logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
+ }
+
+ // If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
+ // We should fallback to that value for backwards compatibility purposes
+ if (
+ !this.clientOpts.hasOwnProperty("threadSupport") &&
+ this.clientOpts.hasOwnProperty("experimentalThreadSupport")
+ ) {
+ this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
+ }
+
+ this.syncApi.sync();
+
+ if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
+ this.clientWellKnownIntervalID = setInterval(() => {
+ this.fetchClientWellKnown();
+ }, 1000 * this.clientOpts.clientWellKnownPollPeriod);
+ this.fetchClientWellKnown();
+ }
+
+ this.toDeviceMessageQueue.start();
+ }
+
+ /**
+ * Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor
+ */
+ protected buildSyncApiOptions(): SyncApiOptions {
+ return {
+ crypto: this.crypto,
+ cryptoCallbacks: this.cryptoBackend,
+ canResetEntireTimeline: (roomId: string): boolean => {
+ if (!this.canResetTimelineCallback) {
+ return false;
+ }
+ return this.canResetTimelineCallback(roomId);
+ },
+ };
+ }
+
+ /**
+ * High level helper method to stop the client from polling and allow a
+ * clean shutdown.
+ */
+ public stopClient(): void {
+ this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started
+
+ if (!this.clientRunning) return; // already stopped
+
+ logger.log("stopping MatrixClient");
+
+ this.clientRunning = false;
+
+ this.syncApi?.stop();
+ this.syncApi = undefined;
+
+ this.peekSync?.stopPeeking();
+
+ this.callEventHandler?.stop();
+ this.groupCallEventHandler?.stop();
+ this.callEventHandler = undefined;
+ this.groupCallEventHandler = undefined;
+
+ global.clearInterval(this.checkTurnServersIntervalID);
+ this.checkTurnServersIntervalID = undefined;
+
+ if (this.clientWellKnownIntervalID !== undefined) {
+ global.clearInterval(this.clientWellKnownIntervalID);
+ }
+
+ this.toDeviceMessageQueue.stop();
+ }
+
+ /**
+ * Try to rehydrate a device if available. The client must have been
+ * initialized with a `cryptoCallback.getDehydrationKey` option, and this
+ * function must be called before initCrypto and startClient are called.
+ *
+ * @returns Promise which resolves to undefined if a device could not be dehydrated, or
+ * to the new device ID if the dehydration was successful.
+ * @returns Rejects: with an error response.
+ */
+ public async rehydrateDevice(): Promise<string | undefined> {
+ if (this.crypto) {
+ throw new Error("Cannot rehydrate device after crypto is initialized");
+ }
+
+ if (!this.cryptoCallbacks.getDehydrationKey) {
+ return;
+ }
+
+ const getDeviceResult = await this.getDehydratedDevice();
+ if (!getDeviceResult) {
+ return;
+ }
+
+ if (!getDeviceResult.device_data || !getDeviceResult.device_id) {
+ logger.info("no dehydrated device found");
+ return;
+ }
+
+ const account = new global.Olm.Account();
+ try {
+ const deviceData = getDeviceResult.device_data;
+ if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) {
+ logger.warn("Wrong algorithm for dehydrated device");
+ return;
+ }
+ logger.log("unpickling dehydrated device");
+ const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => {
+ // copy the key so that it doesn't get clobbered
+ account.unpickle(new Uint8Array(k), deviceData.account);
+ });
+ account.unpickle(key, deviceData.account);
+ logger.log("unpickled device");
+
+ const rehydrateResult = await this.http.authedRequest<{ success: boolean }>(
+ Method.Post,
+ "/dehydrated_device/claim",
+ undefined,
+ {
+ device_id: getDeviceResult.device_id,
+ },
+ {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
+ },
+ );
+
+ if (rehydrateResult.success) {
+ this.deviceId = getDeviceResult.device_id;
+ logger.info("using dehydrated device");
+ const pickleKey = this.pickleKey || "DEFAULT_KEY";
+ this.exportedOlmDeviceToImport = {
+ pickledAccount: account.pickle(pickleKey),
+ sessions: [],
+ pickleKey: pickleKey,
+ };
+ account.free();
+ return this.deviceId;
+ } else {
+ account.free();
+ logger.info("not using dehydrated device");
+ return;
+ }
+ } catch (e) {
+ account.free();
+ logger.warn("could not unpickle", e);
+ }
+ }
+
+ /**
+ * Get the current dehydrated device, if any
+ * @returns A promise of an object containing the dehydrated device
+ */
+ public async getDehydratedDevice(): Promise<IDehydratedDevice | undefined> {
+ try {
+ return await this.http.authedRequest<IDehydratedDevice>(
+ Method.Get,
+ "/dehydrated_device",
+ undefined,
+ undefined,
+ {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
+ },
+ );
+ } catch (e) {
+ logger.info("could not get dehydrated device", e);
+ return;
+ }
+ }
+
+ /**
+ * Set the dehydration key. This will also periodically dehydrate devices to
+ * the server.
+ *
+ * @param key - the dehydration key
+ * @param keyInfo - Information about the key. Primarily for
+ * information about how to generate the key from a passphrase.
+ * @param deviceDisplayName - The device display name for the
+ * dehydrated device.
+ * @returns A promise that resolves when the dehydrated device is stored.
+ */
+ public async setDehydrationKey(
+ key: Uint8Array,
+ keyInfo: IDehydratedDeviceKeyInfo,
+ deviceDisplayName?: string,
+ ): Promise<void> {
+ if (!this.crypto) {
+ logger.warn("not dehydrating device if crypto is not enabled");
+ return;
+ }
+ return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName);
+ }
+
+ /**
+ * Creates a new dehydrated device (without queuing periodic dehydration)
+ * @param key - the dehydration key
+ * @param keyInfo - Information about the key. Primarily for
+ * information about how to generate the key from a passphrase.
+ * @param deviceDisplayName - The device display name for the
+ * dehydrated device.
+ * @returns the device id of the newly created dehydrated device
+ */
+ public async createDehydratedDevice(
+ key: Uint8Array,
+ keyInfo: IDehydratedDeviceKeyInfo,
+ deviceDisplayName?: string,
+ ): Promise<string | undefined> {
+ if (!this.crypto) {
+ logger.warn("not dehydrating device if crypto is not enabled");
+ return;
+ }
+ await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName);
+ return this.crypto.dehydrationManager.dehydrateDevice();
+ }
+
+ public async exportDevice(): Promise<IExportedDevice | undefined> {
+ if (!this.crypto) {
+ logger.warn("not exporting device if crypto is not enabled");
+ return;
+ }
+ return {
+ userId: this.credentials.userId!,
+ deviceId: this.deviceId!,
+ // XXX: Private member access.
+ olmDevice: await this.crypto.olmDevice.export(),
+ };
+ }
+
+ /**
+ * Clear any data out of the persistent stores used by the client.
+ *
+ * @returns Promise which resolves when the stores have been cleared.
+ */
+ public clearStores(): Promise<void> {
+ if (this.clientRunning) {
+ throw new Error("Cannot clear stores while client is running");
+ }
+
+ const promises: Promise<void>[] = [];
+
+ promises.push(this.store.deleteAllData());
+ if (this.cryptoStore) {
+ promises.push(this.cryptoStore.deleteAllData());
+ }
+
+ // delete the stores used by the rust matrix-sdk-crypto, in case they were used
+ const deleteRustSdkStore = async (): Promise<void> => {
+ let indexedDB: IDBFactory;
+ try {
+ indexedDB = global.indexedDB;
+ } catch (e) {
+ // No indexeddb support
+ return;
+ }
+ for (const dbname of [
+ `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
+ `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
+ ]) {
+ const prom = new Promise((resolve, reject) => {
+ logger.info(`Removing IndexedDB instance ${dbname}`);
+ const req = indexedDB.deleteDatabase(dbname);
+ req.onsuccess = (_): void => {
+ logger.info(`Removed IndexedDB instance ${dbname}`);
+ resolve(0);
+ };
+ req.onerror = (e): void => {
+ // In private browsing, Firefox has a global.indexedDB, but attempts to delete an indexeddb
+ // (even a non-existent one) fail with "DOMException: A mutation operation was attempted on a
+ // database that did not allow mutations."
+ //
+ // it seems like the only thing we can really do is ignore the error.
+ logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
+ resolve(0);
+ };
+ req.onblocked = (e): void => {
+ logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
+ };
+ });
+ await prom;
+ }
+ };
+ promises.push(deleteRustSdkStore());
+
+ return Promise.all(promises).then(); // .then to fix types
+ }
+
+ /**
+ * Get the user-id of the logged-in user
+ *
+ * @returns MXID for the logged-in user, or null if not logged in
+ */
+ public getUserId(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId;
+ }
+ return null;
+ }
+
+ /**
+ * Get the user-id of the logged-in user
+ *
+ * @returns MXID for the logged-in user
+ * @throws Error if not logged in
+ */
+ public getSafeUserId(): string {
+ const userId = this.getUserId();
+ if (!userId) {
+ throw new Error("Expected logged in user but found none.");
+ }
+ return userId;
+ }
+
+ /**
+ * Get the domain for this client's MXID
+ * @returns Domain of this MXID
+ */
+ public getDomain(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId.replace(/^.*?:/, "");
+ }
+ return null;
+ }
+
+ /**
+ * Get the local part of the current user ID e.g. "foo" in "\@foo:bar".
+ * @returns The user ID localpart or null.
+ */
+ public getUserIdLocalpart(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId.split(":")[0].substring(1);
+ }
+ return null;
+ }
+
+ /**
+ * Get the device ID of this client
+ * @returns device ID
+ */
+ public getDeviceId(): string | null {
+ return this.deviceId;
+ }
+
+ /**
+ * Get the session ID of this client
+ * @returns session ID
+ */
+ public getSessionId(): string {
+ return this.sessionId;
+ }
+
+ /**
+ * Check if the runtime environment supports VoIP calling.
+ * @returns True if VoIP is supported.
+ */
+ public supportsVoip(): boolean {
+ return this.canSupportVoip;
+ }
+
+ /**
+ * @returns
+ */
+ public getMediaHandler(): MediaHandler {
+ return this.mediaHandler;
+ }
+
+ /**
+ * Set whether VoIP calls are forced to use only TURN
+ * candidates. This is the same as the forceTURN option
+ * when creating the client.
+ * @param force - True to force use of TURN servers
+ */
+ public setForceTURN(force: boolean): void {
+ this.forceTURN = force;
+ }
+
+ /**
+ * Set whether to advertise transfer support to other parties on Matrix calls.
+ * @param support - True to advertise the 'm.call.transferee' capability
+ */
+ public setSupportsCallTransfer(support: boolean): void {
+ this.supportsCallTransfer = support;
+ }
+
+ /**
+ * Returns true if to-device signalling for group calls will be encrypted with Olm.
+ * If false, it will be sent unencrypted.
+ * @returns boolean Whether group call signalling will be encrypted
+ */
+ public getUseE2eForGroupCall(): boolean {
+ return this.useE2eForGroupCall;
+ }
+
+ /**
+ * Creates a new call.
+ * The place*Call methods on the returned call can be used to actually place a call
+ *
+ * @param roomId - The room the call is to be placed in.
+ * @returns the call or null if the browser doesn't support calling.
+ */
+ public createCall(roomId: string): MatrixCall | null {
+ return createNewMatrixCall(this, roomId);
+ }
+
+ /**
+ * Creates a new group call and sends the associated state event
+ * to alert other members that the room now has a group call.
+ *
+ * @param roomId - The room the call is to be placed in.
+ */
+ public async createGroupCall(
+ roomId: string,
+ type: GroupCallType,
+ isPtt: boolean,
+ intent: GroupCallIntent,
+ dataChannelsEnabled?: boolean,
+ dataChannelOptions?: IGroupCallDataChannelOptions,
+ ): Promise<GroupCall> {
+ if (this.getGroupCallForRoom(roomId)) {
+ throw new Error(`${roomId} already has an existing group call`);
+ }
+
+ const room = this.getRoom(roomId);
+
+ if (!room) {
+ throw new Error(`Cannot find room ${roomId}`);
+ }
+
+ // Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
+ // no media WebRTC connection anyway.
+ return new GroupCall(
+ this,
+ room,
+ type,
+ isPtt,
+ intent,
+ undefined,
+ dataChannelsEnabled || this.isVoipWithNoMediaAllowed,
+ dataChannelOptions,
+ this.isVoipWithNoMediaAllowed,
+ ).create();
+ }
+
+ /**
+ * Wait until an initial state for the given room has been processed by the
+ * client and the client is aware of any ongoing group calls. Awaiting on
+ * the promise returned by this method before calling getGroupCallForRoom()
+ * avoids races where getGroupCallForRoom is called before the state for that
+ * room has been processed. It does not, however, fix other races, eg. two
+ * clients both creating a group call at the same time.
+ * @param roomId - The room ID to wait for
+ * @returns A promise that resolves once existing group calls in the room
+ * have been processed.
+ */
+ public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
+ return this.groupCallEventHandler!.waitUntilRoomReadyForGroupCalls(roomId);
+ }
+
+ /**
+ * Get an existing group call for the provided room.
+ * @returns The group call or null if it doesn't already exist.
+ */
+ public getGroupCallForRoom(roomId: string): GroupCall | null {
+ return this.groupCallEventHandler!.groupCalls.get(roomId) || null;
+ }
+
+ /**
+ * Get the current sync state.
+ * @returns the sync state, which may be null.
+ * @see MatrixClient#event:"sync"
+ */
+ public getSyncState(): SyncState | null {
+ return this.syncApi?.getSyncState() ?? null;
+ }
+
+ /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ */
+ public getSyncStateData(): ISyncStateData | null {
+ if (!this.syncApi) {
+ return null;
+ }
+ return this.syncApi.getSyncStateData();
+ }
+
+ /**
+ * Whether the initial sync has completed.
+ * @returns True if at least one sync has happened.
+ */
+ public isInitialSyncComplete(): boolean {
+ const state = this.getSyncState();
+ if (!state) {
+ return false;
+ }
+ return state === SyncState.Prepared || state === SyncState.Syncing;
+ }
+
+ /**
+ * Return whether the client is configured for a guest account.
+ * @returns True if this is a guest access_token (or no token is supplied).
+ */
+ public isGuest(): boolean {
+ return this.isGuestAccount;
+ }
+
+ /**
+ * Set whether this client is a guest account. <b>This method is experimental
+ * and may change without warning.</b>
+ * @param guest - True if this is a guest account.
+ */
+ public setGuest(guest: boolean): void {
+ // EXPERIMENTAL:
+ // If the token is a macaroon, it should be encoded in it that it is a 'guest'
+ // access token, which means that the SDK can determine this entirely without
+ // the dev manually flipping this flag.
+ this.isGuestAccount = guest;
+ }
+
+ /**
+ * Return the provided scheduler, if any.
+ * @returns The scheduler or undefined
+ */
+ public getScheduler(): MatrixScheduler | undefined {
+ return this.scheduler;
+ }
+
+ /**
+ * Retry a backed off syncing request immediately. This should only be used when
+ * the user <b>explicitly</b> attempts to retry their lost connection.
+ * Will also retry any outbound to-device messages currently in the queue to be sent
+ * (retries of regular outgoing events are handled separately, per-event).
+ * @returns True if this resulted in a request being retried.
+ */
+ public retryImmediately(): boolean {
+ // don't await for this promise: we just want to kick it off
+ this.toDeviceMessageQueue.sendQueue();
+ return this.syncApi?.retryImmediately() ?? false;
+ }
+
+ /**
+ * Return the global notification EventTimelineSet, if any
+ *
+ * @returns the globl notification EventTimelineSet
+ */
+ public getNotifTimelineSet(): EventTimelineSet | null {
+ return this.notifTimelineSet;
+ }
+
+ /**
+ * Set the global notification EventTimelineSet
+ *
+ */
+ public setNotifTimelineSet(set: EventTimelineSet): void {
+ this.notifTimelineSet = set;
+ }
+
+ /**
+ * Gets the capabilities of the homeserver. Always returns an object of
+ * capability keys and their options, which may be empty.
+ * @param fresh - True to ignore any cached values.
+ * @returns Promise which resolves to the capabilities of the homeserver
+ * @returns Rejects: with an error response.
+ */
+ public getCapabilities(fresh = false): Promise<ICapabilities> {
+ const now = new Date().getTime();
+
+ if (this.cachedCapabilities && !fresh) {
+ if (now < this.cachedCapabilities.expiration) {
+ logger.log("Returning cached capabilities");
+ return Promise.resolve(this.cachedCapabilities.capabilities);
+ }
+ }
+
+ type Response = {
+ capabilities?: ICapabilities;
+ };
+ return this.http
+ .authedRequest<Response>(Method.Get, "/capabilities")
+ .catch((e: Error): Response => {
+ // We swallow errors because we need a default object anyhow
+ logger.error(e);
+ return {};
+ })
+ .then((r = {}) => {
+ const capabilities = r["capabilities"] || {};
+
+ // If the capabilities missed the cache, cache it for a shorter amount
+ // of time to try and refresh them later.
+ const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
+
+ this.cachedCapabilities = {
+ capabilities,
+ expiration: now + cacheMs,
+ };
+
+ logger.log("Caching capabilities: ", capabilities);
+ return capabilities;
+ });
+ }
+
+ /**
+ * Initialise support for end-to-end encryption in this client, using libolm.
+ *
+ * You should call this method after creating the matrixclient, but *before*
+ * calling `startClient`, if you want to support end-to-end encryption.
+ *
+ * It will return a Promise which will resolve when the crypto layer has been
+ * successfully initialised.
+ */
+ public async initCrypto(): Promise<void> {
+ if (!isCryptoAvailable()) {
+ throw new Error(
+ `End-to-end encryption not supported in this js-sdk build: did ` +
+ `you remember to load the olm library?`,
+ );
+ }
+
+ if (this.cryptoBackend) {
+ logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
+ return;
+ }
+
+ if (!this.cryptoStore) {
+ // the cryptostore is provided by sdk.createClient, so this shouldn't happen
+ throw new Error(`Cannot enable encryption: no cryptoStore provided`);
+ }
+
+ logger.log("Crypto: Starting up crypto store...");
+ await this.cryptoStore.startup();
+
+ // initialise the list of encrypted rooms (whether or not crypto is enabled)
+ logger.log("Crypto: initialising roomlist...");
+ await this.roomList.init();
+
+ const userId = this.getUserId();
+ if (userId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown userId: ` +
+ `ensure userId is passed in createClient().`,
+ );
+ }
+ if (this.deviceId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown deviceId: ` +
+ `ensure deviceId is passed in createClient().`,
+ );
+ }
+
+ const crypto = new Crypto(
+ this,
+ userId,
+ this.deviceId,
+ this.store,
+ this.cryptoStore,
+ this.roomList,
+ this.verificationMethods!,
+ );
+
+ this.reEmitter.reEmit(crypto, [
+ CryptoEvent.KeyBackupFailed,
+ CryptoEvent.KeyBackupSessionsRemaining,
+ CryptoEvent.RoomKeyRequest,
+ CryptoEvent.RoomKeyRequestCancellation,
+ CryptoEvent.Warning,
+ CryptoEvent.DevicesUpdated,
+ CryptoEvent.WillUpdateDevices,
+ CryptoEvent.DeviceVerificationChanged,
+ CryptoEvent.UserTrustStatusChanged,
+ CryptoEvent.KeysChanged,
+ ]);
+
+ logger.log("Crypto: initialising crypto object...");
+ await crypto.init({
+ exportedOlmDevice: this.exportedOlmDeviceToImport,
+ pickleKey: this.pickleKey,
+ });
+ delete this.exportedOlmDeviceToImport;
+
+ this.olmVersion = Crypto.getOlmVersion();
+
+ // if crypto initialisation was successful, tell it to attach its event handlers.
+ crypto.registerEventHandlers(this as Parameters<Crypto["registerEventHandlers"]>[0]);
+ this.cryptoBackend = this.crypto = crypto;
+
+ // upload our keys in the background
+ this.crypto.uploadDeviceKeys().catch((e) => {
+ // TODO: throwing away this error is a really bad idea.
+ logger.error("Error uploading device keys", e);
+ });
+ }
+
+ /**
+ * Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto.
+ *
+ * An alternative to {@link initCrypto}.
+ *
+ * *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
+ * Eventually it will be deprecated and `initCrypto` will do the same thing.
+ *
+ * @experimental
+ *
+ * @returns a Promise which will resolve when the crypto layer has been
+ * successfully initialised.
+ */
+ public async initRustCrypto(): Promise<void> {
+ if (this.cryptoBackend) {
+ logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
+ return;
+ }
+
+ const userId = this.getUserId();
+ if (userId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown userId: ` +
+ `ensure userId is passed in createClient().`,
+ );
+ }
+ const deviceId = this.getDeviceId();
+ if (deviceId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown deviceId: ` +
+ `ensure deviceId is passed in createClient().`,
+ );
+ }
+
+ // importing rust-crypto will download the webassembly, so we delay it until we know it will be
+ // needed.
+ const RustCrypto = await import("./rust-crypto");
+ const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
+ this.cryptoBackend = rustCrypto;
+
+ // attach the event listeners needed by RustCrypto
+ this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
+ }
+
+ /**
+ * Access the crypto API for this client.
+ *
+ * If end-to-end encryption has been enabled for this client (via {@link initCrypto} or {@link initRustCrypto}),
+ * returns an object giving access to the crypto API. Otherwise, returns `undefined`.
+ */
+ public getCrypto(): CryptoApi | undefined {
+ return this.cryptoBackend;
+ }
+
+ /**
+ * Is end-to-end crypto enabled for this client.
+ * @returns True if end-to-end is enabled.
+ * @deprecated prefer {@link getCrypto}
+ */
+ public isCryptoEnabled(): boolean {
+ return !!this.cryptoBackend;
+ }
+
+ /**
+ * Get the Ed25519 key for this device
+ *
+ * @returns base64-encoded ed25519 key. Null if crypto is
+ * disabled.
+ */
+ public getDeviceEd25519Key(): string | null {
+ return this.crypto?.getDeviceEd25519Key() ?? null;
+ }
+
+ /**
+ * Get the Curve25519 key for this device
+ *
+ * @returns base64-encoded curve25519 key. Null if crypto is
+ * disabled.
+ */
+ public getDeviceCurve25519Key(): string | null {
+ return this.crypto?.getDeviceCurve25519Key() ?? null;
+ }
+
+ /**
+ * @deprecated Does nothing.
+ */
+ public async uploadKeys(): Promise<void> {
+ logger.warn("MatrixClient.uploadKeys is deprecated");
+ }
+
+ /**
+ * Download the keys for a list of users and stores the keys in the session
+ * store.
+ * @param userIds - The users to fetch.
+ * @param forceDownload - Always download the keys even if cached.
+ *
+ * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}
+ */
+ public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
+ if (!this.crypto) {
+ return Promise.reject(new Error("End-to-end encryption disabled"));
+ }
+ return this.crypto.downloadKeys(userIds, forceDownload);
+ }
+
+ /**
+ * Get the stored device keys for a user id
+ *
+ * @param userId - the user to list keys for.
+ *
+ * @returns list of devices
+ */
+ public getStoredDevicesForUser(userId: string): DeviceInfo[] {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredDevicesForUser(userId) || [];
+ }
+
+ /**
+ * Get the stored device key for a user id and device id
+ *
+ * @param userId - the user to list keys for.
+ * @param deviceId - unique identifier for the device
+ *
+ * @returns device or null
+ */
+ public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredDevice(userId, deviceId) || null;
+ }
+
+ /**
+ * Mark the given device as verified
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param verified - whether to mark the device as verified. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent#DeviceVerificationChanged}
+ */
+ public setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
+ const prom = this.setDeviceVerification(userId, deviceId, verified, null, null);
+
+ // if one of the user's own devices is being marked as verified / unverified,
+ // check the key backup status, since whether or not we use this depends on
+ // whether it has a signature from a verified device
+ if (userId == this.credentials.userId) {
+ this.checkKeyBackup();
+ }
+ return prom;
+ }
+
+ /**
+ * Mark the given device as blocked/unblocked
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param blocked - whether to mark the device as blocked. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent.DeviceVerificationChanged}
+ */
+ public setDeviceBlocked(userId: string, deviceId: string, blocked = true): Promise<void> {
+ return this.setDeviceVerification(userId, deviceId, null, blocked, null);
+ }
+
+ /**
+ * Mark the given device as known/unknown
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param known - whether to mark the device as known. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent#DeviceVerificationChanged}
+ */
+ public setDeviceKnown(userId: string, deviceId: string, known = true): Promise<void> {
+ return this.setDeviceVerification(userId, deviceId, null, null, known);
+ }
+
+ private async setDeviceVerification(
+ userId: string,
+ deviceId: string,
+ verified?: boolean | null,
+ blocked?: boolean | null,
+ known?: boolean | null,
+ ): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ await this.crypto.setDeviceVerification(userId, deviceId, verified, blocked, known);
+ }
+
+ /**
+ * Request a key verification from another user, using a DM.
+ *
+ * @param userId - the user to request verification with
+ * @param roomId - the room to use for verification
+ *
+ * @returns resolves to a VerificationRequest
+ * when the request has been sent to the other party.
+ */
+ public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestVerificationDM(userId, roomId);
+ }
+
+ /**
+ * Finds a DM verification request that is already in progress for the given room id
+ *
+ * @param roomId - the room to use for verification
+ *
+ * @returns the VerificationRequest that is in progress, if any
+ */
+ public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.findVerificationRequestDMInProgress(roomId);
+ }
+
+ /**
+ * Returns all to-device verification requests that are already in progress for the given user id
+ *
+ * @param userId - the ID of the user to query
+ *
+ * @returns the VerificationRequests that are in progress
+ */
+ public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getVerificationRequestsToDeviceInProgress(userId);
+ }
+
+ /**
+ * Request a key verification from another user.
+ *
+ * @param userId - the user to request verification with
+ * @param devices - array of device IDs to send requests to. Defaults to
+ * all devices owned by the user
+ *
+ * @returns resolves to a VerificationRequest
+ * when the request has been sent to the other party.
+ */
+ public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestVerification(userId, devices);
+ }
+
+ /**
+ * Begin a key verification.
+ *
+ * @param method - the verification method to use
+ * @param userId - the user to verify keys with
+ * @param deviceId - the device to verify
+ *
+ * @returns a verification object
+ * @deprecated Use `requestVerification` instead.
+ */
+ public beginKeyVerification(method: string, userId: string, deviceId: string): Verification<any, any> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.beginKeyVerification(method, userId, deviceId);
+ }
+
+ public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkSecretStorageKey(key, info);
+ }
+
+ /**
+ * Set the global override for whether the client should ever send encrypted
+ * messages to unverified devices. This provides the default for rooms which
+ * do not specify a value.
+ *
+ * @param value - whether to blacklist all unverified devices by default
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```javascript
+ * client.getCrypto().globalBlacklistUnverifiedDevices = value;
+ * ```
+ */
+ public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.globalBlacklistUnverifiedDevices = value;
+ return value;
+ }
+
+ /**
+ * @returns whether to blacklist all unverified devices by default
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```javascript
+ * value = client.getCrypto().globalBlacklistUnverifiedDevices;
+ * ```
+ */
+ public getGlobalBlacklistUnverifiedDevices(): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.globalBlacklistUnverifiedDevices;
+ }
+
+ /**
+ * Set whether sendMessage in a room with unknown and unverified devices
+ * should throw an error and not send them message. This has 'Global' for
+ * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
+ * no room-level equivalent for this setting.
+ *
+ * This API is currently UNSTABLE and may change or be removed without notice.
+ *
+ * @param value - whether error on unknown devices
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```ts
+ * client.getCrypto().globalBlacklistUnverifiedDevices = value;
+ * ```
+ */
+ public setGlobalErrorOnUnknownDevices(value: boolean): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.globalErrorOnUnknownDevices = value;
+ }
+
+ /**
+ * @returns whether to error on unknown devices
+ *
+ * This API is currently UNSTABLE and may change or be removed without notice.
+ */
+ public getGlobalErrorOnUnknownDevices(): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.globalErrorOnUnknownDevices;
+ }
+
+ /**
+ * Get the user's cross-signing key ID.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param type - The type of key to get the ID of. One of
+ * "master", "self_signing", or "user_signing". Defaults to "master".
+ *
+ * @returns the key ID
+ */
+ public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getCrossSigningId(type);
+ }
+
+ /**
+ * Get the cross signing information for a given user.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - the user ID to get the cross-signing info for.
+ *
+ * @returns the cross signing information for the user.
+ */
+ public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredCrossSigningForUser(userId);
+ }
+
+ /**
+ * Check whether a given user is trusted.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - The ID of the user to check.
+ */
+ public checkUserTrust(userId: string): UserTrustLevel {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.checkUserTrust(userId);
+ }
+
+ /**
+ * Check whether a given device is trusted.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - The ID of the user whose devices is to be checked.
+ * @param deviceId - The ID of the device to check
+ */
+ public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
+ }
+
+ /**
+ * Check whether one of our own devices is cross-signed by our
+ * user's stored keys, regardless of whether we trust those keys yet.
+ *
+ * @param deviceId - The ID of the device to check
+ *
+ * @returns true if the device is cross-signed
+ */
+ public checkIfOwnDeviceCrossSigned(deviceId: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkIfOwnDeviceCrossSigned(deviceId);
+ }
+
+ /**
+ * Check the copy of our cross-signing key that we have in the device list and
+ * see if we can get the private key. If so, mark it as trusted.
+ * @param opts - ICheckOwnCrossSigningTrustOpts object
+ */
+ public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkOwnCrossSigningTrust(opts);
+ }
+
+ /**
+ * Checks that a given cross-signing private key matches a given public key.
+ * This can be used by the getCrossSigningKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey);
+ }
+
+ // deprecated: use requestVerification instead
+ public legacyDeviceVerification(
+ userId: string,
+ deviceId: string,
+ method: VerificationMethod,
+ ): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.legacyDeviceVerification(userId, deviceId, method);
+ }
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ * @param room - the room the event is in
+ *
+ * @deprecated Prefer {@link CryptoApi.prepareToEncrypt | `CryptoApi.prepareToEncrypt`}:
+ *
+ * ```javascript
+ * client.getCrypto().prepareToEncrypt(room);
+ * ```
+ */
+ public prepareToEncrypt(room: Room): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.prepareToEncrypt(room);
+ }
+
+ /**
+ * Checks if the user has previously published cross-signing keys
+ *
+ * This means downloading the devicelist for the user and checking if the list includes
+ * the cross-signing pseudo-device.
+ *
+ * @deprecated Prefer {@link CryptoApi.userHasCrossSigningKeys | `CryptoApi.userHasCrossSigningKeys`}:
+ *
+ * ```javascript
+ * result = client.getCrypto().userHasCrossSigningKeys();
+ * ```
+ */
+ public userHasCrossSigningKeys(): Promise<boolean> {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.userHasCrossSigningKeys();
+ }
+
+ /**
+ * Checks whether cross signing:
+ * - is enabled on this account and trusted by this device
+ * - has private keys either cached locally or stored in secret storage
+ *
+ * If this function returns false, bootstrapCrossSigning() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapCrossSigning() completes successfully, this function should
+ * return true.
+ * @returns True if cross-signing is ready to be used on this device
+ */
+ public isCrossSigningReady(): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isCrossSigningReady();
+ }
+
+ /**
+ * Bootstrap cross-signing by creating keys if needed. If everything is already
+ * set up, then no changes are made, so this is safe to run to ensure
+ * cross-signing is ready for use.
+ *
+ * This function:
+ * - creates new cross-signing keys if they are not found locally cached nor in
+ * secret storage (if it has been setup)
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ */
+ public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.bootstrapCrossSigning(opts);
+ }
+
+ /**
+ * Whether to trust a others users signatures of their devices.
+ * If false, devices will only be considered 'verified' if we have
+ * verified that device individually (effectively disabling cross-signing).
+ *
+ * Default: true
+ *
+ * @returns True if trusting cross-signed devices
+ */
+ public getCryptoTrustCrossSignedDevices(): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getCryptoTrustCrossSignedDevices();
+ }
+
+ /**
+ * See getCryptoTrustCrossSignedDevices
+ *
+ * @param val - True to trust cross-signed devices
+ */
+ public setCryptoTrustCrossSignedDevices(val: boolean): void {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.crypto.setCryptoTrustCrossSignedDevices(val);
+ }
+
+ /**
+ * Counts the number of end to end session keys that are waiting to be backed up
+ * @returns Promise which resolves to the number of sessions requiring backup
+ */
+ public countSessionsNeedingBackup(): Promise<number> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.countSessionsNeedingBackup();
+ }
+
+ /**
+ * Get information about the encryption of an event
+ *
+ * @param event - event to be checked
+ * @returns The event information.
+ */
+ public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.getEventEncryptionInfo(event);
+ }
+
+ /**
+ * Create a recovery key from a user-supplied passphrase.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param password - Passphrase string that can be entered by the user
+ * when restoring the backup as an alternative to entering the recovery key.
+ * Optional.
+ * @returns Object with public key metadata, encoded private
+ * recovery key which should be disposed of after displaying to the user,
+ * and raw private key to avoid round tripping if needed.
+ */
+ public createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.createRecoveryKeyFromPassphrase(password);
+ }
+
+ /**
+ * Checks whether secret storage:
+ * - is enabled on this account
+ * - is storing cross-signing private keys
+ * - is storing session backup key (if enabled)
+ *
+ * If this function returns false, bootstrapSecretStorage() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapSecretStorage() completes successfully, this function should
+ * return true.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @returns True if secret storage is ready to be used on this device
+ */
+ public isSecretStorageReady(): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isSecretStorageReady();
+ }
+
+ /**
+ * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is
+ * already set up, then no changes are made, so this is safe to run to ensure secret
+ * storage is ready for use.
+ *
+ * This function
+ * - creates a new Secure Secret Storage key if no default key exists
+ * - if a key backup exists, it is migrated to store the key in the Secret
+ * Storage
+ * - creates a backup if none exists, and one is requested
+ * - migrates Secure Secret Storage to use the latest algorithm, if an outdated
+ * algorithm is found
+ *
+ */
+ public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.bootstrapSecretStorage(opts);
+ }
+
+ /**
+ * Add a key for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param algorithm - the algorithm used by the key
+ * @param opts - the options for the algorithm. The properties used
+ * depend on the algorithm given.
+ * @param keyName - the name of the key. If not given, a random name will be generated.
+ *
+ * @returns An object with:
+ * keyId: the ID of the key
+ * keyInfo: details about the key (iv, mac, passphrase)
+ */
+ public addSecretStorageKey(
+ algorithm: string,
+ opts: IAddSecretStorageKeyOpts,
+ keyName?: string,
+ ): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.addSecretStorageKey(algorithm, opts, keyName);
+ }
+
+ /**
+ * Check whether we have a key with a given ID.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param keyId - The ID of the key to check
+ * for. Defaults to the default key ID if not provided.
+ * @returns Whether we have the key.
+ */
+ public hasSecretStorageKey(keyId?: string): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.hasSecretStorageKey(keyId);
+ }
+
+ /**
+ * Store an encrypted secret on the server.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - The name of the secret
+ * @param secret - The secret contents.
+ * @param keys - The IDs of the keys to use to encrypt the secret or null/undefined
+ * to use the default (will throw if no default key is set).
+ */
+ public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.storeSecret(name, secret, keys);
+ }
+
+ /**
+ * Get a secret from storage.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret
+ *
+ * @returns the contents of the secret
+ */
+ public getSecret(name: string): Promise<string | undefined> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getSecret(name);
+ }
+
+ /**
+ * Check if a secret is stored on the server.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret
+ * @returns map of key name to key info the secret is encrypted
+ * with, or null if it is not present or not encrypted with a trusted
+ * key
+ */
+ public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isSecretStored(name);
+ }
+
+ /**
+ * Request a secret from another device.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret to request
+ * @param devices - the devices to request the secret from
+ *
+ * @returns the secret request object
+ */
+ public requestSecret(name: string, devices: string[]): ISecretRequest {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestSecret(name, devices);
+ }
+
+ /**
+ * Get the current default key ID for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @returns The default key ID or null if no default key ID is set
+ */
+ public getDefaultSecretStorageKeyId(): Promise<string | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getDefaultSecretStorageKeyId();
+ }
+
+ /**
+ * Set the current default key ID for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param keyId - The new default key ID
+ */
+ public setDefaultSecretStorageKeyId(keyId: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.setDefaultSecretStorageKeyId(keyId);
+ }
+
+ /**
+ * Checks that a given secret storage private key matches a given public key.
+ * This can be used by the getSecretStorageKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkSecretStoragePrivateKey(privateKey, expectedPublicKey);
+ }
+
+ /**
+ * Get e2e information on the device that sent an event
+ *
+ * @param event - event to be checked
+ */
+ public async getEventSenderDeviceInfo(event: MatrixEvent): Promise<DeviceInfo | null> {
+ if (!this.crypto) {
+ return null;
+ }
+ return this.crypto.getEventSenderDeviceInfo(event);
+ }
+
+ /**
+ * Check if the sender of an event is verified
+ *
+ * @param event - event to be checked
+ *
+ * @returns true if the sender of this event has been verified using
+ * {@link MatrixClient#setDeviceVerified}.
+ */
+ public async isEventSenderVerified(event: MatrixEvent): Promise<boolean> {
+ const device = await this.getEventSenderDeviceInfo(event);
+ if (!device) {
+ return false;
+ }
+ return device.isVerified();
+ }
+
+ /**
+ * Get outgoing room key request for this event if there is one.
+ * @param event - The event to check for
+ *
+ * @returns A room key request, or null if there is none
+ */
+ public getOutgoingRoomKeyRequest(event: MatrixEvent): Promise<OutgoingRoomKeyRequest | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ const wireContent = event.getWireContent();
+ const requestBody: IRoomKeyRequestBody = {
+ session_id: wireContent.session_id,
+ sender_key: wireContent.sender_key,
+ algorithm: wireContent.algorithm,
+ room_id: event.getRoomId()!,
+ };
+ if (!requestBody.session_id || !requestBody.sender_key || !requestBody.algorithm || !requestBody.room_id) {
+ return Promise.resolve(null);
+ }
+ return this.crypto.cryptoStore.getOutgoingRoomKeyRequest(requestBody);
+ }
+
+ /**
+ * Cancel a room key request for this event if one is ongoing and resend the
+ * request.
+ * @param event - event of which to cancel and resend the room
+ * key request.
+ * @returns A promise that will resolve when the key request is queued
+ */
+ public cancelAndResendEventRoomKeyRequest(event: MatrixEvent): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return event.cancelAndResendKeyRequest(this.crypto, this.getUserId()!);
+ }
+
+ /**
+ * Enable end-to-end encryption for a room. This does not modify room state.
+ * Any messages sent before the returned promise resolves will be sent unencrypted.
+ * @param roomId - The room ID to enable encryption in.
+ * @param config - The encryption config for the room.
+ * @returns A promise that will resolve when encryption is set up.
+ */
+ public setRoomEncryption(roomId: string, config: IRoomEncryption): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return this.crypto.setRoomEncryption(roomId, config);
+ }
+
+ /**
+ * Whether encryption is enabled for a room.
+ * @param roomId - the room id to query.
+ * @returns whether encryption is enabled.
+ */
+ public isRoomEncrypted(roomId: string): boolean {
+ const room = this.getRoom(roomId);
+ if (!room) {
+ // we don't know about this room, so can't determine if it should be
+ // encrypted. Let's assume not.
+ return false;
+ }
+
+ // if there is an 'm.room.encryption' event in this room, it should be
+ // encrypted (independently of whether we actually support encryption)
+ const ev = room.currentState.getStateEvents(EventType.RoomEncryption, "");
+ if (ev) {
+ return true;
+ }
+
+ // we don't have an m.room.encrypted event, but that might be because
+ // the server is hiding it from us. Check the store to see if it was
+ // previously encrypted.
+ return this.roomList.isRoomEncrypted(roomId);
+ }
+
+ /**
+ * Encrypts and sends a given object via Olm to-device messages to a given
+ * set of devices.
+ *
+ * @param userDeviceMap - mapping from userId to deviceInfo
+ *
+ * @param payload - fields to include in the encrypted payload
+ *
+ * @returns Promise which
+ * resolves once the message has been encrypted and sent to the given
+ * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }`
+ * of the successfully sent messages.
+ */
+ public encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload);
+ }
+
+ /**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * @param roomId - The ID of the room to discard the session for
+ *
+ * @deprecated Prefer {@link CryptoApi.forceDiscardSession | `CryptoApi.forceDiscardSession`}:
+ *
+ */
+ public forceDiscardSession(roomId: string): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ this.cryptoBackend.forceDiscardSession(roomId);
+ }
+
+ /**
+ * Get a list containing all of the room keys
+ *
+ * This should be encrypted before returning it to the user.
+ *
+ * @returns a promise which resolves to a list of session export objects
+ *
+ * @deprecated Prefer {@link CryptoApi.exportRoomKeys | `CryptoApi.exportRoomKeys`}:
+ *
+ * ```javascript
+ * sessionData = await client.getCrypto().exportRoomKeys();
+ * ```
+ */
+ public exportRoomKeys(): Promise<IMegolmSessionData[]> {
+ if (!this.cryptoBackend) {
+ return Promise.reject(new Error("End-to-end encryption disabled"));
+ }
+ return this.cryptoBackend.exportRoomKeys();
+ }
+
+ /**
+ * Import a list of room keys previously exported by exportRoomKeys
+ *
+ * @param keys - a list of session export objects
+ *
+ * @returns a promise which resolves when the keys have been imported
+ */
+ public importRoomKeys(keys: IMegolmSessionData[], opts?: IImportRoomKeysOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.importRoomKeys(keys, opts);
+ }
+
+ /**
+ * Force a re-check of the local key backup status against
+ * what's on the server.
+ *
+ * @returns Object with backup info (as returned by
+ * getKeyBackupVersion) in backupInfo and
+ * trust information (as returned by isKeyBackupTrusted)
+ * in trustInfo.
+ */
+ public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.checkKeyBackup();
+ }
+
+ /**
+ * Get information about the current key backup.
+ * @returns Information object from API or null
+ */
+ public async getKeyBackupVersion(): Promise<IKeyBackupInfo | null> {
+ let res: IKeyBackupInfo;
+ try {
+ res = await this.http.authedRequest<IKeyBackupInfo>(
+ Method.Get,
+ "/room_keys/version",
+ undefined,
+ undefined,
+ { prefix: ClientPrefix.V3 },
+ );
+ } catch (e) {
+ if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
+ return null;
+ } else {
+ throw e;
+ }
+ }
+ BackupManager.checkBackupVersion(res);
+ return res;
+ }
+
+ /**
+ * @param info - key backup info dict from getKeyBackupVersion()
+ */
+ public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.isKeyBackupTrusted(info);
+ }
+
+ /**
+ * @returns true if the client is configured to back up keys to
+ * the server, otherwise false. If we haven't completed a successful check
+ * of key backup status yet, returns null.
+ */
+ public getKeyBackupEnabled(): boolean | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.getKeyBackupEnabled();
+ }
+
+ /**
+ * Enable backing up of keys, using data previously returned from
+ * getKeyBackupVersion.
+ *
+ * @param info - Backup information object as returned by getKeyBackupVersion
+ * @returns Promise which resolves when complete.
+ */
+ public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ return this.crypto.backupManager.enableKeyBackup(info);
+ }
+
+ /**
+ * Disable backing up of keys.
+ */
+ public disableKeyBackup(): void {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ this.crypto.backupManager.disableKeyBackup();
+ }
+
+ /**
+ * Set up the data required to create a new backup version. The backup version
+ * will not be created and enabled until createKeyBackupVersion is called.
+ *
+ * @param password - Passphrase string that can be entered by the user
+ * when restoring the backup as an alternative to entering the recovery key.
+ * Optional.
+ *
+ * @returns Object that can be passed to createKeyBackupVersion and
+ * additionally has a 'recovery_key' member with the user-facing recovery key string.
+ */
+ public async prepareKeyBackupVersion(
+ password?: string | Uint8Array | null,
+ opts: IKeyBackupPrepareOpts = { secureSecretStorage: false },
+ ): Promise<Pick<IPreparedKeyBackupVersion, "algorithm" | "auth_data" | "recovery_key">> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ // eslint-disable-next-line camelcase
+ const { algorithm, auth_data, recovery_key, privateKey } =
+ await this.crypto.backupManager.prepareKeyBackupVersion(password);
+
+ if (opts.secureSecretStorage) {
+ await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
+ logger.info("Key backup private key stored in secret storage");
+ }
+
+ return {
+ algorithm,
+ /* eslint-disable camelcase */
+ auth_data,
+ recovery_key,
+ /* eslint-enable camelcase */
+ };
+ }
+
+ /**
+ * Check whether the key backup private key is stored in secret storage.
+ * @returns map of key name to key info the secret is
+ * encrypted with, or null if it is not present or not encrypted with a
+ * trusted key
+ */
+ public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
+ }
+
+ /**
+ * Create a new key backup version and enable it, using the information return
+ * from prepareKeyBackupVersion.
+ *
+ * @param info - Info object from prepareKeyBackupVersion
+ * @returns Object with 'version' param indicating the version created
+ */
+ public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<IKeyBackupInfo> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ await this.crypto.backupManager.createKeyBackupVersion(info);
+
+ const data = {
+ algorithm: info.algorithm,
+ auth_data: info.auth_data,
+ };
+
+ // Sign the backup auth data with the device key for backwards compat with
+ // older devices with cross-signing. This can probably go away very soon in
+ // favour of just signing with the cross-singing master key.
+ // XXX: Private member access
+ await this.crypto.signObject(data.auth_data);
+
+ if (
+ this.cryptoCallbacks.getCrossSigningKey &&
+ // XXX: Private member access
+ this.crypto.crossSigningInfo.getId()
+ ) {
+ // now also sign the auth data with the cross-signing master key
+ // we check for the callback explicitly here because we still want to be able
+ // to create an un-cross-signed key backup if there is a cross-signing key but
+ // no callback supplied.
+ // XXX: Private member access
+ await this.crypto.crossSigningInfo.signObject(data.auth_data, "master");
+ }
+
+ const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data, {
+ prefix: ClientPrefix.V3,
+ });
+
+ // We could assume everything's okay and enable directly, but this ensures
+ // we run the same signature verification that will be used for future
+ // sessions.
+ await this.checkKeyBackup();
+ if (!this.getKeyBackupEnabled()) {
+ logger.error("Key backup not usable even though we just created it");
+ }
+
+ return res;
+ }
+
+ public async deleteKeyBackupVersion(version: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ // If we're currently backing up to this backup... stop.
+ // (We start using it automatically in createKeyBackupVersion
+ // so this is symmetrical).
+ if (this.crypto.backupManager.version) {
+ this.crypto.backupManager.disableKeyBackup();
+ }
+
+ const path = utils.encodeUri("/room_keys/version/$version", {
+ $version: version,
+ });
+
+ await this.http.authedRequest(Method.Delete, path, undefined, undefined, { prefix: ClientPrefix.V3 });
+ }
+
+ private makeKeyBackupPath(roomId: undefined, sessionId: undefined, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId: string, sessionId: undefined, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId: string, sessionId: string, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath {
+ let path: string;
+ if (sessionId !== undefined) {
+ path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", {
+ $roomId: roomId!,
+ $sessionId: sessionId,
+ });
+ } else if (roomId !== undefined) {
+ path = utils.encodeUri("/room_keys/keys/$roomId", {
+ $roomId: roomId,
+ });
+ } else {
+ path = "/room_keys/keys";
+ }
+ const queryData = version === undefined ? undefined : { version };
+ return { path, queryData };
+ }
+
+ /**
+ * Back up session keys to the homeserver.
+ * @param roomId - ID of the room that the keys are for Optional.
+ * @param sessionId - ID of the session that the keys are for Optional.
+ * @param version - backup version Optional.
+ * @param data - Object keys to send
+ * @returns a promise that will resolve when the keys
+ * are uploaded
+ */
+ public sendKeyBackup(
+ roomId: undefined,
+ sessionId: undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public sendKeyBackup(
+ roomId: string,
+ sessionId: undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public sendKeyBackup(
+ roomId: string,
+ sessionId: string,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public async sendKeyBackup(
+ roomId: string | undefined,
+ sessionId: string | undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
+ await this.http.authedRequest(Method.Put, path.path, path.queryData, data, { prefix: ClientPrefix.V3 });
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+ public async scheduleAllGroupSessionsForBackup(): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ await this.crypto.backupManager.scheduleAllGroupSessionsForBackup();
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up without scheduling
+ * them to upload in the background.
+ * @returns Promise which resolves to the number of sessions requiring a backup.
+ */
+ public flagAllGroupSessionsForBackup(): Promise<number> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ return this.crypto.backupManager.flagAllGroupSessionsForBackup();
+ }
+
+ public isValidRecoveryKey(recoveryKey: string): boolean {
+ try {
+ decodeRecoveryKey(recoveryKey);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the raw key for a key backup from the password
+ * Used when migrating key backups into SSSS
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param password - Passphrase
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @returns key backup key
+ */
+ public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise<Uint8Array> {
+ return keyFromAuthData(backupInfo.auth_data, password);
+ }
+
+ /**
+ * Get the raw key for a key backup from the recovery key
+ * Used when migrating key backups into SSSS
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param recoveryKey - The recovery key
+ * @returns key backup key
+ */
+ public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array {
+ return decodeRecoveryKey(recoveryKey);
+ }
+
+ /**
+ * Restore from an existing key backup via a passphrase.
+ *
+ * @param password - Passphrase
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param opts - Optional params such as callbacks
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const privKey = await keyFromAuthData(backupInfo.auth_data, password);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ /**
+ * Restore from an existing key backup via a private key stored in secret
+ * storage.
+ *
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param opts - Optional params such as callbacks
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public async restoreKeyBackupWithSecretStorage(
+ backupInfo: IKeyBackupInfo,
+ targetRoomId?: string,
+ targetSessionId?: string,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const storedKey = await this.getSecret("m.megolm_backup.v1");
+
+ // ensure that the key is in the right format. If not, fix the key and
+ // store the fixed version
+ const fixedKey = fixBackupKey(storedKey);
+ if (fixedKey) {
+ const keys = await this.crypto.getSecretStorageKey();
+ await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
+ }
+
+ const privKey = decodeBase64(fixedKey || storedKey!);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ /**
+ * Restore from an existing key backup via an encoded recovery key.
+ *
+ * @param recoveryKey - Encoded recovery key
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param opts - Optional params such as callbacks
+
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const privKey = decodeRecoveryKey(recoveryKey);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ public async restoreKeyBackupWithCache(
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const privKey = await this.crypto.getSessionBackupPrivateKey();
+ if (!privKey) {
+ throw new Error("Couldn't get key");
+ }
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const cacheCompleteCallback = opts?.cacheCompleteCallback;
+ const progressCallback = opts?.progressCallback;
+
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ let totalKeyCount = 0;
+ let keys: IMegolmSessionData[] = [];
+
+ const path = this.makeKeyBackupPath(targetRoomId!, targetSessionId!, backupInfo.version);
+
+ const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => {
+ return privKey;
+ });
+
+ const untrusted = algorithm.untrusted;
+
+ try {
+ // If the pubkey computed from the private data we've been given
+ // doesn't match the one in the auth_data, the user has entered
+ // a different recovery key / the wrong passphrase.
+ if (!(await algorithm.keyMatches(privKey))) {
+ return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
+ }
+
+ // Cache the key, if possible.
+ // This is async.
+ this.crypto
+ .storeSessionBackupPrivateKey(privKey)
+ .catch((e) => {
+ logger.warn("Error caching session backup key:", e);
+ })
+ .then(cacheCompleteCallback);
+
+ if (progressCallback) {
+ progressCallback({
+ stage: "fetch",
+ });
+ }
+
+ const res = await this.http.authedRequest<IRoomsKeysResponse | IRoomKeysResponse | IKeyBackupSession>(
+ Method.Get,
+ path.path,
+ path.queryData,
+ undefined,
+ { prefix: ClientPrefix.V3 },
+ );
+
+ if ((res as IRoomsKeysResponse).rooms) {
+ const rooms = (res as IRoomsKeysResponse).rooms;
+ for (const [roomId, roomData] of Object.entries(rooms)) {
+ if (!roomData.sessions) continue;
+
+ totalKeyCount += Object.keys(roomData.sessions).length;
+ const roomKeys = await algorithm.decryptSessions(roomData.sessions);
+ for (const k of roomKeys) {
+ k.room_id = roomId;
+ keys.push(k);
+ }
+ }
+ } else if ((res as IRoomKeysResponse).sessions) {
+ const sessions = (res as IRoomKeysResponse).sessions;
+ totalKeyCount = Object.keys(sessions).length;
+ keys = await algorithm.decryptSessions(sessions);
+ for (const k of keys) {
+ k.room_id = targetRoomId!;
+ }
+ } else {
+ totalKeyCount = 1;
+ try {
+ const [key] = await algorithm.decryptSessions({
+ [targetSessionId!]: res as IKeyBackupSession,
+ });
+ key.room_id = targetRoomId!;
+ key.session_id = targetSessionId!;
+ keys.push(key);
+ } catch (e) {
+ logger.log("Failed to decrypt megolm session from backup", e);
+ }
+ }
+ } finally {
+ algorithm.free();
+ }
+
+ await this.importRoomKeys(keys, {
+ progressCallback,
+ untrusted,
+ source: "backup",
+ });
+
+ await this.checkKeyBackup();
+
+ return { total: totalKeyCount, imported: keys.length };
+ }
+
+ public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise<void>;
+ public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise<void>;
+ public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise<void>;
+ public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
+ await this.http.authedRequest(Method.Delete, path.path, path.queryData, undefined, { prefix: ClientPrefix.V3 });
+ }
+
+ /**
+ * Share shared-history decryption keys with the given users.
+ *
+ * @param roomId - the room for which keys should be shared.
+ * @param userIds - a list of users to share with. The keys will be sent to
+ * all of the user's current devices.
+ */
+ public async sendSharedHistoryKeys(roomId: string, userIds: string[]): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const roomEncryption = this.roomList.getRoomEncryption(roomId);
+ if (!roomEncryption) {
+ // unknown room, or unencrypted room
+ logger.error("Unknown room. Not sharing decryption keys");
+ return;
+ }
+
+ const deviceInfos = await this.crypto.downloadKeys(userIds);
+ const devicesByUser: Map<string, DeviceInfo[]> = new Map();
+ for (const [userId, devices] of deviceInfos) {
+ devicesByUser.set(userId, Array.from(devices.values()));
+ }
+
+ // XXX: Private member access
+ const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm);
+ if (alg.sendSharedHistoryInboundSessions) {
+ await alg.sendSharedHistoryInboundSessions(devicesByUser);
+ } else {
+ logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
+ }
+ }
+
+ /**
+ * Get the config for the media repository.
+ * @returns Promise which resolves with an object containing the config.
+ */
+ public getMediaConfig(): Promise<IMediaConfig> {
+ return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
+ prefix: MediaPrefix.R0,
+ });
+ }
+
+ /**
+ * Get the room for the given room ID.
+ * This function will return a valid room for any room for which a Room event
+ * has been emitted. Note in particular that other events, eg. RoomState.members
+ * will be emitted for a room before this function will return the given room.
+ * @param roomId - The room ID
+ * @returns The Room or null if it doesn't exist or there is no data store.
+ */
+ public getRoom(roomId: string | undefined): Room | null {
+ if (!roomId) {
+ return null;
+ }
+ return this.store.getRoom(roomId);
+ }
+
+ /**
+ * Retrieve all known rooms.
+ * @returns A list of rooms, or an empty list if there is no data store.
+ */
+ public getRooms(): Room[] {
+ return this.store.getRooms();
+ }
+
+ /**
+ * Retrieve all rooms that should be displayed to the user
+ * This is essentially getRooms() with some rooms filtered out, eg. old versions
+ * of rooms that have been replaced or (in future) other rooms that have been
+ * marked at the protocol level as not to be displayed to the user.
+ *
+ * @param msc3946ProcessDynamicPredecessor - if true, look for an
+ * m.room.predecessor state event and
+ * use it if found (MSC3946).
+ * @returns A list of rooms, or an empty list if there is no data store.
+ */
+ public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
+ const allRooms = this.store.getRooms();
+
+ const replacedRooms = new Set();
+ for (const r of allRooms) {
+ const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ if (predecessor) {
+ replacedRooms.add(predecessor);
+ }
+ }
+
+ return allRooms.filter((r) => {
+ const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, "");
+ if (tombstone && replacedRooms.has(r.roomId)) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ /**
+ * Retrieve a user.
+ * @param userId - The user ID to retrieve.
+ * @returns A user or null if there is no data store or the user does
+ * not exist.
+ */
+ public getUser(userId: string): User | null {
+ return this.store.getUser(userId);
+ }
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, or an empty list if there is no data store.
+ */
+ public getUsers(): User[] {
+ return this.store.getUsers();
+ }
+
+ /**
+ * Set account data event for the current user.
+ * It will retry the request up to 5 times.
+ * @param eventType - The event type
+ * @param content - the contents object for the event
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setAccountData(eventType: EventType | string, content: IContent): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $type: eventType,
+ });
+ return retryNetworkOperation(5, () => {
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ });
+ }
+
+ /**
+ * Get account data event of given type for the current user.
+ * @param eventType - The event type
+ * @returns The contents of the given account data event
+ */
+ public getAccountData(eventType: string): MatrixEvent | undefined {
+ return this.store.getAccountData(eventType);
+ }
+
+ /**
+ * Get account data event of given type for the current user. This variant
+ * gets account data directly from the homeserver if the local store is not
+ * ready, which can be useful very early in startup before the initial sync.
+ * @param eventType - The event type
+ * @returns Promise which resolves: The contents of the given account data event.
+ * @returns Rejects: with an error response.
+ */
+ public async getAccountDataFromServer<T extends { [k: string]: any }>(eventType: string): Promise<T | null> {
+ if (this.isInitialSyncComplete()) {
+ const event = this.store.getAccountData(eventType);
+ if (!event) {
+ return null;
+ }
+ // The network version below returns just the content, so this branch
+ // does the same to match.
+ return event.getContent<T>();
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $type: eventType,
+ });
+ try {
+ return await this.http.authedRequest(Method.Get, path);
+ } catch (e) {
+ if ((<MatrixError>e).data?.errcode === "M_NOT_FOUND") {
+ return null;
+ }
+ throw e;
+ }
+ }
+
+ public async deleteAccountData(eventType: string): Promise<void> {
+ const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
+ // if deletion is not supported overwrite with empty content
+ if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
+ await this.setAccountData(eventType, {});
+ return;
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.getSafeUserId(),
+ $type: eventType,
+ });
+ const options =
+ msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable
+ ? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" }
+ : undefined;
+ return await this.http.authedRequest(Method.Delete, path, undefined, undefined, options);
+ }
+
+ /**
+ * Gets the users that are ignored by this client
+ * @returns The array of users that are ignored (empty if none)
+ */
+ public getIgnoredUsers(): string[] {
+ const event = this.getAccountData("m.ignored_user_list");
+ if (!event || !event.getContent() || !event.getContent()["ignored_users"]) return [];
+ return Object.keys(event.getContent()["ignored_users"]);
+ }
+
+ /**
+ * Sets the users that the current user should ignore.
+ * @param userIds - the user IDs to ignore
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setIgnoredUsers(userIds: string[]): Promise<{}> {
+ const content = { ignored_users: {} as Record<string, object> };
+ userIds.forEach((u) => {
+ content.ignored_users[u] = {};
+ });
+ return this.setAccountData("m.ignored_user_list", content);
+ }
+
+ /**
+ * Gets whether or not a specific user is being ignored by this client.
+ * @param userId - the user ID to check
+ * @returns true if the user is ignored, false otherwise
+ */
+ public isUserIgnored(userId: string): boolean {
+ return this.getIgnoredUsers().includes(userId);
+ }
+
+ /**
+ * Join a room. If you have already joined the room, this will no-op.
+ * @param roomIdOrAlias - The room ID or room alias to join.
+ * @param opts - Options when joining the room.
+ * @returns Promise which resolves: Room object.
+ * @returns Rejects: with an error response.
+ */
+ public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
+ if (opts.syncRoom === undefined) {
+ opts.syncRoom = true;
+ }
+
+ const room = this.getRoom(roomIdOrAlias);
+ if (room?.hasMembershipState(this.credentials.userId!, "join")) {
+ return Promise.resolve(room);
+ }
+
+ let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
+
+ if (opts.inviteSignUrl) {
+ const url = new URL(opts.inviteSignUrl);
+ url.searchParams.set("mxid", this.credentials.userId!);
+ signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
+ }
+
+ const queryString: Record<string, string | string[]> = {};
+ if (opts.viaServers) {
+ queryString["server_name"] = opts.viaServers;
+ }
+
+ try {
+ const data: IJoinRequestBody = {};
+ const signedInviteObj = await signPromise;
+ if (signedInviteObj) {
+ data.third_party_signed = signedInviteObj;
+ }
+
+ const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
+ const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
+
+ const roomId = res.room_id;
+ const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ const room = syncApi.createRoom(roomId);
+ if (opts.syncRoom) {
+ // v2 will do this for us
+ // return syncApi.syncRoom(room);
+ }
+ return room;
+ } catch (e) {
+ throw e; // rethrow for reject
+ }
+ }
+
+ /**
+ * Resend an event. Will also retry any to-device messages waiting to be sent.
+ * @param event - The event to resend.
+ * @param room - Optional. The room the event is in. Will update the
+ * timeline entry if provided.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
+ // also kick the to-device queue to retry
+ this.toDeviceMessageQueue.sendQueue();
+
+ this.updatePendingEventStatus(room, event, EventStatus.SENDING);
+ return this.encryptAndSendEvent(room, event);
+ }
+
+ /**
+ * Cancel a queued or unsent event.
+ *
+ * @param event - Event to cancel
+ * @throws Error if the event is not in QUEUED, NOT_SENT or ENCRYPTING state
+ */
+ public cancelPendingEvent(event: MatrixEvent): void {
+ if (![EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.ENCRYPTING].includes(event.status!)) {
+ throw new Error("cannot cancel an event with status " + event.status);
+ }
+
+ // if the event is currently being encrypted then
+ if (event.status === EventStatus.ENCRYPTING) {
+ this.pendingEventEncryption.delete(event.getId()!);
+ } else if (this.scheduler && event.status === EventStatus.QUEUED) {
+ // tell the scheduler to forget about it, if it's queued
+ this.scheduler.removeEventFromQueue(event);
+ }
+
+ // then tell the room about the change of state, which will remove it
+ // from the room's list of pending events.
+ const room = this.getRoom(event.getRoomId());
+ this.updatePendingEventStatus(room, event, EventStatus.CANCELLED);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public setRoomName(roomId: string, name: string): Promise<ISendEventResponse> {
+ return this.sendStateEvent(roomId, EventType.RoomName, { name: name });
+ }
+
+ /**
+ * @param htmlTopic - Optional.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public setRoomTopic(roomId: string, topic: string, htmlTopic?: string): Promise<ISendEventResponse> {
+ const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
+ return this.sendStateEvent(roomId, EventType.RoomTopic, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to an object keyed by tagId with objects containing a numeric order field.
+ * @returns Rejects: with an error response.
+ */
+ public getRoomTags(roomId: string): Promise<ITagsResponse> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param tagName - name of room tag to be set
+ * @param metadata - associated with that tag to be stored
+ * @returns Promise which resolves: to an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $tag: tagName,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, metadata);
+ }
+
+ /**
+ * @param tagName - name of room tag to be removed
+ * @returns Promise which resolves: to an empty object
+ * @returns Rejects: with an error response.
+ */
+ public deleteRoomTag(roomId: string, tagName: string): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $tag: tagName,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * @param eventType - event type to be set
+ * @param content - event content
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $type: eventType,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ }
+
+ /**
+ * Set a power level to one or multiple users.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public setPowerLevel(
+ roomId: string,
+ userId: string | string[],
+ powerLevel: number | undefined,
+ event: MatrixEvent | null,
+ ): Promise<ISendEventResponse> {
+ let content = {
+ users: {} as Record<string, number>,
+ };
+ if (event?.getType() === EventType.RoomPowerLevels) {
+ // take a copy of the content to ensure we don't corrupt
+ // existing client state with a failed power level change
+ content = utils.deepCopy(event.getContent());
+ }
+
+ const users = Array.isArray(userId) ? userId : [userId];
+ for (const user of users) {
+ if (powerLevel == null) {
+ delete content.users[user];
+ } else {
+ content.users[user] = powerLevel;
+ }
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ }
+
+ /**
+ * Create an m.beacon_info event
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public async unstable_createLiveBeacon(
+ roomId: Room["roomId"],
+ beaconInfoContent: MBeaconInfoEventContent,
+ ): Promise<ISendEventResponse> {
+ return this.unstable_setLiveBeacon(roomId, beaconInfoContent);
+ }
+
+ /**
+ * Upsert a live beacon event
+ * using a specific m.beacon_info.* event variable type
+ * @param roomId - string
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public async unstable_setLiveBeacon(
+ roomId: string,
+ beaconInfoContent: MBeaconInfoEventContent,
+ ): Promise<ISendEventResponse> {
+ return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
+ }
+
+ public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
+ public sendEvent(
+ roomId: string,
+ threadId: string | null,
+ eventType: string,
+ content: IContent,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendEvent(
+ roomId: string,
+ threadIdOrEventType: string | null,
+ eventTypeOrContent: string | IContent,
+ contentOrTxnId?: IContent | string,
+ txnIdOrVoid?: string,
+ ): Promise<ISendEventResponse> {
+ let threadId: string | null;
+ let eventType: string;
+ let content: IContent;
+ let txnId: string | undefined;
+ if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) {
+ txnId = contentOrTxnId as string;
+ content = eventTypeOrContent as IContent;
+ eventType = threadIdOrEventType;
+ threadId = null;
+ } else {
+ txnId = txnIdOrVoid;
+ content = contentOrTxnId as IContent;
+ eventType = eventTypeOrContent as string;
+ threadId = threadIdOrEventType;
+ }
+
+ // If we expect that an event is part of a thread but is missing the relation
+ // we need to add it manually, as well as the reply fallback
+ if (threadId && !content!["m.relates_to"]?.rel_type) {
+ const isReply = !!content!["m.relates_to"]?.["m.in_reply_to"];
+ content!["m.relates_to"] = {
+ ...content!["m.relates_to"],
+ rel_type: THREAD_RELATION_TYPE.name,
+ event_id: threadId,
+ // Set is_falling_back to true unless this is actually intended to be a reply
+ is_falling_back: !isReply,
+ };
+ const thread = this.getRoom(roomId)?.getThread(threadId);
+ if (thread && !isReply) {
+ content!["m.relates_to"]["m.in_reply_to"] = {
+ event_id:
+ thread
+ .lastReply((ev: MatrixEvent) => {
+ return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
+ })
+ ?.getId() ?? threadId,
+ };
+ }
+ }
+
+ return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, txnId);
+ }
+
+ /**
+ * @param eventObject - An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added.
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ private sendCompleteEvent(
+ roomId: string,
+ threadId: string | null,
+ eventObject: any,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!txnId) {
+ txnId = this.makeTxnId();
+ }
+
+ // We always construct a MatrixEvent when sending because the store and scheduler use them.
+ // We'll extract the params back out if it turns out the client has no scheduler or store.
+ const localEvent = new MatrixEvent(
+ Object.assign(eventObject, {
+ event_id: "~" + roomId + ":" + txnId,
+ user_id: this.credentials.userId,
+ sender: this.credentials.userId,
+ room_id: roomId,
+ origin_server_ts: new Date().getTime(),
+ }),
+ );
+
+ const room = this.getRoom(roomId);
+ const thread = threadId ? room?.getThread(threadId) : undefined;
+ if (thread) {
+ localEvent.setThread(thread);
+ }
+
+ // set up re-emitter for this new event - this is normally the job of EventMapper but we don't use it here
+ this.reEmitter.reEmit(localEvent, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]);
+ room?.reEmitter.reEmit(localEvent, [MatrixEventEvent.BeforeRedaction]);
+
+ // if this is a relation or redaction of an event
+ // that hasn't been sent yet (e.g. with a local id starting with a ~)
+ // then listen for the remote echo of that event so that by the time
+ // this event does get sent, we have the correct event_id
+ const targetId = localEvent.getAssociatedId();
+ if (targetId?.startsWith("~")) {
+ const target = room?.getPendingEvents().find((e) => e.getId() === targetId);
+ target?.once(MatrixEventEvent.LocalEventIdReplaced, () => {
+ localEvent.updateAssociatedId(target.getId()!);
+ });
+ }
+
+ const type = localEvent.getType();
+ logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
+
+ localEvent.setTxnId(txnId);
+ localEvent.setStatus(EventStatus.SENDING);
+
+ // add this event immediately to the local store as 'sending'.
+ room?.addPendingEvent(localEvent, txnId);
+
+ // addPendingEvent can change the state to NOT_SENT if it believes
+ // that there's other events that have failed. We won't bother to
+ // try sending the event if the state has changed as such.
+ if (localEvent.status === EventStatus.NOT_SENT) {
+ return Promise.reject(new Error("Event blocked by other events not yet sent"));
+ }
+
+ return this.encryptAndSendEvent(room, localEvent);
+ }
+
+ /**
+ * encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
+ * @returns returns a promise which resolves with the result of the send request
+ */
+ protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
+ let cancelled = false;
+ // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
+ // so that we can handle synchronous and asynchronous exceptions with the
+ // same code path.
+ return Promise.resolve()
+ .then(() => {
+ const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined);
+ if (!encryptionPromise) return null; // doesn't need encryption
+
+ this.pendingEventEncryption.set(event.getId()!, encryptionPromise);
+ this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
+ return encryptionPromise.then(() => {
+ if (!this.pendingEventEncryption.has(event.getId()!)) {
+ // cancelled via MatrixClient::cancelPendingEvent
+ cancelled = true;
+ return;
+ }
+ this.updatePendingEventStatus(room, event, EventStatus.SENDING);
+ });
+ })
+ .then(() => {
+ if (cancelled) return {} as ISendEventResponse;
+ let promise: Promise<ISendEventResponse> | null = null;
+ if (this.scheduler) {
+ // if this returns a promise then the scheduler has control now and will
+ // resolve/reject when it is done. Internally, the scheduler will invoke
+ // processFn which is set to this._sendEventHttpRequest so the same code
+ // path is executed regardless.
+ promise = this.scheduler.queueEvent(event);
+ if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
+ // event is processed FIFO so if the length is 2 or more we know
+ // this event is stuck behind an earlier event.
+ this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
+ }
+ }
+
+ if (!promise) {
+ promise = this.sendEventHttpRequest(event);
+ if (room) {
+ promise = promise.then((res) => {
+ room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
+ return res;
+ });
+ }
+ }
+
+ return promise;
+ })
+ .catch((err) => {
+ logger.error("Error sending event", err.stack || err);
+ try {
+ // set the error on the event before we update the status:
+ // updating the status emits the event, so the state should be
+ // consistent at that point.
+ event.error = err;
+ this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
+ } catch (e) {
+ logger.error("Exception in error handler!", (<Error>e).stack || err);
+ }
+ if (err instanceof MatrixError) {
+ err.event = event;
+ }
+ throw err;
+ });
+ }
+
+ private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> | null {
+ if (event.isEncrypted()) {
+ // this event has already been encrypted; this happens if the
+ // encryption step succeeded, but the send step failed on the first
+ // attempt.
+ return null;
+ }
+
+ if (event.isRedaction()) {
+ // Redactions do not support encryption in the spec at this time,
+ // whilst it mostly worked in some clients, it wasn't compliant.
+ return null;
+ }
+
+ if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
+ return null;
+ }
+
+ if (!this.cryptoBackend && this.usingExternalCrypto) {
+ // The client has opted to allow sending messages to encrypted
+ // rooms even if the room is encrypted, and we haven't setup
+ // crypto. This is useful for users of matrix-org/pantalaimon
+ return null;
+ }
+
+ if (event.getType() === EventType.Reaction) {
+ // For reactions, there is a very little gained by encrypting the entire
+ // event, as relation data is already kept in the clear. Event
+ // encryption for a reaction effectively only obscures the event type,
+ // but the purpose is still obvious from the relation data, so nothing
+ // is really gained. It also causes quite a few problems, such as:
+ // * triggers notifications via default push rules
+ // * prevents server-side bundling for reactions
+ // The reaction key / content / emoji value does warrant encrypting, but
+ // this will be handled separately by encrypting just this value.
+ // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642
+ return null;
+ }
+
+ if (!this.cryptoBackend) {
+ throw new Error("This room is configured to use encryption, but your client does not support encryption.");
+ }
+
+ return this.cryptoBackend.encryptEvent(event, room);
+ }
+
+ /**
+ * Returns the eventType that should be used taking encryption into account
+ * for a given eventType.
+ * @param roomId - the room for the events `eventType` relates to
+ * @param eventType - the event type
+ * @returns the event type taking encryption into account
+ */
+ private getEncryptedIfNeededEventType(
+ roomId: string,
+ eventType?: EventType | string | null,
+ ): EventType | string | null | undefined {
+ if (eventType === EventType.Reaction) return eventType;
+ return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType;
+ }
+
+ protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus): void {
+ if (room) {
+ room.updatePendingEvent(event, newStatus);
+ } else {
+ event.setStatus(newStatus);
+ }
+ }
+
+ private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse> {
+ let txnId = event.getTxnId();
+ if (!txnId) {
+ txnId = this.makeTxnId();
+ event.setTxnId(txnId);
+ }
+
+ const pathParams = {
+ $roomId: event.getRoomId()!,
+ $eventType: event.getWireType(),
+ $stateKey: event.getStateKey()!,
+ $txnId: txnId,
+ };
+
+ let path: string;
+
+ if (event.isState()) {
+ let pathTemplate = "/rooms/$roomId/state/$eventType";
+ if (event.getStateKey() && event.getStateKey()!.length > 0) {
+ pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
+ }
+ path = utils.encodeUri(pathTemplate, pathParams);
+ } else if (event.isRedaction()) {
+ const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
+ path = utils.encodeUri(pathTemplate, {
+ $redactsEventId: event.event.redacts!,
+ ...pathParams,
+ });
+ } else {
+ path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
+ }
+
+ return this.http
+ .authedRequest<ISendEventResponse>(Method.Put, path, undefined, event.getWireContent())
+ .then((res) => {
+ logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
+ return res;
+ });
+ }
+
+ /**
+ * @param txnId - transaction id. One will be made up if not supplied.
+ * @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ * @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
+ * Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
+ */
+ public redactEvent(
+ roomId: string,
+ eventId: string,
+ txnId?: string | undefined,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse>;
+ public redactEvent(
+ roomId: string,
+ threadId: string | null,
+ eventId: string,
+ txnId?: string | undefined,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse>;
+ public redactEvent(
+ roomId: string,
+ threadId: string | null,
+ eventId?: string,
+ txnId?: string | IRedactOpts,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse> {
+ if (!eventId?.startsWith(EVENT_ID_PREFIX)) {
+ opts = txnId as IRedactOpts;
+ txnId = eventId;
+ eventId = threadId!;
+ threadId = null;
+ }
+ const reason = opts?.reason;
+
+ if (
+ opts?.with_relations &&
+ this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
+ ) {
+ throw new Error(
+ "Server does not support relation based redactions " +
+ `roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
+ );
+ }
+
+ const withRelations = opts?.with_relations
+ ? {
+ [this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
+ ? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
+ : MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
+ }
+ : {};
+
+ return this.sendCompleteEvent(
+ roomId,
+ threadId,
+ {
+ type: EventType.RoomRedaction,
+ content: {
+ ...withRelations,
+ reason,
+ },
+ redacts: eventId,
+ },
+ txnId as string,
+ );
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendMessage(roomId: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
+ public sendMessage(
+ roomId: string,
+ threadId: string | null,
+ content: IContent,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendMessage(
+ roomId: string,
+ threadId: string | null | IContent,
+ content?: IContent | string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (typeof threadId !== "string" && threadId !== null) {
+ txnId = content as string;
+ content = threadId as IContent;
+ threadId = null;
+ }
+
+ const eventType: string = EventType.RoomMessage;
+ const sendContent: IContent = content as IContent;
+
+ return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns
+ * @returns Rejects: with an error response.
+ */
+ public sendTextMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendTextMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendTextMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeTextMessage(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendNotice(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeNotice(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendEmoteMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendEmoteMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeEmoteMessage(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise<ISendEventResponse>;
+ public sendImageMessage(
+ roomId: string,
+ threadId: string | null,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendImageMessage(
+ roomId: string,
+ threadId: string | null,
+ url?: string | IImageInfo,
+ info?: IImageInfo | string,
+ text = "Image",
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = (info as string) || "Image";
+ info = url as IImageInfo;
+ url = threadId as string;
+ threadId = null;
+ }
+ const content = {
+ msgtype: MsgType.Image,
+ url: url,
+ info: info,
+ body: text,
+ };
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendStickerMessage(
+ roomId: string,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendStickerMessage(
+ roomId: string,
+ threadId: string | null,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendStickerMessage(
+ roomId: string,
+ threadId: string | null,
+ url?: string | IImageInfo,
+ info?: IImageInfo | string,
+ text = "Sticker",
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = (info as string) || "Sticker";
+ info = url as IImageInfo;
+ url = threadId as string;
+ threadId = null;
+ }
+ const content = {
+ url: url,
+ info: info,
+ body: text,
+ };
+
+ return this.sendEvent(roomId, threadId, EventType.Sticker, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlMessage(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlNotice(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlEmote(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlEmote(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlEmote(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * Send a receipt.
+ * @param event - The event being acknowledged
+ * @param receiptType - The kind of receipt e.g. "m.read". Other than
+ * ReceiptType.Read are experimental!
+ * @param body - Additional content to send alongside the receipt.
+ * @param unthreaded - An unthreaded receipt will clear room+thread notifications
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> {
+ if (this.isGuest()) {
+ return Promise.resolve({}); // guests cannot send receipts so don't bother.
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
+ $roomId: event.getRoomId()!,
+ $receiptType: receiptType,
+ $eventId: event.getId()!,
+ });
+
+ if (!unthreaded) {
+ const isThread = !!event.threadRootId;
+ body = {
+ ...body,
+ thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE,
+ };
+ }
+
+ const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
+
+ const room = this.getRoom(event.getRoomId());
+ if (room && this.credentials.userId) {
+ room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
+ }
+ return promise;
+ }
+
+ /**
+ * Send a read receipt.
+ * @param event - The event that has been read.
+ * @param receiptType - other than ReceiptType.Read are experimental! Optional.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async sendReadReceipt(
+ event: MatrixEvent | null,
+ receiptType = ReceiptType.Read,
+ unthreaded = false,
+ ): Promise<{} | undefined> {
+ if (!event) return;
+ const eventId = event.getId()!;
+ const room = this.getRoom(event.getRoomId());
+ if (room?.hasPendingEvent(eventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
+ }
+
+ return this.sendReceipt(event, receiptType, {}, unthreaded);
+ }
+
+ /**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param roomId - ID of the room that has been read
+ * @param rmEventId - ID of the event that has been read
+ * @param rrEvent - the event tracked by the read receipt. This is here for
+ * convenience because the RR and the RM are commonly updated at the same time as each
+ * other. The local echo of this receipt will be done if set. Optional.
+ * @param rpEvent - the m.read.private read receipt event for when we don't
+ * want other users to see the read receipts. This is experimental. Optional.
+ * @returns Promise which resolves: the empty object, `{}`.
+ */
+ public async setRoomReadMarkers(
+ roomId: string,
+ rmEventId: string,
+ rrEvent?: MatrixEvent,
+ rpEvent?: MatrixEvent,
+ ): Promise<{}> {
+ const room = this.getRoom(roomId);
+ if (room && room.hasPendingEvent(rmEventId)) {
+ throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
+ }
+
+ // Add the optional RR update, do local echo like `sendReceipt`
+ let rrEventId: string | undefined;
+ if (rrEvent) {
+ rrEventId = rrEvent.getId()!;
+ if (room?.hasPendingEvent(rrEventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
+ }
+ room?.addLocalEchoReceipt(this.credentials.userId!, rrEvent, ReceiptType.Read);
+ }
+
+ // Add the optional private RR update, do local echo like `sendReceipt`
+ let rpEventId: string | undefined;
+ if (rpEvent) {
+ rpEventId = rpEvent.getId()!;
+ if (room?.hasPendingEvent(rpEventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${rpEventId})`);
+ }
+ room?.addLocalEchoReceipt(this.credentials.userId!, rpEvent, ReceiptType.ReadPrivate);
+ }
+
+ return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
+ }
+
+ /**
+ * Get a preview of the given URL as of (roughly) the given point in time,
+ * described as an object with OpenGraph keys and associated values.
+ * Attributes may be synthesized where actual OG metadata is lacking.
+ * Caches results to prevent hammering the server.
+ * @param url - The URL to get preview data for
+ * @param ts - The preferred point in time that the preview should
+ * describe (ms since epoch). The preview returned will either be the most
+ * recent one preceding this timestamp if available, or failing that the next
+ * most recent available preview.
+ * @returns Promise which resolves: Object of OG metadata.
+ * @returns Rejects: with an error response.
+ * May return synthesized attributes if the URL lacked OG meta.
+ */
+ public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
+ // bucket the timestamp to the nearest minute to prevent excessive spam to the server
+ // Surely 60-second accuracy is enough for anyone.
+ ts = Math.floor(ts / 60000) * 60000;
+
+ const parsed = new URL(url);
+ parsed.hash = ""; // strip the hash as it won't affect the preview
+ url = parsed.toString();
+
+ const key = ts + "_" + url;
+
+ // If there's already a request in flight (or we've handled it), return that instead.
+ const cachedPreview = this.urlPreviewCache[key];
+ if (cachedPreview) {
+ return cachedPreview;
+ }
+
+ const resp = this.http.authedRequest<IPreviewUrlResponse>(
+ Method.Get,
+ "/preview_url",
+ {
+ url,
+ ts: ts.toString(),
+ },
+ undefined,
+ {
+ prefix: MediaPrefix.R0,
+ },
+ );
+ // TODO: Expire the URL preview cache sometimes
+ this.urlPreviewCache[key] = resp;
+ return resp;
+ }
+
+ /**
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> {
+ if (this.isGuest()) {
+ return Promise.resolve({}); // guests cannot send typing notifications so don't bother.
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/typing/$userId", {
+ $roomId: roomId,
+ $userId: this.getUserId()!,
+ });
+ const data: any = {
+ typing: isTyping,
+ };
+ if (isTyping) {
+ data.timeout = timeoutMs ? timeoutMs : 20000;
+ }
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * Determines the history of room upgrades for a given room, as far as the
+ * client can see. Returns an array of Rooms where the first entry is the
+ * oldest and the last entry is the newest (likely current) room. If the
+ * provided room is not found, this returns an empty list. This works in
+ * both directions, looking for older and newer rooms of the given room.
+ * @param roomId - The room ID to search from
+ * @param verifyLinks - If true, the function will only return rooms
+ * which can be proven to be linked. For example, rooms which have a create
+ * event pointing to an old room which the client is not aware of or doesn't
+ * have a matching tombstone would not be returned.
+ * @param msc3946ProcessDynamicPredecessor - if true, look for
+ * m.room.predecessor state events as well as create events, and prefer
+ * predecessor events where they exist (MSC3946).
+ * @returns An array of rooms representing the upgrade
+ * history.
+ */
+ public getRoomUpgradeHistory(
+ roomId: string,
+ verifyLinks = false,
+ msc3946ProcessDynamicPredecessor = false,
+ ): Room[] {
+ const currentRoom = this.getRoom(roomId);
+ if (!currentRoom) return [];
+
+ const before = this.findPredecessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
+ const after = this.findSuccessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
+
+ return [...before, currentRoom, ...after];
+ }
+
+ private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
+ const ret: Room[] = [];
+
+ // Work backwards from newer to older rooms
+ let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ while (predecessorRoomId !== null) {
+ const predecessorRoom = this.getRoom(predecessorRoomId);
+ if (predecessorRoom === null) {
+ break;
+ }
+ if (verifyLinks) {
+ const tombstone = predecessorRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
+ if (!tombstone || tombstone.getContent()["replacement_room"] !== room.roomId) {
+ break;
+ }
+ }
+
+ // Insert at the front because we're working backwards from the currentRoom
+ ret.splice(0, 0, predecessorRoom);
+
+ room = predecessorRoom;
+ predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ }
+ return ret;
+ }
+
+ private findSuccessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
+ const ret: Room[] = [];
+
+ // Work forwards, looking at tombstone events
+ let tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
+ while (tombstoneEvent) {
+ const successorRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
+ if (!successorRoom) break; // end of the chain
+ if (successorRoom.roomId === room.roomId) break; // Tombstone is referencing its own room
+
+ if (verifyLinks) {
+ const predecessorRoomId = successorRoom.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ if (!predecessorRoomId || predecessorRoomId !== room.roomId) {
+ break;
+ }
+ }
+
+ // Push to the end because we're looking forwards
+ ret.push(successorRoom);
+ const roomIds = new Set(ret.map((ref) => ref.roomId));
+ if (roomIds.size < ret.length) {
+ // The last room added to the list introduced a previous roomId
+ // To avoid recursion, return the last rooms - 1
+ return ret.slice(0, ret.length - 1);
+ }
+
+ // Set the current room to the reference room so we know where we're at
+ room = successorRoom;
+ tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
+ }
+ return ret;
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public invite(roomId: string, userId: string, reason?: string): Promise<{}> {
+ return this.membershipChange(roomId, userId, "invite", reason);
+ }
+
+ /**
+ * Invite a user to a room based on their email address.
+ * @param roomId - The room to invite the user to.
+ * @param email - The email address to invite.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public inviteByEmail(roomId: string, email: string): Promise<{}> {
+ return this.inviteByThreePid(roomId, "email", email);
+ }
+
+ /**
+ * Invite a user to a room based on a third-party identifier.
+ * @param roomId - The room to invite the user to.
+ * @param medium - The medium to invite the user e.g. "email".
+ * @param address - The address for the specified medium.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId });
+
+ const identityServerUrl = this.getIdentityServerUrl(true);
+ if (!identityServerUrl) {
+ return Promise.reject(
+ new MatrixError({
+ error: "No supplied identity server URL",
+ errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM",
+ }),
+ );
+ }
+ const params: Record<string, string> = {
+ id_server: identityServerUrl,
+ medium: medium,
+ address: address,
+ };
+
+ if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ params["id_access_token"] = identityAccessToken;
+ }
+ }
+
+ return this.http.authedRequest(Method.Post, path, undefined, params);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public leave(roomId: string): Promise<{}> {
+ return this.membershipChange(roomId, undefined, "leave");
+ }
+
+ /**
+ * Leaves all rooms in the chain of room upgrades based on the given room. By
+ * default, this will leave all the previous and upgraded rooms, including the
+ * given room. To only leave the given room and any previous rooms, keeping the
+ * upgraded (modern) rooms untouched supply `false` to `includeFuture`.
+ * @param roomId - The room ID to start leaving at
+ * @param includeFuture - If true, the whole chain (past and future) of
+ * upgraded rooms will be left.
+ * @returns Promise which resolves when completed with an object keyed
+ * by room ID and value of the error encountered when leaving or null.
+ */
+ public leaveRoomChain(
+ roomId: string,
+ includeFuture = true,
+ ): Promise<{ [roomId: string]: Error | MatrixError | null }> {
+ const upgradeHistory = this.getRoomUpgradeHistory(roomId);
+
+ let eligibleToLeave = upgradeHistory;
+ if (!includeFuture) {
+ eligibleToLeave = [];
+ for (const room of upgradeHistory) {
+ eligibleToLeave.push(room);
+ if (room.roomId === roomId) {
+ break;
+ }
+ }
+ }
+
+ const populationResults: { [roomId: string]: Error } = {};
+ const promises: Promise<any>[] = [];
+
+ const doLeave = (roomId: string): Promise<void> => {
+ return this.leave(roomId)
+ .then(() => {
+ delete populationResults[roomId];
+ })
+ .catch((err) => {
+ // suppress error
+ populationResults[roomId] = err;
+ });
+ };
+
+ for (const room of eligibleToLeave) {
+ promises.push(doLeave(room.roomId));
+ }
+
+ return Promise.all(promises).then(() => populationResults);
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public ban(roomId: string, userId: string, reason?: string): Promise<{}> {
+ return this.membershipChange(roomId, userId, "ban", reason);
+ }
+
+ /**
+ * @param deleteRoom - True to delete the room from the store on success.
+ * Default: true.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public forget(roomId: string, deleteRoom = true): Promise<{}> {
+ const promise = this.membershipChange(roomId, undefined, "forget");
+ if (!deleteRoom) {
+ return promise;
+ }
+ return promise.then((response) => {
+ this.store.removeRoom(roomId);
+ this.emit(ClientEvent.DeleteRoom, roomId);
+ return response;
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: Object (currently empty)
+ * @returns Rejects: with an error response.
+ */
+ public unban(roomId: string, userId: string): Promise<{}> {
+ // unbanning != set their state to leave: this used to be
+ // the case, but was then changed so that leaving was always
+ // a revoking of privilege, otherwise two people racing to
+ // kick / ban someone could end up banning and then un-banning
+ // them.
+ const path = utils.encodeUri("/rooms/$roomId/unban", {
+ $roomId: roomId,
+ });
+ const data = {
+ user_id: userId,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public kick(roomId: string, userId: string, reason?: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/kick", {
+ $roomId: roomId,
+ });
+ const data = {
+ user_id: userId,
+ reason: reason,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ private membershipChange(
+ roomId: string,
+ userId: string | undefined,
+ membership: string,
+ reason?: string,
+ ): Promise<{}> {
+ // API returns an empty object
+ const path = utils.encodeUri("/rooms/$room_id/$membership", {
+ $room_id: roomId,
+ $membership: membership,
+ });
+ return this.http.authedRequest(Method.Post, path, undefined, {
+ user_id: userId, // may be undefined e.g. on leave
+ reason: reason,
+ });
+ }
+
+ /**
+ * Obtain a dict of actions which should be performed for this event according
+ * to the push rules for this user. Caches the dict on the event.
+ * @param event - The event to get push actions for.
+ * @param forceRecalculate - forces to recalculate actions for an event
+ * Useful when an event just got decrypted
+ * @returns A dict of actions to perform.
+ */
+ public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null {
+ if (!event.getPushActions() || forceRecalculate) {
+ event.setPushActions(this.pushProcessor.actionsForEvent(event));
+ }
+ return event.getPushActions();
+ }
+
+ /**
+ * @param info - The kind of info to set (e.g. 'avatar_url')
+ * @param data - The JSON object to set.
+ * @returns
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<{}>;
+ public setProfileInfo(info: "displayname", data: { displayname: string }): Promise<{}>;
+ public setProfileInfo(info: "avatar_url" | "displayname", data: object): Promise<{}> {
+ const path = utils.encodeUri("/profile/$userId/$info", {
+ $userId: this.credentials.userId!,
+ $info: info,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async setDisplayName(name: string): Promise<{}> {
+ const prom = await this.setProfileInfo("displayname", { displayname: name });
+ // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
+ const user = this.getUser(this.getUserId()!);
+ if (user) {
+ user.displayName = name;
+ user.emit(UserEvent.DisplayName, user.events.presence, user);
+ }
+ return prom;
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async setAvatarUrl(url: string): Promise<{}> {
+ const prom = await this.setProfileInfo("avatar_url", { avatar_url: url });
+ // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
+ const user = this.getUser(this.getUserId()!);
+ if (user) {
+ user.avatarUrl = url;
+ user.emit(UserEvent.AvatarUrl, user.events.presence, user);
+ }
+ return prom;
+ }
+
+ /**
+ * Turn an MXC URL into an HTTP one. <strong>This method is experimental and
+ * may change.</strong>
+ * @param mxcUrl - The MXC URL
+ * @param width - The desired width of the thumbnail.
+ * @param height - The desired height of the thumbnail.
+ * @param resizeMethod - The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param allowDirectLinks - If true, return any non-mxc URLs
+ * directly. Fetching such URLs will leak information about the user to
+ * anyone they share a room with. If false, will return null for such URLs.
+ * @returns the avatar URL or null.
+ */
+ public mxcUrlToHttp(
+ mxcUrl: string,
+ width?: number,
+ height?: number,
+ resizeMethod?: string,
+ allowDirectLinks?: boolean,
+ ): string | null {
+ return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
+ }
+
+ /**
+ * @param opts - Options to apply
+ * @returns Promise which resolves
+ * @returns Rejects: with an error response.
+ * @throws If 'presence' isn't a valid presence enum value.
+ */
+ public async setPresence(opts: IPresenceOpts): Promise<void> {
+ const path = utils.encodeUri("/presence/$userId/status", {
+ $userId: this.credentials.userId!,
+ });
+
+ const validStates = ["offline", "online", "unavailable"];
+ if (validStates.indexOf(opts.presence) === -1) {
+ throw new Error("Bad presence value: " + opts.presence);
+ }
+ await this.http.authedRequest(Method.Put, path, undefined, opts);
+ }
+
+ /**
+ * @param userId - The user to get presence for
+ * @returns Promise which resolves: The presence state for this user.
+ * @returns Rejects: with an error response.
+ */
+ public getPresence(userId: string): Promise<IStatusResponse> {
+ const path = utils.encodeUri("/presence/$userId/status", {
+ $userId: userId,
+ });
+
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Retrieve older messages from the given room and put them in the timeline.
+ *
+ * If this is called multiple times whilst a request is ongoing, the <i>same</i>
+ * Promise will be returned. If there was a problem requesting scrollback, there
+ * will be a small delay before another request can be made (to prevent tight-looping
+ * when there is no connection).
+ *
+ * @param room - The room to get older messages in.
+ * @param limit - Optional. The maximum number of previous events to
+ * pull in. Default: 30.
+ * @returns Promise which resolves: Room. If you are at the beginning
+ * of the timeline, `Room.oldState.paginationToken` will be
+ * `null`.
+ * @returns Rejects: with an error response.
+ */
+ public scrollback(room: Room, limit = 30): Promise<Room> {
+ let timeToWaitMs = 0;
+
+ let info = this.ongoingScrollbacks[room.roomId] || {};
+ if (info.promise) {
+ return info.promise;
+ } else if (info.errorTs) {
+ const timeWaitedMs = Date.now() - info.errorTs;
+ timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0);
+ }
+
+ if (room.oldState.paginationToken === null) {
+ return Promise.resolve(room); // already at the start.
+ }
+ // attempt to grab more events from the store first
+ const numAdded = this.store.scrollback(room, limit).length;
+ if (numAdded === limit) {
+ // store contained everything we needed.
+ return Promise.resolve(room);
+ }
+ // reduce the required number of events appropriately
+ limit = limit - numAdded;
+
+ const promise = new Promise<Room>((resolve, reject) => {
+ // wait for a time before doing this request
+ // (which may be 0 in order not to special case the code paths)
+ sleep(timeToWaitMs)
+ .then(() => {
+ return this.createMessagesRequest(
+ room.roomId,
+ room.oldState.paginationToken,
+ limit,
+ Direction.Backward,
+ );
+ })
+ .then((res: IMessagesResponse) => {
+ const matrixEvents = res.chunk.map(this.getEventMapper());
+ if (res.state) {
+ const stateEvents = res.state.map(this.getEventMapper());
+ room.currentState.setUnknownStateEvents(stateEvents);
+ }
+
+ const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
+
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
+ this.processThreadEvents(room, threadedEvents, true);
+
+ room.oldState.paginationToken = res.end ?? null;
+ if (res.chunk.length === 0) {
+ room.oldState.paginationToken = null;
+ }
+ this.store.storeEvents(room, matrixEvents, res.end ?? null, true);
+ delete this.ongoingScrollbacks[room.roomId];
+ resolve(room);
+ })
+ .catch((err) => {
+ this.ongoingScrollbacks[room.roomId] = {
+ errorTs: Date.now(),
+ };
+ reject(err);
+ });
+ });
+
+ info = { promise };
+
+ this.ongoingScrollbacks[room.roomId] = info;
+ return promise;
+ }
+
+ public getEventMapper(options?: MapperOpts): EventMapper {
+ return eventMapperFor(this, options || {});
+ }
+
+ /**
+ * Get an EventTimeline for the given event
+ *
+ * <p>If the EventTimelineSet object already has the given event in its store, the
+ * corresponding timeline will be returned. Otherwise, a /context request is
+ * made, and used to construct an EventTimeline.
+ * If the event does not belong to this EventTimelineSet then undefined will be returned.
+ *
+ * @param timelineSet - The timelineSet to look for the event in, must be bound to a room
+ * @param eventId - The ID of the event to look for
+ *
+ * @returns Promise which resolves:
+ * {@link EventTimeline} including the given event
+ */
+ public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> {
+ // don't allow any timeline support unless it's been enabled.
+ if (!this.timelineSupport) {
+ throw new Error(
+ "timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable it.",
+ );
+ }
+
+ if (!timelineSet?.room) {
+ throw new Error("getEventTimeline only supports room timelines");
+ }
+
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return timelineSet.getTimelineForEvent(eventId);
+ }
+
+ if (timelineSet.thread && this.supportsThreads()) {
+ return this.getThreadTimeline(timelineSet, eventId);
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
+ $roomId: timelineSet.room.roomId,
+ $eventId: eventId,
+ });
+
+ let params: Record<string, string | string[]> | undefined = undefined;
+ if (this.clientOpts?.lazyLoadMembers) {
+ params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
+ }
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
+ const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
+ if (!res.event) {
+ throw new Error("'event' not in '/context' result - homeserver too old?");
+ }
+
+ // by the time the request completes, the event might have ended up in the timeline.
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return timelineSet.getTimelineForEvent(eventId);
+ }
+
+ const mapper = this.getEventMapper();
+ const event = mapper(res.event);
+ if (event.isRelation(THREAD_RELATION_TYPE.name)) {
+ logger.warn("Tried loading a regular timeline at the position of a thread event");
+ return undefined;
+ }
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...res.events_after.reverse().map(mapper),
+ event,
+ ...res.events_before.map(mapper),
+ ];
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
+ let timeline = timelineSet.getTimelineForEvent(events[0].getId());
+ if (timeline) {
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+ } else {
+ timeline = timelineSet.addTimeline();
+ timeline.initialiseState(res.state.map(mapper));
+ timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
+ }
+
+ const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
+ timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
+ // The target event is not in a thread but process the contextual events, so we can show any threads around it.
+ this.processThreadEvents(timelineSet.room, threadedEvents, true);
+ this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
+
+ // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
+ // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
+ // anywhere, if it was later redacted, so we just return the timeline we first thought of.
+ return (
+ timelineSet.getTimelineForEvent(eventId) ??
+ timelineSet.room.findThreadForEvent(event)?.liveTimeline ?? // for Threads degraded support
+ timeline
+ );
+ }
+
+ public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
+ if (!this.supportsThreads()) {
+ throw new Error("could not get thread timeline: no client support");
+ }
+
+ if (!timelineSet.room) {
+ throw new Error("could not get thread timeline: not a room timeline");
+ }
+
+ if (!timelineSet.thread) {
+ throw new Error("could not get thread timeline: not a thread timeline");
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
+ $roomId: timelineSet.room.roomId,
+ $eventId: eventId,
+ });
+
+ const params: Record<string, string | string[]> = {
+ limit: "0",
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
+ const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
+ const mapper = this.getEventMapper();
+ const event = mapper(res.event);
+
+ if (!timelineSet.canContain(event)) {
+ return undefined;
+ }
+
+ if (Thread.hasServerSideSupport) {
+ if (Thread.hasServerSideFwdPaginationSupport) {
+ if (!timelineSet.thread) {
+ throw new Error("could not get thread timeline: not a thread timeline");
+ }
+
+ const thread = timelineSet.thread;
+ const resOlder: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, from: res.start },
+ );
+ const resNewer: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Forward, from: res.end },
+ );
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...resNewer.chunk.reverse().map(mapper),
+ event,
+ ...resOlder.chunk.map(mapper),
+ ];
+ for (const event of events) {
+ await timelineSet.thread?.processEvent(event);
+ }
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
+ let timeline = timelineSet.getTimelineForEvent(event.getId());
+ if (timeline) {
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+ } else {
+ timeline = timelineSet.addTimeline();
+ timeline.initialiseState(res.state.map(mapper));
+ }
+
+ timelineSet.addEventsToTimeline(events, true, timeline, resNewer.next_batch);
+ if (!resOlder.next_batch) {
+ const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null);
+ }
+ timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
+ timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
+ this.processAggregatedTimelineEvents(timelineSet.room, events);
+
+ // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
+ // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
+ // anywhere, if it was later redacted, so we just return the timeline we first thought of.
+ return timelineSet.getTimelineForEvent(eventId) ?? timeline;
+ } else {
+ // Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
+ // functions contiguously, so we have to jump through some hoops to get our target event in it.
+ // XXX: workaround for https://github.com/vector-im/element-meta/issues/150
+
+ const thread = timelineSet.thread;
+
+ const resOlder = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, from: res.start },
+ );
+ const eventsNewer: IEvent[] = [];
+ let nextBatch: Optional<string> = res.end;
+ while (nextBatch) {
+ const resNewer: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Forward, from: nextBatch },
+ );
+ nextBatch = resNewer.next_batch ?? null;
+ eventsNewer.push(...resNewer.chunk);
+ }
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...eventsNewer.reverse().map(mapper),
+ event,
+ ...resOlder.chunk.map(mapper),
+ ];
+ for (const event of events) {
+ await timelineSet.thread?.processEvent(event);
+ }
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread
+ // summaries.
+ const timeline = timelineSet.getLiveTimeline();
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+
+ timelineSet.addEventsToTimeline(events, true, timeline, null);
+ if (!resOlder.next_batch) {
+ const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null);
+ }
+ timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
+ timeline.setPaginationToken(null, Direction.Forward);
+ this.processAggregatedTimelineEvents(timelineSet.room, events);
+
+ return timeline;
+ }
+ }
+ }
+
+ /**
+ * Get an EventTimeline for the latest events in the room. This will just
+ * call `/messages` to get the latest message in the room, then use
+ * `client.getEventTimeline(...)` to construct a new timeline from it.
+ *
+ * @param timelineSet - The timelineSet to find or add the timeline to
+ *
+ * @returns Promise which resolves:
+ * {@link EventTimeline} timeline with the latest events in the room
+ */
+ public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
+ // don't allow any timeline support unless it's been enabled.
+ if (!this.timelineSupport) {
+ throw new Error(
+ "timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable it.",
+ );
+ }
+
+ if (!timelineSet.room) {
+ throw new Error("getLatestTimeline only supports room timelines");
+ }
+
+ let event;
+ if (timelineSet.threadListType !== null) {
+ const res = await this.createThreadListMessagesRequest(
+ timelineSet.room.roomId,
+ null,
+ 1,
+ Direction.Backward,
+ timelineSet.threadListType,
+ timelineSet.getFilter(),
+ );
+ event = res.chunk?.[0];
+ } else if (timelineSet.thread && Thread.hasServerSideSupport) {
+ const res = await this.fetchRelations(
+ timelineSet.room.roomId,
+ timelineSet.thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, limit: 1 },
+ );
+ event = res.chunk?.[0];
+ } else {
+ const messagesPath = utils.encodeUri("/rooms/$roomId/messages", {
+ $roomId: timelineSet.room.roomId,
+ });
+
+ const params: Record<string, string | string[]> = {
+ dir: "b",
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+
+ const res = await this.http.authedRequest<IMessagesResponse>(Method.Get, messagesPath, params);
+ event = res.chunk?.[0];
+ }
+ if (!event) {
+ throw new Error("No message returned when trying to construct getLatestTimeline");
+ }
+
+ return this.getEventTimeline(timelineSet, event.event_id);
+ }
+
+ /**
+ * Makes a request to /messages with the appropriate lazy loading filter set.
+ * XXX: if we do get rid of scrollback (as it's not used at the moment),
+ * we could inline this method again in paginateEventTimeline as that would
+ * then be the only call-site
+ * @param limit - the maximum amount of events the retrieve
+ * @param dir - 'f' or 'b'
+ * @param timelineFilter - the timeline filter to pass
+ */
+ // XXX: Intended private, used in code.
+ public createMessagesRequest(
+ roomId: string,
+ fromToken: string | null,
+ limit = 30,
+ dir: Direction,
+ timelineFilter?: Filter,
+ ): Promise<IMessagesResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/messages", { $roomId: roomId });
+
+ const params: Record<string, string> = {
+ limit: limit.toString(),
+ dir: dir,
+ };
+
+ if (fromToken) {
+ params.from = fromToken;
+ }
+
+ let filter: IRoomEventFilter | null = null;
+ if (this.clientOpts?.lazyLoadMembers) {
+ // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+ // so the timelineFilter doesn't get written into it below
+ filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+ if (timelineFilter) {
+ // XXX: it's horrific that /messages' filter parameter doesn't match
+ // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+ filter = filter || {};
+ Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON());
+ }
+ if (filter) {
+ params.filter = JSON.stringify(filter);
+ }
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ /**
+ * Makes a request to /messages with the appropriate lazy loading filter set.
+ * XXX: if we do get rid of scrollback (as it's not used at the moment),
+ * we could inline this method again in paginateEventTimeline as that would
+ * then be the only call-site
+ * @param limit - the maximum amount of events the retrieve
+ * @param dir - 'f' or 'b'
+ * @param timelineFilter - the timeline filter to pass
+ */
+ // XXX: Intended private, used by room.fetchRoomThreads
+ public createThreadListMessagesRequest(
+ roomId: string,
+ fromToken: string | null,
+ limit = 30,
+ dir = Direction.Backward,
+ threadListType: ThreadFilterType | null = ThreadFilterType.All,
+ timelineFilter?: Filter,
+ ): Promise<IMessagesResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId });
+
+ const params: Record<string, string> = {
+ limit: limit.toString(),
+ dir: dir,
+ include: threadFilterTypeToFilter(threadListType),
+ };
+
+ if (fromToken) {
+ params.from = fromToken;
+ }
+
+ let filter: IRoomEventFilter = {};
+ if (this.clientOpts?.lazyLoadMembers) {
+ // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+ // so the timelineFilter doesn't get written into it below
+ filter = {
+ ...Filter.LAZY_LOADING_MESSAGES_FILTER,
+ };
+ }
+ if (timelineFilter) {
+ // XXX: it's horrific that /messages' filter parameter doesn't match
+ // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+ filter = {
+ ...filter,
+ ...timelineFilter.getRoomTimelineFilterComponent()?.toJSON(),
+ };
+ }
+ if (Object.keys(filter).length) {
+ params.filter = JSON.stringify(filter);
+ }
+
+ const opts = {
+ prefix:
+ Thread.hasServerSideListSupport === FeatureSupport.Stable
+ ? "/_matrix/client/v1"
+ : "/_matrix/client/unstable/org.matrix.msc3856",
+ };
+
+ return this.http
+ .authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts)
+ .then((res) => ({
+ ...res,
+ chunk: res.chunk?.reverse(),
+ start: res.prev_batch,
+ end: res.next_batch,
+ }));
+ }
+
+ /**
+ * Take an EventTimeline, and back/forward-fill results.
+ *
+ * @param eventTimeline - timeline object to be updated
+ *
+ * @returns Promise which resolves to a boolean: false if there are no
+ * events and we reached either end of the timeline; else true.
+ */
+ public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> {
+ const isNotifTimeline = eventTimeline.getTimelineSet() === this.notifTimelineSet;
+ const room = this.getRoom(eventTimeline.getRoomId()!);
+ const threadListType = eventTimeline.getTimelineSet().threadListType;
+ const thread = eventTimeline.getTimelineSet().thread;
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more
+ // nicely with HTTP errors.
+ opts = opts || {};
+ const backwards = opts.backwards || false;
+
+ if (isNotifTimeline) {
+ if (!backwards) {
+ throw new Error("paginateNotifTimeline can only paginate backwards");
+ }
+ }
+
+ const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
+
+ const token = eventTimeline.getPaginationToken(dir);
+ const pendingRequest = eventTimeline.paginationRequests[dir];
+
+ if (pendingRequest) {
+ // already a request in progress - return the existing promise
+ return pendingRequest;
+ }
+
+ let path: string;
+ let params: Record<string, string>;
+ let promise: Promise<boolean>;
+
+ if (isNotifTimeline) {
+ path = "/notifications";
+ params = {
+ limit: (opts.limit ?? 30).toString(),
+ only: "highlight",
+ };
+
+ if (token && token !== "end") {
+ params.from = token;
+ }
+
+ promise = this.http
+ .authedRequest<INotificationsResponse>(Method.Get, path, params)
+ .then(async (res) => {
+ const token = res.next_token;
+ const matrixEvents: MatrixEvent[] = [];
+
+ res.notifications = res.notifications.filter(noUnsafeEventProps);
+
+ for (let i = 0; i < res.notifications.length; i++) {
+ const notification = res.notifications[i];
+ const event = this.getEventMapper()(notification.event);
+ event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions));
+ event.event.room_id = notification.room_id; // XXX: gutwrenching
+ matrixEvents[i] = event;
+ }
+
+ // No need to partition events for threads here, everything lives
+ // in the notification timeline set
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && !res.next_token) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return Boolean(res.next_token);
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else if (threadListType !== null) {
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ if (!Thread.hasServerSideFwdPaginationSupport && dir === Direction.Forward) {
+ throw new Error("Cannot paginate threads forwards without server-side support for MSC 3715");
+ }
+
+ promise = this.createThreadListMessagesRequest(
+ eventTimeline.getRoomId()!,
+ token,
+ opts.limit,
+ dir,
+ threadListType,
+ eventTimeline.getFilter(),
+ )
+ .then((res) => {
+ if (res.state) {
+ const roomState = eventTimeline.getState(dir)!;
+ const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(room, matrixEvents);
+ this.processThreadRoots(room, matrixEvents, backwards);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && res.end == res.start) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return res.end !== res.start;
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else if (thread) {
+ const room = this.getRoom(eventTimeline.getRoomId() ?? undefined);
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
+ dir,
+ limit: opts.limit,
+ from: token ?? undefined,
+ })
+ .then(async (res) => {
+ const mapper = this.getEventMapper();
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
+
+ // Process latest events first
+ for (const event of matrixEvents.slice().reverse()) {
+ await thread?.processEvent(event);
+ const sender = event.getSender()!;
+ if (!backwards || thread?.getEventReadUpTo(sender) === null) {
+ room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
+ }
+ }
+
+ const newToken = res.next_batch;
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null);
+ if (!newToken && backwards) {
+ const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null);
+ }
+ this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && !newToken) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return Boolean(newToken);
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else {
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ promise = this.createMessagesRequest(
+ eventTimeline.getRoomId()!,
+ token,
+ opts.limit,
+ dir,
+ eventTimeline.getFilter(),
+ )
+ .then((res) => {
+ if (res.state) {
+ const roomState = eventTimeline.getState(dir)!;
+ const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
+ timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ this.processThreadRoots(
+ room,
+ timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
+ false,
+ );
+
+ const atEnd = res.end === undefined || res.end === res.start;
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && atEnd) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return !atEnd;
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ }
+
+ return promise;
+ }
+
+ /**
+ * Reset the notifTimelineSet entirely, paginating in some historical notifs as
+ * a starting point for subsequent pagination.
+ */
+ public resetNotifTimelineSet(): void {
+ if (!this.notifTimelineSet) {
+ return;
+ }
+
+ // FIXME: This thing is a total hack, and results in duplicate events being
+ // added to the timeline both from /sync and /notifications, and lots of
+ // slow and wasteful processing and pagination. The correct solution is to
+ // extend /messages or /search or something to filter on notifications.
+
+ // use the fictitious token 'end'. in practice we would ideally give it
+ // the oldest backwards pagination token from /sync, but /sync doesn't
+ // know about /notifications, so we have no choice but to start paginating
+ // from the current point in time. This may well overlap with historical
+ // notifs which are then inserted into the timeline by /sync responses.
+ this.notifTimelineSet.resetLiveTimeline("end");
+
+ // we could try to paginate a single event at this point in order to get
+ // a more valid pagination token, but it just ends up with an out of order
+ // timeline. given what a mess this is and given we're going to have duplicate
+ // events anyway, just leave it with the dummy token for now.
+ /*
+ this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), {
+ backwards: true,
+ limit: 1
+ });
+ */
+ }
+
+ /**
+ * Peek into a room and receive updates about the room. This only works if the
+ * history visibility for the room is world_readable.
+ * @param roomId - The room to attempt to peek into.
+ * @returns Promise which resolves: Room object
+ * @returns Rejects: with an error response.
+ */
+ public peekInRoom(roomId: string): Promise<Room> {
+ this.peekSync?.stopPeeking();
+ this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ return this.peekSync.peek(roomId);
+ }
+
+ /**
+ * Stop any ongoing room peeking.
+ */
+ public stopPeeking(): void {
+ if (this.peekSync) {
+ this.peekSync.stopPeeking();
+ this.peekSync = null;
+ }
+ }
+
+ /**
+ * Set r/w flags for guest access in a room.
+ * @param roomId - The room to configure guest access in.
+ * @param opts - Options
+ * @returns Promise which resolves
+ * @returns Rejects: with an error response.
+ */
+ public setGuestAccess(roomId: string, opts: IGuestAccessOpts): Promise<void> {
+ const writePromise = this.sendStateEvent(
+ roomId,
+ EventType.RoomGuestAccess,
+ {
+ guest_access: opts.allowJoin ? "can_join" : "forbidden",
+ },
+ "",
+ );
+
+ let readPromise: Promise<any> = Promise.resolve<any>(undefined);
+ if (opts.allowRead) {
+ readPromise = this.sendStateEvent(
+ roomId,
+ EventType.RoomHistoryVisibility,
+ {
+ history_visibility: "world_readable",
+ },
+ "",
+ );
+ }
+
+ return Promise.all([readPromise, writePromise]).then(); // .then() to hide results for contract
+ }
+
+ /**
+ * Requests an email verification token for the purposes of registration.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ *
+ * Parameters and return value are as for requestEmailToken
+
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestRegisterEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/register/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of registration.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ *
+ * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in which
+ * phoneNumber should be parsed relative to.
+ * @param phoneNumber - The phone number, in national or international format
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestRegisterMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/register/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests an email verification token for the purposes of adding a
+ * third party identifier to an account.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ * If an account with the given email address already exists and is
+ * associated with an account other than the one the user is authed as,
+ * it will either send an email to the address informing them of this
+ * or return M_THREEPID_IN_USE (which one is up to the homeserver).
+ *
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestAdd3pidEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/3pid/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of adding a
+ * third party identifier to an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the addition of phone numbers to an
+ * account, as requestAdd3pidEmailToken.
+ *
+ * @param phoneCountry - As requestRegisterMsisdnToken
+ * @param phoneNumber - As requestRegisterMsisdnToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestAdd3pidMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/3pid/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests an email verification token for the purposes of resetting
+ * the password on an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the password resetting. Specifically,
+ * if no account with the given email address exists, it may either
+ * return M_THREEPID_NOT_FOUND or send an email
+ * to the address informing them of this (which one is up to the homeserver).
+ *
+ * requestEmailToken calls the equivalent API directly on the identity server,
+ * therefore bypassing the password reset specific logic.
+ *
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestPasswordEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/password/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of resetting
+ * the password on an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the password resetting, as requestPasswordEmailToken.
+ *
+ * @param phoneCountry - As requestRegisterMsisdnToken
+ * @param phoneNumber - As requestRegisterMsisdnToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestPasswordMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/password/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Internal utility function for requesting validation tokens from usage-specific
+ * requestToken endpoints.
+ *
+ * @param endpoint - The endpoint to send the request to
+ * @param params - Parameters for the POST request
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ private async requestTokenFromEndpoint<T extends IRequestTokenResponse>(
+ endpoint: string,
+ params: Record<string, any>,
+ ): Promise<T> {
+ const postParams = Object.assign({}, params);
+
+ // If the HS supports separate add and bind, then requestToken endpoints
+ // don't need an IS as they are all validated by the HS directly.
+ if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) {
+ const idServerUrl = new URL(this.idBaseUrl);
+ postParams.id_server = idServerUrl.host;
+
+ if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ postParams.id_access_token = identityAccessToken;
+ }
+ }
+ }
+
+ return this.http.request(Method.Post, endpoint, undefined, postParams);
+ }
+
+ /**
+ * Get the room-kind push rule associated with a room.
+ * @param scope - "global" or device-specific.
+ * @param roomId - the id of the room.
+ * @returns the rule or undefined.
+ */
+ public getRoomPushRule(scope: "global" | "device", roomId: string): IPushRule | undefined {
+ // There can be only room-kind push rule per room
+ // and its id is the room id.
+ if (this.pushRules) {
+ return this.pushRules[scope]?.room?.find((rule) => rule.rule_id === roomId);
+ } else {
+ throw new Error("SyncApi.sync() must be done before accessing to push rules.");
+ }
+ }
+
+ /**
+ * Set a room-kind muting push rule in a room.
+ * The operation also updates MatrixClient.pushRules at the end.
+ * @param scope - "global" or device-specific.
+ * @param roomId - the id of the room.
+ * @param mute - the mute state.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomMutePushRule(scope: "global" | "device", roomId: string, mute: boolean): Promise<void> | undefined {
+ let promise: Promise<unknown> | undefined;
+ let hasDontNotifyRule = false;
+
+ // Get the existing room-kind push rule if any
+ const roomPushRule = this.getRoomPushRule(scope, roomId);
+ if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) {
+ hasDontNotifyRule = true;
+ }
+
+ if (!mute) {
+ // Remove the rule only if it is a muting rule
+ if (hasDontNotifyRule) {
+ promise = this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule!.rule_id);
+ }
+ } else {
+ if (!roomPushRule) {
+ promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
+ actions: [PushRuleActionName.DontNotify],
+ });
+ } else if (!hasDontNotifyRule) {
+ // Remove the existing one before setting the mute push rule
+ // This is a workaround to SYN-590 (Push rule update fails)
+ const deferred = utils.defer();
+ this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
+ .then(() => {
+ this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
+ actions: [PushRuleActionName.DontNotify],
+ })
+ .then(() => {
+ deferred.resolve();
+ })
+ .catch((err) => {
+ deferred.reject(err);
+ });
+ })
+ .catch((err) => {
+ deferred.reject(err);
+ });
+
+ promise = deferred.promise;
+ }
+ }
+
+ if (promise) {
+ return new Promise<void>((resolve, reject) => {
+ // Update this.pushRules when the operation completes
+ promise!
+ .then(() => {
+ this.getPushRules()
+ .then((result) => {
+ this.pushRules = result;
+ resolve();
+ })
+ .catch((err) => {
+ reject(err);
+ });
+ })
+ .catch((err: Error) => {
+ // Update it even if the previous operation fails. This can help the
+ // app to recover when push settings has been modified from another client
+ this.getPushRules()
+ .then((result) => {
+ this.pushRules = result;
+ reject(err);
+ })
+ .catch((err2) => {
+ reject(err);
+ });
+ });
+ });
+ }
+ }
+
+ public searchMessageText(opts: ISearchOpts): Promise<ISearchResponse> {
+ const roomEvents: ISearchRequestBody["search_categories"]["room_events"] = {
+ search_term: opts.query,
+ };
+
+ if ("keys" in opts) {
+ roomEvents.keys = opts.keys;
+ }
+
+ return this.search({
+ body: {
+ search_categories: {
+ room_events: roomEvents,
+ },
+ },
+ });
+ }
+
+ /**
+ * Perform a server-side search for room events.
+ *
+ * The returned promise resolves to an object containing the fields:
+ *
+ * * count: estimate of the number of results
+ * * next_batch: token for back-pagination; if undefined, there are no more results
+ * * highlights: a list of words to highlight from the stemming algorithm
+ * * results: a list of results
+ *
+ * Each entry in the results list is a SearchResult.
+ *
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public searchRoomEvents(opts: IEventSearchOpts): Promise<ISearchResults> {
+ // TODO: support search groups
+
+ const body = {
+ search_categories: {
+ room_events: {
+ search_term: opts.term,
+ filter: opts.filter,
+ order_by: SearchOrderBy.Recent,
+ event_context: {
+ before_limit: 1,
+ after_limit: 1,
+ include_profile: true,
+ },
+ },
+ },
+ };
+
+ const searchResults: ISearchResults = {
+ _query: body,
+ results: [],
+ highlights: [],
+ };
+
+ return this.search({ body: body }).then((res) => this.processRoomEventsSearch(searchResults, res));
+ }
+
+ /**
+ * Take a result from an earlier searchRoomEvents call, and backfill results.
+ *
+ * @param searchResults - the results object to be updated
+ * @returns Promise which resolves: updated result object
+ * @returns Rejects: with an error response.
+ */
+ public backPaginateRoomEventsSearch<T extends ISearchResults>(searchResults: T): Promise<T> {
+ // TODO: we should implement a backoff (as per scrollback()) to deal more
+ // nicely with HTTP errors.
+
+ if (!searchResults.next_batch) {
+ return Promise.reject(new Error("Cannot backpaginate event search any further"));
+ }
+
+ if (searchResults.pendingRequest) {
+ // already a request in progress - return the existing promise
+ return searchResults.pendingRequest as Promise<T>;
+ }
+
+ const searchOpts = {
+ body: searchResults._query!,
+ next_batch: searchResults.next_batch,
+ };
+
+ const promise = this.search(searchOpts, searchResults.abortSignal)
+ .then((res) => this.processRoomEventsSearch(searchResults, res))
+ .finally(() => {
+ searchResults.pendingRequest = undefined;
+ });
+ searchResults.pendingRequest = promise;
+
+ return promise;
+ }
+
+ /**
+ * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the
+ * response from the API call and updates the searchResults
+ *
+ * @returns searchResults
+ * @internal
+ */
+ // XXX: Intended private, used in code
+ public processRoomEventsSearch<T extends ISearchResults>(searchResults: T, response: ISearchResponse): T {
+ const roomEvents = response.search_categories.room_events;
+
+ searchResults.count = roomEvents.count;
+ searchResults.next_batch = roomEvents.next_batch;
+
+ // combine the highlight list with our existing list;
+ const highlights = new Set<string>(roomEvents.highlights);
+ searchResults.highlights.forEach((hl) => {
+ highlights.add(hl);
+ });
+
+ // turn it back into a list.
+ searchResults.highlights = Array.from(highlights);
+
+ const mapper = this.getEventMapper();
+
+ // append the new results to our existing results
+ const resultsLength = roomEvents.results?.length ?? 0;
+ for (let i = 0; i < resultsLength; i++) {
+ const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
+ const room = this.getRoom(sr.context.getEvent().getRoomId());
+ if (room) {
+ // Copy over a known event sender if we can
+ for (const ev of sr.context.getTimeline()) {
+ const sender = room.getMember(ev.getSender()!);
+ if (!ev.sender && sender) ev.sender = sender;
+ }
+ }
+ searchResults.results.push(sr);
+ }
+ return searchResults;
+ }
+
+ /**
+ * Populate the store with rooms the user has left.
+ * @returns Promise which resolves: TODO - Resolved when the rooms have
+ * been added to the data store.
+ * @returns Rejects: with an error response.
+ */
+ public syncLeftRooms(): Promise<Room[]> {
+ // Guard against multiple calls whilst ongoing and multiple calls post success
+ if (this.syncedLeftRooms) {
+ return Promise.resolve([]); // don't call syncRooms again if it succeeded.
+ }
+ if (this.syncLeftRoomsPromise) {
+ return this.syncLeftRoomsPromise; // return the ongoing request
+ }
+ const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
+
+ // cleanup locks
+ this.syncLeftRoomsPromise
+ .then(() => {
+ logger.log("Marking success of sync left room request");
+ this.syncedLeftRooms = true; // flip the bit on success
+ })
+ .finally(() => {
+ this.syncLeftRoomsPromise = undefined; // cleanup ongoing request state
+ });
+
+ return this.syncLeftRoomsPromise;
+ }
+
+ /**
+ * Create a new filter.
+ * @param content - The HTTP body for the request
+ * @returns Promise which resolves to a Filter object.
+ * @returns Rejects: with an error response.
+ */
+ public createFilter(content: IFilterDefinition): Promise<Filter> {
+ const path = utils.encodeUri("/user/$userId/filter", {
+ $userId: this.credentials.userId!,
+ });
+ return this.http.authedRequest<IFilterResponse>(Method.Post, path, undefined, content).then((response) => {
+ // persist the filter
+ const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content);
+ this.store.storeFilter(filter);
+ return filter;
+ });
+ }
+
+ /**
+ * Retrieve a filter.
+ * @param userId - The user ID of the filter owner
+ * @param filterId - The filter ID to retrieve
+ * @param allowCached - True to allow cached filters to be returned.
+ * Default: True.
+ * @returns Promise which resolves: a Filter object
+ * @returns Rejects: with an error response.
+ */
+ public getFilter(userId: string, filterId: string, allowCached: boolean): Promise<Filter> {
+ if (allowCached) {
+ const filter = this.store.getFilter(userId, filterId);
+ if (filter) {
+ return Promise.resolve(filter);
+ }
+ }
+
+ const path = utils.encodeUri("/user/$userId/filter/$filterId", {
+ $userId: userId,
+ $filterId: filterId,
+ });
+
+ return this.http.authedRequest<IFilterDefinition>(Method.Get, path).then((response) => {
+ // persist the filter
+ const filter = Filter.fromJson(userId, filterId, response);
+ this.store.storeFilter(filter);
+ return filter;
+ });
+ }
+
+ /**
+ * @returns Filter ID
+ */
+ public async getOrCreateFilter(filterName: string, filter: Filter): Promise<string> {
+ const filterId = this.store.getFilterIdByName(filterName);
+ let existingId: string | undefined;
+
+ if (filterId) {
+ // check that the existing filter matches our expectations
+ try {
+ const existingFilter = await this.getFilter(this.credentials.userId!, filterId, true);
+ if (existingFilter) {
+ const oldDef = existingFilter.getDefinition();
+ const newDef = filter.getDefinition();
+
+ if (utils.deepCompare(oldDef, newDef)) {
+ // super, just use that.
+ // debuglog("Using existing filter ID %s: %s", filterId,
+ // JSON.stringify(oldDef));
+ existingId = filterId;
+ }
+ }
+ } catch (error) {
+ // Synapse currently returns the following when the filter cannot be found:
+ // {
+ // errcode: "M_UNKNOWN",
+ // name: "M_UNKNOWN",
+ // message: "No row found",
+ // }
+ if ((<MatrixError>error).errcode !== "M_UNKNOWN" && (<MatrixError>error).errcode !== "M_NOT_FOUND") {
+ throw error;
+ }
+ }
+ // if the filter doesn't exist anymore on the server, remove from store
+ if (!existingId) {
+ this.store.setFilterIdByName(filterName, undefined);
+ }
+ }
+
+ if (existingId) {
+ return existingId;
+ }
+
+ // create a new filter
+ const createdFilter = await this.createFilter(filter.getDefinition());
+
+ this.store.setFilterIdByName(filterName, createdFilter.filterId);
+ return createdFilter.filterId!;
+ }
+
+ /**
+ * Gets a bearer token from the homeserver that the user can
+ * present to a third party in order to prove their ownership
+ * of the Matrix account they are logged into.
+ * @returns Promise which resolves: Token object
+ * @returns Rejects: with an error response.
+ */
+ public getOpenIdToken(): Promise<IOpenIDToken> {
+ const path = utils.encodeUri("/user/$userId/openid/request_token", {
+ $userId: this.credentials.userId!,
+ });
+
+ return this.http.authedRequest(Method.Post, path, undefined, {});
+ }
+
+ private startCallEventHandler = (): void => {
+ if (this.isInitialSyncComplete()) {
+ this.callEventHandler!.start();
+ this.groupCallEventHandler!.start();
+ this.off(ClientEvent.Sync, this.startCallEventHandler);
+ }
+ };
+
+ /**
+ * Once the client has been initialised, we want to clear notifications we
+ * know for a fact should be here.
+ * This issue should also be addressed on synapse's side and is tracked as part
+ * of https://github.com/matrix-org/synapse/issues/14837
+ *
+ * We consider a room or a thread as fully read if the current user has sent
+ * the last event in the live timeline of that context and if the read receipt
+ * we have on record matches.
+ */
+ private fixupRoomNotifications = (): void => {
+ if (this.isInitialSyncComplete()) {
+ const unreadRooms = (this.getRooms() ?? []).filter((room) => {
+ return room.getUnreadNotificationCount(NotificationCountType.Total) > 0;
+ });
+
+ for (const room of unreadRooms) {
+ const currentUserId = this.getSafeUserId();
+ room.fixupNotifications(currentUserId);
+ }
+
+ this.off(ClientEvent.Sync, this.fixupRoomNotifications);
+ }
+ };
+
+ /**
+ * @returns Promise which resolves: ITurnServerResponse object
+ * @returns Rejects: with an error response.
+ */
+ public turnServer(): Promise<ITurnServerResponse> {
+ return this.http.authedRequest(Method.Get, "/voip/turnServer");
+ }
+
+ /**
+ * Get the TURN servers for this homeserver.
+ * @returns The servers or an empty list.
+ */
+ public getTurnServers(): ITurnServer[] {
+ return this.turnServers || [];
+ }
+
+ /**
+ * Get the unix timestamp (in milliseconds) at which the current
+ * TURN credentials (from getTurnServers) expire
+ * @returns The expiry timestamp in milliseconds
+ */
+ public getTurnServersExpiry(): number {
+ return this.turnServersExpiry;
+ }
+
+ public get pollingTurnServers(): boolean {
+ return this.checkTurnServersIntervalID !== undefined;
+ }
+
+ // XXX: Intended private, used in code.
+ public async checkTurnServers(): Promise<boolean | undefined> {
+ if (!this.canSupportVoip) {
+ return;
+ }
+
+ let credentialsGood = false;
+ const remainingTime = this.turnServersExpiry - Date.now();
+ if (remainingTime > TURN_CHECK_INTERVAL) {
+ logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
+ credentialsGood = true;
+ } else {
+ logger.debug("Fetching new TURN credentials");
+ try {
+ const res = await this.turnServer();
+ if (res.uris) {
+ logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
+ // map the response to a format that can be fed to RTCPeerConnection
+ const servers: ITurnServer = {
+ urls: res.uris,
+ username: res.username,
+ credential: res.password,
+ };
+ this.turnServers = [servers];
+ // The TTL is in seconds but we work in ms
+ this.turnServersExpiry = Date.now() + res.ttl * 1000;
+ credentialsGood = true;
+ this.emit(ClientEvent.TurnServers, this.turnServers);
+ }
+ } catch (err) {
+ logger.error("Failed to get TURN URIs", err);
+ if ((<HTTPError>err).httpStatus === 403) {
+ // We got a 403, so there's no point in looping forever.
+ logger.info("TURN access unavailable for this account: stopping credentials checks");
+ if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID);
+ this.checkTurnServersIntervalID = undefined;
+ this.emit(ClientEvent.TurnServersError, <HTTPError>err, true); // fatal
+ } else {
+ // otherwise, if we failed for whatever reason, try again the next time we're called.
+ this.emit(ClientEvent.TurnServersError, <Error>err, false); // non-fatal
+ }
+ }
+ }
+
+ return credentialsGood;
+ }
+
+ /**
+ * Set whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ */
+ public setFallbackICEServerAllowed(allow: boolean): void {
+ this.fallbackICEServerAllowed = allow;
+ }
+
+ /**
+ * Get whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ * @returns
+ */
+ public isFallbackICEServerAllowed(): boolean {
+ return this.fallbackICEServerAllowed;
+ }
+
+ /**
+ * Determines if the current user is an administrator of the Synapse homeserver.
+ * Returns false if untrue or the homeserver does not appear to be a Synapse
+ * homeserver. <strong>This function is implementation specific and may change
+ * as a result.</strong>
+ * @returns true if the user appears to be a Synapse administrator.
+ */
+ public isSynapseAdministrator(): Promise<boolean> {
+ const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", { $userId: this.getUserId()! });
+ return this.http
+ .authedRequest<{ admin: boolean }>(Method.Get, path, undefined, undefined, { prefix: "" })
+ .then((r) => r.admin); // pull out the specific boolean we want
+ }
+
+ /**
+ * Performs a whois lookup on a user using Synapse's administrator API.
+ * <strong>This function is implementation specific and may change as a
+ * result.</strong>
+ * @param userId - the User ID to look up.
+ * @returns the whois response - see Synapse docs for information.
+ */
+ public whoisSynapseUser(userId: string): Promise<ISynapseAdminWhoisResponse> {
+ const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix: "" });
+ }
+
+ /**
+ * Deactivates a user using Synapse's administrator API. <strong>This
+ * function is implementation specific and may change as a result.</strong>
+ * @param userId - the User ID to deactivate.
+ * @returns the deactivate response - see Synapse docs for information.
+ */
+ public deactivateSynapseUser(userId: string): Promise<ISynapseAdminDeactivateResponse> {
+ const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Post, path, undefined, undefined, { prefix: "" });
+ }
+
+ private async fetchClientWellKnown(): Promise<void> {
+ // `getRawClientConfig` does not throw or reject on network errors, instead
+ // it absorbs errors and returns `{}`.
+ this.clientWellKnownPromise = AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined);
+ this.clientWellKnown = await this.clientWellKnownPromise;
+ this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown);
+ }
+
+ public getClientWellKnown(): IClientWellKnown | undefined {
+ return this.clientWellKnown;
+ }
+
+ public waitForClientWellKnown(): Promise<IClientWellKnown> {
+ if (!this.clientRunning) {
+ throw new Error("Client is not running");
+ }
+ return this.clientWellKnownPromise!;
+ }
+
+ /**
+ * store client options with boolean/string/numeric values
+ * to know in the next session what flags the sync data was
+ * created with (e.g. lazy loading)
+ * @param opts - the complete set of client options
+ * @returns for store operation
+ */
+ public storeClientOptions(): Promise<void> {
+ // XXX: Intended private, used in code
+ const primTypes = ["boolean", "string", "number"];
+ const serializableOpts = Object.entries(this.clientOpts!)
+ .filter(([key, value]) => {
+ return primTypes.includes(typeof value);
+ })
+ .reduce<Record<string, any>>((obj, [key, value]) => {
+ obj[key] = value;
+ return obj;
+ }, {});
+ return this.store.storeClientOptions(serializableOpts);
+ }
+
+ /**
+ * Gets a set of room IDs in common with another user
+ * @param userId - The userId to check.
+ * @returns Promise which resolves to a set of rooms
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line
+ public async _unstable_getSharedRooms(userId: string): Promise<string[]> {
+ const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666");
+ const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms");
+
+ if (!sharedRoomsSupport && !mutualRoomsSupport) {
+ throw Error("Server does not support mutual_rooms API");
+ }
+
+ const path = utils.encodeUri(
+ `/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
+ { $userId: userId },
+ );
+
+ const res = await this.http.authedRequest<{ joined: string[] }>(Method.Get, path, undefined, undefined, {
+ prefix: ClientPrefix.Unstable,
+ });
+ return res.joined;
+ }
+
+ /**
+ * Get the API versions supported by the server, along with any
+ * unstable APIs it supports
+ * @returns The server /versions response
+ */
+ public async getVersions(): Promise<IServerVersions> {
+ if (this.serverVersionsPromise) {
+ return this.serverVersionsPromise;
+ }
+
+ this.serverVersionsPromise = this.http
+ .request<IServerVersions>(
+ Method.Get,
+ "/_matrix/client/versions",
+ undefined, // queryParams
+ undefined, // data
+ {
+ prefix: "",
+ },
+ )
+ .catch((e) => {
+ // Need to unset this if it fails, otherwise we'll never retry
+ this.serverVersionsPromise = undefined;
+ // but rethrow the exception to anything that was waiting
+ throw e;
+ });
+
+ const serverVersions = await this.serverVersionsPromise;
+ this.canSupport = await buildFeatureSupportMap(serverVersions);
+
+ return this.serverVersionsPromise;
+ }
+
+ /**
+ * Check if a particular spec version is supported by the server.
+ * @param version - The spec version (such as "r0.5.0") to check for.
+ * @returns Whether it is supported
+ */
+ public async isVersionSupported(version: string): Promise<boolean> {
+ const { versions } = await this.getVersions();
+ return versions && versions.includes(version);
+ }
+
+ /**
+ * Query the server to see if it supports members lazy loading
+ * @returns true if server supports lazy loading
+ */
+ public async doesServerSupportLazyLoading(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+
+ return (
+ (versions && versions.includes("r0.5.0")) || (unstableFeatures && unstableFeatures["m.lazy_load_members"])
+ );
+ }
+
+ /**
+ * Query the server to see if the `id_server` parameter is required
+ * when registering with an 3pid, adding a 3pid or resetting password.
+ * @returns true if id_server parameter is required
+ */
+ public async doesServerRequireIdServerParam(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return true;
+
+ const versions = response["versions"];
+
+ // Supporting r0.6.0 is the same as having the flag set to false
+ if (versions && versions.includes("r0.6.0")) {
+ return false;
+ }
+
+ const unstableFeatures = response["unstable_features"];
+ if (!unstableFeatures) return true;
+ if (unstableFeatures["m.require_identity_server"] === undefined) {
+ return true;
+ } else {
+ return unstableFeatures["m.require_identity_server"];
+ }
+ }
+
+ /**
+ * Query the server to see if the `id_access_token` parameter can be safely
+ * passed to the homeserver. Some homeservers may trigger errors if they are not
+ * prepared for the new parameter.
+ * @returns true if id_access_token can be sent
+ */
+ public async doesServerAcceptIdentityAccessToken(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+ return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.id_access_token"]);
+ }
+
+ /**
+ * Query the server to see if it supports separate 3PID add and bind functions.
+ * This affects the sequence of API calls clients should use for these operations,
+ * so it's helpful to be able to check for support.
+ * @returns true if separate functions are supported
+ */
+ public async doesServerSupportSeparateAddAndBind(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+
+ return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
+ }
+
+ /**
+ * Query the server to see if it lists support for an unstable feature
+ * in the /versions response
+ * @param feature - the feature name
+ * @returns true if the feature is supported
+ */
+ public async doesServerSupportUnstableFeature(feature: string): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+ const unstableFeatures = response["unstable_features"];
+ return unstableFeatures && !!unstableFeatures[feature];
+ }
+
+ /**
+ * Query the server to see if it is forcing encryption to be enabled for
+ * a given room preset, based on the /versions response.
+ * @param presetName - The name of the preset to check.
+ * @returns true if the server is forcing encryption
+ * for the preset.
+ */
+ public async doesServerForceEncryptionForPreset(presetName: Preset): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+ const unstableFeatures = response["unstable_features"];
+
+ // The preset name in the versions response will be without the _chat suffix.
+ const versionsPresetName = presetName.includes("_chat")
+ ? presetName.substring(0, presetName.indexOf("_chat"))
+ : presetName;
+
+ return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`];
+ }
+
+ public async doesServerSupportThread(): Promise<{
+ threads: FeatureSupport;
+ list: FeatureSupport;
+ fwdPagination: FeatureSupport;
+ }> {
+ if (await this.isVersionSupported("v1.4")) {
+ return {
+ threads: FeatureSupport.Stable,
+ list: FeatureSupport.Stable,
+ fwdPagination: FeatureSupport.Stable,
+ };
+ }
+
+ try {
+ const [threadUnstable, threadStable, listUnstable, listStable, fwdPaginationUnstable, fwdPaginationStable] =
+ await Promise.all([
+ this.doesServerSupportUnstableFeature("org.matrix.msc3440"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3856"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3715"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable"),
+ ]);
+
+ return {
+ threads: determineFeatureSupport(threadStable, threadUnstable),
+ list: determineFeatureSupport(listStable, listUnstable),
+ fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable),
+ };
+ } catch (e) {
+ return {
+ threads: FeatureSupport.None,
+ list: FeatureSupport.None,
+ fwdPagination: FeatureSupport.None,
+ };
+ }
+ }
+
+ /**
+ * Query the server to see if it supports the MSC2457 `logout_devices` parameter when setting password
+ * @returns true if server supports the `logout_devices` parameter
+ */
+ public doesServerSupportLogoutDevices(): Promise<boolean> {
+ return this.isVersionSupported("r0.6.1");
+ }
+
+ /**
+ * Get if lazy loading members is being used.
+ * @returns Whether or not members are lazy loaded by this client
+ */
+ public hasLazyLoadMembersEnabled(): boolean {
+ return !!this.clientOpts?.lazyLoadMembers;
+ }
+
+ /**
+ * Set a function which is called when /sync returns a 'limited' response.
+ * It is called with a room ID and returns a boolean. It should return 'true' if the SDK
+ * can SAFELY remove events from this room. It may not be safe to remove events if there
+ * are other references to the timelines for this room, e.g because the client is
+ * actively viewing events in this room.
+ * Default: returns false.
+ * @param cb - The callback which will be invoked.
+ */
+ public setCanResetTimelineCallback(cb: ResetTimelineCallback): void {
+ this.canResetTimelineCallback = cb;
+ }
+
+ /**
+ * Get the callback set via `setCanResetTimelineCallback`.
+ * @returns The callback or null
+ */
+ public getCanResetTimelineCallback(): ResetTimelineCallback | undefined {
+ return this.canResetTimelineCallback;
+ }
+
+ /**
+ * Returns relations for a given event. Handles encryption transparently,
+ * with the caveat that the amount of events returned might be 0, even though you get a nextBatch.
+ * When the returned promise resolves, all messages should have finished trying to decrypt.
+ * @param roomId - the room of the event
+ * @param eventId - the id of the event
+ * @param relationType - the rel_type of the relations requested
+ * @param eventType - the event type of the relations requested
+ * @param opts - options with optional values for the request.
+ * @returns an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available.
+ */
+ public async relations(
+ roomId: string,
+ eventId: string,
+ relationType?: RelationType | string | null,
+ eventType?: EventType | string | null,
+ opts: IRelationsRequestOpts = { dir: Direction.Backward },
+ ): Promise<{
+ originalEvent?: MatrixEvent | null;
+ events: MatrixEvent[];
+ nextBatch?: string | null;
+ prevBatch?: string | null;
+ }> {
+ const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null;
+ const [eventResult, result] = await Promise.all([
+ this.fetchRoomEvent(roomId, eventId),
+ this.fetchRelations(roomId, eventId, relationType, fetchedEventType, opts),
+ ]);
+ const mapper = this.getEventMapper();
+
+ const originalEvent = eventResult ? mapper(eventResult) : undefined;
+ let events = result.chunk.map(mapper);
+
+ if (fetchedEventType === EventType.RoomMessageEncrypted) {
+ const allEvents = originalEvent ? events.concat(originalEvent) : events;
+ await Promise.all(allEvents.map((e) => this.decryptEventIfNeeded(e)));
+ if (eventType !== null) {
+ events = events.filter((e) => e.getType() === eventType);
+ }
+ }
+
+ if (originalEvent && relationType === RelationType.Replace) {
+ events = events.filter((e) => e.getSender() === originalEvent.getSender());
+ }
+ return {
+ originalEvent: originalEvent ?? null,
+ events,
+ nextBatch: result.next_batch ?? null,
+ prevBatch: result.prev_batch ?? null,
+ };
+ }
+
+ /**
+ * The app may wish to see if we have a key cached without
+ * triggering a user interaction.
+ */
+ public getCrossSigningCacheCallbacks(): ICacheCallbacks | undefined {
+ // XXX: Private member access
+ return this.crypto?.crossSigningInfo.getCacheCallbacks();
+ }
+
+ /**
+ * Generates a random string suitable for use as a client secret. <strong>This
+ * method is experimental and may change.</strong>
+ * @returns A new client secret
+ */
+ public generateClientSecret(): string {
+ return randomString(32);
+ }
+
+ /**
+ * Attempts to decrypt an event
+ * @param event - The event to decrypt
+ * @returns A decryption promise
+ */
+ public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
+ if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) {
+ event.attemptDecryption(this.cryptoBackend!, options);
+ }
+
+ if (event.isBeingDecrypted()) {
+ return event.getDecryptionPromise()!;
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ private termsUrlForService(serviceType: SERVICE_TYPES, baseUrl: string): URL {
+ switch (serviceType) {
+ case SERVICE_TYPES.IS:
+ return this.http.getUrl("/terms", undefined, IdentityPrefix.V2, baseUrl);
+ case SERVICE_TYPES.IM:
+ return this.http.getUrl("/terms", undefined, "/_matrix/integrations/v1", baseUrl);
+ default:
+ throw new Error("Unsupported service type");
+ }
+ }
+
+ /**
+ * Get the Homeserver URL of this client
+ * @returns Homeserver URL of this client
+ */
+ public getHomeserverUrl(): string {
+ return this.baseUrl;
+ }
+
+ /**
+ * Get the identity server URL of this client
+ * @param stripProto - whether or not to strip the protocol from the URL
+ * @returns Identity server URL of this client
+ */
+ public getIdentityServerUrl(stripProto = false): string | undefined {
+ if (stripProto && (this.idBaseUrl?.startsWith("http://") || this.idBaseUrl?.startsWith("https://"))) {
+ return this.idBaseUrl.split("://")[1];
+ }
+ return this.idBaseUrl;
+ }
+
+ /**
+ * Set the identity server URL of this client
+ * @param url - New identity server URL
+ */
+ public setIdentityServerUrl(url: string): void {
+ this.idBaseUrl = utils.ensureNoTrailingSlash(url);
+ this.http.setIdBaseUrl(this.idBaseUrl);
+ }
+
+ /**
+ * Get the access token associated with this account.
+ * @returns The access_token or null
+ */
+ public getAccessToken(): string | null {
+ return this.http.opts.accessToken || null;
+ }
+
+ /**
+ * Set the access token associated with this account.
+ * @param token - The new access token.
+ */
+ public setAccessToken(token: string): void {
+ this.http.opts.accessToken = token;
+ }
+
+ /**
+ * @returns true if there is a valid access_token for this client.
+ */
+ public isLoggedIn(): boolean {
+ return this.http.opts.accessToken !== undefined;
+ }
+
+ /**
+ * Make up a new transaction id
+ *
+ * @returns a new, unique, transaction id
+ */
+ public makeTxnId(): string {
+ return "m" + new Date().getTime() + "." + this.txnCtr++;
+ }
+
+ /**
+ * Check whether a username is available prior to registration. An error response
+ * indicates an invalid/unavailable username.
+ * @param username - The username to check the availability of.
+ * @returns Promise which resolves: to boolean of whether the username is available.
+ */
+ public isUsernameAvailable(username: string): Promise<boolean> {
+ return this.http
+ .authedRequest<{ available: true }>(Method.Get, "/register/available", { username })
+ .then((response) => {
+ return response.available;
+ })
+ .catch((response) => {
+ if (response.errcode === "M_USER_IN_USE") {
+ return false;
+ }
+ return Promise.reject(response);
+ });
+ }
+
+ /**
+ * @param bindThreepids - Set key 'email' to true to bind any email
+ * threepid uses during registration in the identity server. Set 'msisdn' to
+ * true to bind msisdn.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public register(
+ username: string,
+ password: string,
+ sessionId: string | null,
+ auth: { session?: string; type: string },
+ bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean },
+ guestAccessToken?: string,
+ inhibitLogin?: boolean,
+ ): Promise<IAuthData> {
+ // backwards compat
+ if (bindThreepids === true) {
+ bindThreepids = { email: true };
+ } else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) {
+ bindThreepids = {};
+ }
+ if (sessionId) {
+ auth.session = sessionId;
+ }
+
+ const params: IRegisterRequestParams = {
+ auth: auth,
+ refresh_token: true, // always ask for a refresh token - does nothing if unsupported
+ };
+ if (username !== undefined && username !== null) {
+ params.username = username;
+ }
+ if (password !== undefined && password !== null) {
+ params.password = password;
+ }
+ if (bindThreepids.email) {
+ params.bind_email = true;
+ }
+ if (bindThreepids.msisdn) {
+ params.bind_msisdn = true;
+ }
+ if (guestAccessToken !== undefined && guestAccessToken !== null) {
+ params.guest_access_token = guestAccessToken;
+ }
+ if (inhibitLogin !== undefined && inhibitLogin !== null) {
+ params.inhibit_login = inhibitLogin;
+ }
+ // Temporary parameter added to make the register endpoint advertise
+ // msisdn flows. This exists because there are clients that break
+ // when given stages they don't recognise. This parameter will cease
+ // to be necessary once these old clients are gone.
+ // Only send it if we send any params at all (the password param is
+ // mandatory, so if we send any params, we'll send the password param)
+ if (password !== undefined && password !== null) {
+ params.x_show_msisdn = true;
+ }
+
+ return this.registerRequest(params);
+ }
+
+ /**
+ * Register a guest account.
+ * This method returns the auth info needed to create a new authenticated client,
+ * Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g:
+ * ```javascript
+ * const tmpClient = await sdk.createClient(MATRIX_INSTANCE);
+ * const { user_id, device_id, access_token } = tmpClient.registerGuest();
+ * const client = createClient({
+ * baseUrl: MATRIX_INSTANCE,
+ * accessToken: access_token,
+ * userId: user_id,
+ * deviceId: device_id,
+ * })
+ * client.setGuest(true);
+ * ```
+ *
+ * @param body - JSON HTTP body to provide.
+ * @returns Promise which resolves: JSON object that contains:
+ * `{ user_id, device_id, access_token, home_server }`
+ * @returns Rejects: with an error response.
+ */
+ public registerGuest({ body }: { body?: any } = {}): Promise<any> {
+ // TODO: Types
+ return this.registerRequest(body || {}, "guest");
+ }
+
+ /**
+ * @param data - parameters for registration request
+ * @param kind - type of user to register. may be "guest"
+ * @returns Promise which resolves: to the /register response
+ * @returns Rejects: with an error response.
+ */
+ public registerRequest(data: IRegisterRequestParams, kind?: string): Promise<IAuthData> {
+ const params: { kind?: string } = {};
+ if (kind) {
+ params.kind = kind;
+ }
+
+ return this.http.request(Method.Post, "/register", params, data);
+ }
+
+ /**
+ * Refreshes an access token using a provided refresh token. The refresh token
+ * must be valid for the current access token known to the client instance.
+ *
+ * Note that this function will not cause a logout if the token is deemed
+ * unknown by the server - the caller is responsible for managing logout
+ * actions on error.
+ * @param refreshToken - The refresh token.
+ * @returns Promise which resolves to the new token.
+ * @returns Rejects with an error response.
+ */
+ public refreshToken(refreshToken: string): Promise<IRefreshTokenResponse> {
+ return this.http.authedRequest(
+ Method.Post,
+ "/refresh",
+ undefined,
+ { refresh_token: refreshToken },
+ {
+ prefix: ClientPrefix.V1,
+ inhibitLogoutEmit: true, // we don't want to cause logout loops
+ },
+ );
+ }
+
+ /**
+ * @returns Promise which resolves to the available login flows
+ * @returns Rejects: with an error response.
+ */
+ public loginFlows(): Promise<ILoginFlowsResponse> {
+ return this.http.request(Method.Get, "/login");
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public login(loginType: string, data: any): Promise<any> {
+ // TODO: Types
+ const loginData = {
+ type: loginType,
+ };
+
+ // merge data into loginData
+ Object.assign(loginData, data);
+
+ return this.http
+ .authedRequest<{
+ access_token?: string;
+ user_id?: string;
+ }>(Method.Post, "/login", undefined, loginData)
+ .then((response) => {
+ if (response.access_token && response.user_id) {
+ this.http.opts.accessToken = response.access_token;
+ this.credentials = {
+ userId: response.user_id,
+ };
+ }
+ return response;
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithPassword(user: string, password: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.password", {
+ user: user,
+ password: password,
+ });
+ }
+
+ /**
+ * @param relayState - URL Callback after SAML2 Authentication
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithSAML2(relayState: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.saml2", {
+ relay_state: relayState,
+ });
+ }
+
+ /**
+ * @param redirectUrl - The URL to redirect to after the HS
+ * authenticates with CAS.
+ * @returns The HS URL to hit to begin the CAS login process.
+ */
+ public getCasLoginUrl(redirectUrl: string): string {
+ return this.getSsoLoginUrl(redirectUrl, "cas");
+ }
+
+ /**
+ * @param redirectUrl - The URL to redirect to after the HS
+ * authenticates with the SSO.
+ * @param loginType - The type of SSO login we are doing (sso or cas).
+ * Defaults to 'sso'.
+ * @param idpId - The ID of the Identity Provider being targeted, optional.
+ * @param action - the SSO flow to indicate to the IdP, optional.
+ * @returns The HS URL to hit to begin the SSO login process.
+ */
+ public getSsoLoginUrl(redirectUrl: string, loginType = "sso", idpId?: string, action?: SSOAction): string {
+ let url = "/login/" + loginType + "/redirect";
+ if (idpId) {
+ url += "/" + idpId;
+ }
+
+ const params = {
+ redirectUrl,
+ [SSO_ACTION_PARAM.unstable!]: action,
+ };
+
+ return this.http.getUrl(url, params, ClientPrefix.R0).href;
+ }
+
+ /**
+ * @param token - Login token previously received from homeserver
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithToken(token: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.token", {
+ token: token,
+ });
+ }
+
+ /**
+ * Logs out the current session.
+ * Obviously, further calls that require authorisation should fail after this
+ * method is called. The state of the MatrixClient object is not affected:
+ * it is up to the caller to either reset or destroy the MatrixClient after
+ * this method succeeds.
+ * @param stopClient - whether to stop the client before calling /logout to prevent invalid token errors.
+ * @returns Promise which resolves: On success, the empty object `{}`
+ */
+ public async logout(stopClient = false): Promise<{}> {
+ if (this.crypto?.backupManager?.getKeyBackupEnabled()) {
+ try {
+ while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0);
+ } catch (err) {
+ logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err);
+ }
+ }
+
+ if (stopClient) {
+ this.stopClient();
+ this.http.abort();
+ }
+
+ return this.http.authedRequest(Method.Post, "/logout");
+ }
+
+ /**
+ * Deactivates the logged-in account.
+ * Obviously, further calls that require authorisation should fail after this
+ * method is called. The state of the MatrixClient object is not affected:
+ * it is up to the caller to either reset or destroy the MatrixClient after
+ * this method succeeds.
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @param erase - Optional. If set, send as `erase` attribute in the
+ * JSON request body, indicating whether the account should be erased. Defaults
+ * to false.
+ * @returns Promise which resolves: On success, the empty object
+ */
+ public deactivateAccount(auth?: any, erase?: boolean): Promise<{}> {
+ const body: any = {};
+ if (auth) {
+ body.auth = auth;
+ }
+ if (erase !== undefined) {
+ body.erase = erase;
+ }
+
+ return this.http.authedRequest(Method.Post, "/account/deactivate", undefined, body);
+ }
+
+ /**
+ * Make a request for an `m.login.token` to be issued as per
+ * [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882).
+ * The server may require User-Interactive auth.
+ * Note that this is UNSTABLE and subject to breaking changes without notice.
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: On success, the token response
+ * or UIA auth data.
+ */
+ public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
+ const body: UIARequest<{}> = { auth };
+ return this.http.authedRequest(
+ Method.Post,
+ "/org.matrix.msc3882/login/token",
+ undefined, // no query params
+ body,
+ { prefix: ClientPrefix.Unstable },
+ );
+ }
+
+ /**
+ * Get the fallback URL to use for unknown interactive-auth stages.
+ *
+ * @param loginType - the type of stage being attempted
+ * @param authSessionId - the auth session ID provided by the homeserver
+ *
+ * @returns HS URL to hit to for the fallback interface
+ */
+ public getFallbackAuthUrl(loginType: string, authSessionId: string): string {
+ const path = utils.encodeUri("/auth/$loginType/fallback/web", {
+ $loginType: loginType,
+ });
+
+ return this.http.getUrl(
+ path,
+ {
+ session: authSessionId,
+ },
+ ClientPrefix.R0,
+ ).href;
+ }
+
+ /**
+ * Create a new room.
+ * @param options - a list of options to pass to the /createRoom API.
+ * @returns Promise which resolves: `{room_id: {string}}`
+ * @returns Rejects: with an error response.
+ */
+ public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> {
+ // eslint-disable-line camelcase
+ // some valid options include: room_alias_name, visibility, invite
+
+ // inject the id_access_token if inviting 3rd party addresses
+ const invitesNeedingToken = (options.invite_3pid || []).filter((i) => !i.id_access_token);
+ if (
+ invitesNeedingToken.length > 0 &&
+ this.identityServer?.getAccessToken &&
+ (await this.doesServerAcceptIdentityAccessToken())
+ ) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ for (const invite of invitesNeedingToken) {
+ invite.id_access_token = identityAccessToken;
+ }
+ }
+ }
+
+ return this.http.authedRequest(Method.Post, "/createRoom", undefined, options);
+ }
+
+ /**
+ * Fetches relations for a given event
+ * @param roomId - the room of the event
+ * @param eventId - the id of the event
+ * @param relationType - the rel_type of the relations requested
+ * @param eventType - the event type of the relations requested
+ * @param opts - options with optional values for the request.
+ * @returns the response, with chunk, prev_batch and, next_batch.
+ */
+ public fetchRelations(
+ roomId: string,
+ eventId: string,
+ relationType?: RelationType | string | null,
+ eventType?: EventType | string | null,
+ opts: IRelationsRequestOpts = { dir: Direction.Backward },
+ ): Promise<IRelationsResponse> {
+ let params = opts as QueryDict;
+ if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
+ params = replaceParam("dir", "org.matrix.msc3715.dir", params);
+ }
+ const queryString = utils.encodeParams(params);
+
+ let templatedUrl = "/rooms/$roomId/relations/$eventId";
+ if (relationType !== null) {
+ templatedUrl += "/$relationType";
+ if (eventType !== null) {
+ templatedUrl += "/$eventType";
+ }
+ } else if (eventType !== null) {
+ logger.warn(`eventType: ${eventType} ignored when fetching
+ relations as relationType is null`);
+ eventType = null;
+ }
+
+ const path = utils.encodeUri(templatedUrl + "?" + queryString, {
+ $roomId: roomId,
+ $eventId: eventId,
+ $relationType: relationType!,
+ $eventType: eventType!,
+ });
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, {
+ prefix: ClientPrefix.V1,
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public roomState(roomId: string): Promise<IStateEventWithRoomId[]> {
+ const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Get an event in a room by its event id.
+ *
+ * @returns Promise which resolves to an object containing the event.
+ * @returns Rejects: with an error response.
+ */
+ public fetchRoomEvent(roomId: string, eventId: string): Promise<Partial<IEvent>> {
+ const path = utils.encodeUri("/rooms/$roomId/event/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param includeMembership - the membership type to include in the response
+ * @param excludeMembership - the membership type to exclude from the response
+ * @param atEventId - the id of the event for which moment in the timeline the members should be returned for
+ * @returns Promise which resolves: dictionary of userid to profile information
+ * @returns Rejects: with an error response.
+ */
+ public members(
+ roomId: string,
+ includeMembership?: string,
+ excludeMembership?: string,
+ atEventId?: string,
+ ): Promise<{ [userId: string]: IStateEventWithRoomId[] }> {
+ const queryParams: Record<string, string> = {};
+ if (includeMembership) {
+ queryParams.membership = includeMembership;
+ }
+ if (excludeMembership) {
+ queryParams.not_membership = excludeMembership;
+ }
+ if (atEventId) {
+ queryParams.at = atEventId;
+ }
+
+ const queryString = utils.encodeParams(queryParams);
+
+ const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, { $roomId: roomId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Upgrades a room to a new protocol version
+ * @param newVersion - The target version to upgrade to
+ * @returns Promise which resolves: Object with key 'replacement_room'
+ * @returns Rejects: with an error response.
+ */
+ public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> {
+ // eslint-disable-line camelcase
+ const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId });
+ return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion });
+ }
+
+ /**
+ * Retrieve a state event.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getStateEvent(roomId: string, eventType: string, stateKey: string): Promise<Record<string, any>> {
+ const pathParams = {
+ $roomId: roomId,
+ $eventType: eventType,
+ $stateKey: stateKey,
+ };
+ let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+ if (stateKey !== undefined) {
+ path = utils.encodeUri(path + "/$stateKey", pathParams);
+ }
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param opts - Options for the request function.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public sendStateEvent(
+ roomId: string,
+ eventType: string,
+ content: any,
+ stateKey = "",
+ opts: IRequestOpts = {},
+ ): Promise<ISendEventResponse> {
+ const pathParams = {
+ $roomId: roomId,
+ $eventType: eventType,
+ $stateKey: stateKey,
+ };
+ let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+ if (stateKey !== undefined) {
+ path = utils.encodeUri(path + "/$stateKey", pathParams);
+ }
+ return this.http.authedRequest(Method.Put, path, undefined, content, opts);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public roomInitialSync(roomId: string, limit: number): Promise<IRoomInitialSyncResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId });
+
+ return this.http.authedRequest(Method.Get, path, { limit: limit?.toString() ?? "30" });
+ }
+
+ /**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param roomId - ID of the room that has been read
+ * @param rmEventId - ID of the event that has been read
+ * @param rrEventId - ID of the event tracked by the read receipt. This is here
+ * for convenience because the RR and the RM are commonly updated at the same time as
+ * each other. Optional.
+ * @param rpEventId - rpEvent the m.read.private read receipt event for when we
+ * don't want other users to see the read receipts. This is experimental. Optional.
+ * @returns Promise which resolves: the empty object, `{}`.
+ */
+ public async setRoomReadMarkersHttpRequest(
+ roomId: string,
+ rmEventId: string,
+ rrEventId?: string,
+ rpEventId?: string,
+ ): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/read_markers", {
+ $roomId: roomId,
+ });
+
+ const content: IContent = {
+ [ReceiptType.FullyRead]: rmEventId,
+ [ReceiptType.Read]: rrEventId,
+ };
+
+ if (
+ (await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) ||
+ (await this.isVersionSupported("v1.4"))
+ ) {
+ content[ReceiptType.ReadPrivate] = rpEventId;
+ }
+
+ return this.http.authedRequest(Method.Post, path, undefined, content);
+ }
+
+ /**
+ * @returns Promise which resolves: A list of the user's current rooms
+ * @returns Rejects: with an error response.
+ */
+ public getJoinedRooms(): Promise<IJoinedRoomsResponse> {
+ const path = utils.encodeUri("/joined_rooms", {});
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Retrieve membership info. for a room.
+ * @param roomId - ID of the room to get membership for
+ * @returns Promise which resolves: A list of currently joined users
+ * and their profile data.
+ * @returns Rejects: with an error response.
+ */
+ public getJoinedRoomMembers(roomId: string): Promise<IJoinedMembersResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/joined_members", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param options - Options for this request
+ * @param server - The remote server to query for the room list.
+ * Optional. If unspecified, get the local home
+ * server's public room list.
+ * @param limit - Maximum number of entries to return
+ * @param since - Token to paginate from
+ * @returns Promise which resolves: IPublicRoomsResponse
+ * @returns Rejects: with an error response.
+ */
+ public publicRooms({
+ server,
+ limit,
+ since,
+ ...options
+ }: IRoomDirectoryOptions = {}): Promise<IPublicRoomsResponse> {
+ const queryParams: QueryDict = { server, limit, since };
+ if (Object.keys(options).length === 0) {
+ return this.http.authedRequest(Method.Get, "/publicRooms", queryParams);
+ } else {
+ return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options);
+ }
+ }
+
+ /**
+ * Create an alias to room ID mapping.
+ * @param alias - The room alias to create.
+ * @param roomId - The room ID to link the alias to.
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public createAlias(alias: string, roomId: string): Promise<{}> {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ const data = {
+ room_id: roomId,
+ };
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * Delete an alias to room ID mapping. This alias must be on your local server,
+ * and you must have sufficient access to do this operation.
+ * @param alias - The room alias to delete.
+ * @returns Promise which resolves: an empty object `{}`.
+ * @returns Rejects: with an error response.
+ */
+ public deleteAlias(alias: string): Promise<{}> {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * Gets the local aliases for the room. Note: this includes all local aliases, unlike the
+ * curated list from the m.room.canonical_alias state event.
+ * @param roomId - The room ID to get local aliases for.
+ * @returns Promise which resolves: an object with an `aliases` property, containing an array of local aliases
+ * @returns Rejects: with an error response.
+ */
+ public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
+ const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
+ const prefix = ClientPrefix.V3;
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix });
+ }
+
+ /**
+ * Get room info for the given alias.
+ * @param alias - The room alias to resolve.
+ * @returns Promise which resolves: Object with room_id and servers.
+ * @returns Rejects: with an error response.
+ */
+ public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
+ // eslint-disable-line camelcase
+ // TODO: deprecate this or resolveRoomAlias
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves: Object with room_id and servers.
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
+ // TODO: deprecate this or getRoomIdForAlias
+ const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
+ return this.http.request(Method.Get, path);
+ }
+
+ /**
+ * Get the visibility of a room in the current HS's room directory
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getRoomDirectoryVisibility(roomId: string): Promise<{ visibility: Visibility }> {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Set the visbility of a room in the current HS's room directory
+ * @param visibility - "public" to make the room visible
+ * in the public directory, or "private" to make
+ * it invisible.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<{}> {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { visibility });
+ }
+
+ /**
+ * Set the visbility of a room bridged to a 3rd party network in
+ * the current HS's room directory.
+ * @param networkId - the network ID of the 3rd party
+ * instance under which this room is published under.
+ * @param visibility - "public" to make the room visible
+ * in the public directory, or "private" to make
+ * it invisible.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomDirectoryVisibilityAppService(
+ networkId: string,
+ roomId: string,
+ visibility: "public" | "private",
+ ): Promise<any> {
+ // TODO: Types
+ const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
+ $networkId: networkId,
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { visibility: visibility });
+ }
+
+ /**
+ * Query the user directory with a term matching user IDs, display names and domains.
+ * @param term - the term with which to search.
+ * @param limit - the maximum number of results to return. The server will
+ * apply a limit if unspecified.
+ * @returns Promise which resolves: an array of results.
+ */
+ public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise<IUserDirectoryResponse> {
+ const body: any = {
+ search_term: term,
+ };
+
+ if (limit !== undefined) {
+ body.limit = limit;
+ }
+
+ return this.http.authedRequest(Method.Post, "/user_directory/search", undefined, body);
+ }
+
+ /**
+ * Upload a file to the media repository on the homeserver.
+ *
+ * @param file - The object to upload. On a browser, something that
+ * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
+ * a a Buffer, String or ReadStream.
+ *
+ * @param opts - options object
+ *
+ * @returns Promise which resolves to response object, as
+ * determined by this.opts.onlyData, opts.rawResponse, and
+ * opts.onlyContentUri. Rejects with an error (usually a MatrixError).
+ */
+ public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> {
+ return this.http.uploadContent(file, opts);
+ }
+
+ /**
+ * Cancel a file upload in progress
+ * @param upload - The object returned from uploadContent
+ * @returns true if canceled, otherwise false
+ */
+ public cancelUpload(upload: Promise<UploadResponse>): boolean {
+ return this.http.cancelUpload(upload);
+ }
+
+ /**
+ * Get a list of all file uploads in progress
+ * @returns Array of objects representing current uploads.
+ * Currently in progress is element 0. Keys:
+ * - promise: The promise associated with the upload
+ * - loaded: Number of bytes uploaded
+ * - total: Total number of bytes to upload
+ */
+ public getCurrentUploads(): Upload[] {
+ return this.http.getCurrentUploads();
+ }
+
+ /**
+ * @param info - The kind of info to retrieve (e.g. 'displayname',
+ * 'avatar_url').
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getProfileInfo(
+ userId: string,
+ info?: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ avatar_url?: string; displayname?: string }> {
+ const path = info
+ ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info })
+ : utils.encodeUri("/profile/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves to a list of the user's threepids.
+ * @returns Rejects: with an error response.
+ */
+ public getThreePids(): Promise<{ threepids: IThreepid[] }> {
+ return this.http.authedRequest(Method.Get, "/account/3pid");
+ }
+
+ /**
+ * Add a 3PID to your homeserver account and optionally bind it to an identity
+ * server as well. An identity server is required as part of the `creds` object.
+ *
+ * This API is deprecated, and you should instead use `addThreePidOnly`
+ * for homeservers that support it.
+ *
+ * @returns Promise which resolves: on success
+ * @returns Rejects: with an error response.
+ */
+ public addThreePid(creds: any, bind: boolean): Promise<any> {
+ // TODO: Types
+ const path = "/account/3pid";
+ const data = {
+ threePidCreds: creds,
+ bind: bind,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * Add a 3PID to your homeserver account. This API does not use an identity
+ * server, as the homeserver is expected to handle 3PID ownership validation.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param data - A object with 3PID validation data from having called
+ * `account/3pid/<medium>/requestToken` on the homeserver.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
+ const path = "/account/3pid/add";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * Bind a 3PID for discovery onto an identity server via the homeserver. The
+ * identity server handles 3PID ownership validation and the homeserver records
+ * the new binding to track where all 3PIDs for the account are bound.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param data - A object with 3PID validation data from having called
+ * `validate/<medium>/requestToken` on the identity server. It should also
+ * contain `id_server` and `id_access_token` fields as well.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async bindThreePid(data: IBindThreePidBody): Promise<{}> {
+ const path = "/account/3pid/bind";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * Unbind a 3PID for discovery on an identity server via the homeserver. The
+ * homeserver removes its record of the binding to keep an updated record of
+ * where all 3PIDs for the account are bound.
+ *
+ * @param medium - The threepid medium (eg. 'email')
+ * @param address - The threepid address (eg. 'bob\@example.com')
+ * this must be as returned by getThreePids.
+ * @returns Promise which resolves: on success
+ * @returns Rejects: with an error response.
+ */
+ public async unbindThreePid(
+ medium: string,
+ address: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
+ const path = "/account/3pid/unbind";
+ const data = {
+ medium,
+ address,
+ id_server: this.getIdentityServerUrl(true),
+ };
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * @param medium - The threepid medium (eg. 'email')
+ * @param address - The threepid address (eg. 'bob\@example.com')
+ * this must be as returned by getThreePids.
+ * @returns Promise which resolves: The server response on success
+ * (generally the empty JSON object)
+ * @returns Rejects: with an error response.
+ */
+ public deleteThreePid(
+ medium: string,
+ address: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
+ const path = "/account/3pid/delete";
+ return this.http.authedRequest(Method.Post, path, undefined, { medium, address });
+ }
+
+ /**
+ * Make a request to change your password.
+ * @param newPassword - The new desired password.
+ * @param logoutDevices - Should all sessions be logged out after the password change. Defaults to true.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPassword(authDict: IAuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> {
+ const path = "/account/password";
+ const data = {
+ auth: authDict,
+ new_password: newPassword,
+ logout_devices: logoutDevices,
+ };
+
+ return this.http.authedRequest<{}>(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * Gets all devices recorded for the logged-in user
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public getDevices(): Promise<{ devices: IMyDevice[] }> {
+ return this.http.authedRequest(Method.Get, "/devices");
+ }
+
+ /**
+ * Gets specific device details for the logged-in user
+ * @param deviceId - device to query
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public getDevice(deviceId: string): Promise<IMyDevice> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Update the given device
+ *
+ * @param deviceId - device to update
+ * @param body - body of request
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public setDeviceDetails(deviceId: string, body: { display_name: string }): Promise<{}> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * Delete the given device
+ *
+ * @param deviceId - device to delete
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public deleteDevice(deviceId: string, auth?: IAuthDict): Promise<IAuthData | {}> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+
+ const body: any = {};
+
+ if (auth) {
+ body.auth = auth;
+ }
+
+ return this.http.authedRequest(Method.Delete, path, undefined, body);
+ }
+
+ /**
+ * Delete multiple device
+ *
+ * @param devices - IDs of the devices to delete
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise<IAuthData | {}> {
+ const body: any = { devices };
+
+ if (auth) {
+ body.auth = auth;
+ }
+
+ const path = "/delete_devices";
+ return this.http.authedRequest(Method.Post, path, undefined, body);
+ }
+
+ /**
+ * Gets all pushers registered for the logged-in user
+ *
+ * @returns Promise which resolves: Array of objects representing pushers
+ * @returns Rejects: with an error response.
+ */
+ public async getPushers(): Promise<{ pushers: IPusher[] }> {
+ const response = await this.http.authedRequest<{ pushers: IPusher[] }>(Method.Get, "/pushers");
+
+ // Migration path for clients that connect to a homeserver that does not support
+ // MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration
+ if (!(await this.doesServerSupportUnstableFeature("org.matrix.msc3881"))) {
+ response.pushers = response.pushers.map((pusher) => {
+ if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) {
+ pusher[PUSHER_ENABLED.name] = true;
+ }
+ return pusher;
+ });
+ }
+
+ return response;
+ }
+
+ /**
+ * Adds a new pusher or updates an existing pusher
+ *
+ * @param pusher - Object representing a pusher
+ * @returns Promise which resolves: Empty json object on success
+ * @returns Rejects: with an error response.
+ */
+ public setPusher(pusher: IPusherRequest): Promise<{}> {
+ const path = "/pushers/set";
+ return this.http.authedRequest(Method.Post, path, undefined, pusher);
+ }
+
+ /**
+ * Persists local notification settings
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setLocalNotificationSettings(
+ deviceId: string,
+ notificationSettings: LocalNotificationSettings,
+ ): Promise<{}> {
+ const key = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
+ return this.setAccountData(key, notificationSettings);
+ }
+
+ /**
+ * Get the push rules for the account from the server.
+ * @returns Promise which resolves to the push rules.
+ * @returns Rejects: with an error response.
+ */
+ public getPushRules(): Promise<IPushRules> {
+ return this.http.authedRequest<IPushRules>(Method.Get, "/pushrules/").then((rules: IPushRules) => {
+ this.setPushRules(rules);
+ return this.pushRules!;
+ });
+ }
+
+ /**
+ * Update the push rules for the account. This should be called whenever
+ * updated push rules are available.
+ */
+ public setPushRules(rules: IPushRules): void {
+ // Fix-up defaults, if applicable.
+ this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
+ // Pre-calculate any necessary caches.
+ this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
+ }
+
+ /**
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public addPushRule(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: Exclude<string, RuleId>,
+ body: Pick<IPushRule, "actions" | "conditions" | "pattern">,
+ ): Promise<{}> {
+ // NB. Scope not uri encoded because devices need the '/'
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public deletePushRule(scope: string, kind: PushRuleKind, ruleId: Exclude<string, RuleId>): Promise<{}> {
+ // NB. Scope not uri encoded because devices need the '/'
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * Enable or disable a push notification rule.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPushRuleEnabled(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: RuleId | string,
+ enabled: boolean,
+ ): Promise<{}> {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { enabled: enabled });
+ }
+
+ /**
+ * Set the actions for a push notification rule.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPushRuleActions(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: RuleId | string,
+ actions: PushRuleAction[],
+ ): Promise<{}> {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { actions: actions });
+ }
+
+ /**
+ * Perform a server-side search.
+ * @param next_batch - the batch token to pass in the query string
+ * @param body - the JSON object to pass to the request body.
+ * @param abortSignal - optional signal used to cancel the http request.
+ * @returns Promise which resolves to the search response object.
+ * @returns Rejects: with an error response.
+ */
+ public search(
+ { body, next_batch: nextBatch }: { body: ISearchRequestBody; next_batch?: string },
+ abortSignal?: AbortSignal,
+ ): Promise<ISearchResponse> {
+ const queryParams: any = {};
+ if (nextBatch) {
+ queryParams.next_batch = nextBatch;
+ }
+ return this.http.authedRequest(Method.Post, "/search", queryParams, body, { abortSignal });
+ }
+
+ /**
+ * Upload keys
+ *
+ * @param content - body of upload request
+ *
+ * @param opts - this method no longer takes any opts,
+ * used to take opts.device_id but this was not removed from the spec as a redundant parameter
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public uploadKeysRequest(content: IUploadKeysRequest, opts?: void): Promise<IKeysUploadResponse> {
+ return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content);
+ }
+
+ public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> {
+ return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, {
+ prefix: ClientPrefix.V3,
+ });
+ }
+
+ /**
+ * Download device keys
+ *
+ * @param userIds - list of users to get keys for
+ *
+ * @param token - sync token to pass in the query request, to help
+ * the HS give the most recent results
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public downloadKeysForUsers(userIds: string[], { token }: { token?: string } = {}): Promise<IDownloadKeyResult> {
+ const content: IQueryKeysRequest = {
+ device_keys: {},
+ };
+ if (token !== undefined) {
+ content.token = token;
+ }
+ userIds.forEach((u) => {
+ content.device_keys[u] = [];
+ });
+
+ return this.http.authedRequest(Method.Post, "/keys/query", undefined, content);
+ }
+
+ /**
+ * Claim one-time keys
+ *
+ * @param devices - a list of [userId, deviceId] pairs
+ *
+ * @param keyAlgorithm - desired key type
+ *
+ * @param timeout - the time (in milliseconds) to wait for keys from remote
+ * servers
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public claimOneTimeKeys(
+ devices: [string, string][],
+ keyAlgorithm = "signed_curve25519",
+ timeout?: number,
+ ): Promise<IClaimOTKsResult> {
+ const queries: Record<string, Record<string, string>> = {};
+
+ if (keyAlgorithm === undefined) {
+ keyAlgorithm = "signed_curve25519";
+ }
+
+ for (const [userId, deviceId] of devices) {
+ const query = queries[userId] || {};
+ queries[userId] = query;
+ query[deviceId] = keyAlgorithm;
+ }
+ const content: IClaimKeysRequest = { one_time_keys: queries };
+ if (timeout) {
+ content.timeout = timeout;
+ }
+ const path = "/keys/claim";
+ return this.http.authedRequest(Method.Post, path, undefined, content);
+ }
+
+ /**
+ * Ask the server for a list of users who have changed their device lists
+ * between a pair of sync tokens
+ *
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public getKeyChanges(oldToken: string, newToken: string): Promise<{ changed: string[]; left: string[] }> {
+ const qps = {
+ from: oldToken,
+ to: newToken,
+ };
+
+ return this.http.authedRequest(Method.Get, "/keys/changes", qps);
+ }
+
+ public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
+ // API returns empty object
+ const data = Object.assign({}, keys);
+ if (auth) Object.assign(data, { auth });
+ return this.http.authedRequest(Method.Post, "/keys/device_signing/upload", undefined, data, {
+ prefix: ClientPrefix.Unstable,
+ });
+ }
+
+ /**
+ * Register with an identity server using the OpenID token from the user's
+ * Homeserver, which can be retrieved via
+ * {@link MatrixClient#getOpenIdToken}.
+ *
+ * Note that the `/account/register` endpoint (as well as IS authentication in
+ * general) was added as part of the v2 API version.
+ *
+ * @returns Promise which resolves: with object containing an Identity
+ * Server access token.
+ * @returns Rejects: with an error response.
+ */
+ public registerWithIdentityServer(hsOpenIdToken: IOpenIDToken): Promise<{
+ access_token: string;
+ token: string;
+ }> {
+ if (!this.idBaseUrl) {
+ throw new Error("No identity server base URL set");
+ }
+
+ const uri = this.http.getUrl("/account/register", undefined, IdentityPrefix.V2, this.idBaseUrl);
+ return this.http.requestOtherUrl(Method.Post, uri, hsOpenIdToken);
+ }
+
+ /**
+ * Requests an email verification token directly from an identity server.
+ *
+ * This API is used as part of binding an email for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
+ *
+ * @param email - The email address to request a token for
+ * @param clientSecret - A secret binary string generated by the client.
+ * It is recommended this be around 16 ASCII characters.
+ * @param sendAttempt - If an identity server sees a duplicate request
+ * with the same sendAttempt, it will not send another email.
+ * To request another email to be sent, use a larger value for
+ * the sendAttempt param as was used in the previous request.
+ * @param nextLink - Optional If specified, the client will be redirected
+ * to this link after validation.
+ * @param identityAccessToken - The `access_token` field of the identity
+ * server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ * @throws Error if no identity server is set
+ */
+ public requestEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ identityAccessToken?: string,
+ ): Promise<IRequestTokenResponse> {
+ const params: Record<string, string> = {
+ client_secret: clientSecret,
+ email: email,
+ send_attempt: sendAttempt?.toString(),
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+
+ return this.http.idServerRequest<IRequestTokenResponse>(
+ Method.Post,
+ "/validate/email/requestToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Requests a MSISDN verification token directly from an identity server.
+ *
+ * This API is used as part of binding a MSISDN for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
+ *
+ * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in
+ * which phoneNumber should be parsed relative to.
+ * @param phoneNumber - The phone number, in national or international
+ * format
+ * @param clientSecret - A secret binary string generated by the client.
+ * It is recommended this be around 16 ASCII characters.
+ * @param sendAttempt - If an identity server sees a duplicate request
+ * with the same sendAttempt, it will not send another SMS.
+ * To request another SMS to be sent, use a larger value for
+ * the sendAttempt param as was used in the previous request.
+ * @param nextLink - Optional If specified, the client will be redirected
+ * to this link after validation.
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves to an object with a sid string
+ * @returns Rejects: with an error response.
+ * @throws Error if no identity server is set
+ */
+ public requestMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ identityAccessToken?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ const params: Record<string, string> = {
+ client_secret: clientSecret,
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ send_attempt: sendAttempt?.toString(),
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+
+ return this.http.idServerRequest<IRequestMsisdnTokenResponse>(
+ Method.Post,
+ "/validate/msisdn/requestToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Submits a MSISDN token to the identity server
+ *
+ * This is used when submitting the code sent by SMS to a phone number.
+ * The identity server has an equivalent API for email but the js-sdk does
+ * not expose this, since email is normally validated by the user clicking
+ * a link rather than entering a code.
+ *
+ * @param sid - The sid given in the response to requestToken
+ * @param clientSecret - A secret binary string generated by the client.
+ * This must be the same value submitted in the requestToken call.
+ * @param msisdnToken - The MSISDN token, as enetered by the user.
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: Object, currently with no parameters.
+ * @returns Rejects: with an error response.
+ * @throws Error if No identity server is set
+ */
+ public submitMsisdnToken(
+ sid: string,
+ clientSecret: string,
+ msisdnToken: string,
+ identityAccessToken: string,
+ ): Promise<any> {
+ // TODO: Types
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken,
+ };
+
+ return this.http.idServerRequest(
+ Method.Post,
+ "/validate/msisdn/submitToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Submits a MSISDN token to an arbitrary URL.
+ *
+ * This is used when submitting the code sent by SMS to a phone number in the
+ * newer 3PID flow where the homeserver validates 3PID ownership (as part of
+ * `requestAdd3pidMsisdnToken`). The homeserver response may include a
+ * `submit_url` to specify where the token should be sent, and this helper can
+ * be used to pass the token to this URL.
+ *
+ * @param url - The URL to submit the token to
+ * @param sid - The sid given in the response to requestToken
+ * @param clientSecret - A secret binary string generated by the client.
+ * This must be the same value submitted in the requestToken call.
+ * @param msisdnToken - The MSISDN token, as enetered by the user.
+ *
+ * @returns Promise which resolves: Object, currently with no parameters.
+ * @returns Rejects: with an error response.
+ */
+ public submitMsisdnTokenOtherUrl(
+ url: string,
+ sid: string,
+ clientSecret: string,
+ msisdnToken: string,
+ ): Promise<any> {
+ // TODO: Types
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken,
+ };
+ return this.http.requestOtherUrl(Method.Post, url, params);
+ }
+
+ /**
+ * Gets the V2 hashing information from the identity server. Primarily useful for
+ * lookups.
+ * @param identityAccessToken - The access token for the identity server.
+ * @returns The hashing information for the identity server.
+ */
+ public getIdentityHashDetails(identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ return this.http.idServerRequest(
+ Method.Get,
+ "/hash_details",
+ undefined,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Performs a hashed lookup of addresses against the identity server. This is
+ * only supported on identity servers which have at least the version 2 API.
+ * @param addressPairs - An array of 2 element arrays.
+ * The first element of each pair is the address, the second is the 3PID medium.
+ * Eg: `["email@example.org", "email"]`
+ * @param identityAccessToken - The access token for the identity server.
+ * @returns A collection of address mappings to
+ * found MXIDs. Results where no user could be found will not be listed.
+ */
+ public async identityHashedLookup(
+ addressPairs: [string, string][],
+ identityAccessToken: string,
+ ): Promise<{ address: string; mxid: string }[]> {
+ const params: Record<string, string | string[]> = {
+ // addresses: ["email@example.org", "10005550000"],
+ // algorithm: "sha256",
+ // pepper: "abc123"
+ };
+
+ // Get hash information first before trying to do a lookup
+ const hashes = await this.getIdentityHashDetails(identityAccessToken);
+ if (!hashes || !hashes["lookup_pepper"] || !hashes["algorithms"]) {
+ throw new Error("Unsupported identity server: bad response");
+ }
+
+ params["pepper"] = hashes["lookup_pepper"];
+
+ const localMapping: Record<string, string> = {
+ // hashed identifier => plain text address
+ // For use in this function's return format
+ };
+
+ // When picking an algorithm, we pick the hashed over no hashes
+ if (hashes["algorithms"].includes("sha256")) {
+ // Abuse the olm hashing
+ const olmutil = new global.Olm.Utility();
+ params["addresses"] = addressPairs.map((p) => {
+ const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+ const med = p[1].toLowerCase();
+ const hashed = olmutil
+ .sha256(`${addr} ${med} ${params["pepper"]}`)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_"); // URL-safe base64
+ // Map the hash to a known (case-sensitive) address. We use the case
+ // sensitive version because the caller might be expecting that.
+ localMapping[hashed] = p[0];
+ return hashed;
+ });
+ params["algorithm"] = "sha256";
+ } else if (hashes["algorithms"].includes("none")) {
+ params["addresses"] = addressPairs.map((p) => {
+ const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+ const med = p[1].toLowerCase();
+ const unhashed = `${addr} ${med}`;
+ // Map the unhashed values to a known (case-sensitive) address. We use
+ // the case-sensitive version because the caller might be expecting that.
+ localMapping[unhashed] = p[0];
+ return unhashed;
+ });
+ params["algorithm"] = "none";
+ } else {
+ throw new Error("Unsupported identity server: unknown hash algorithm");
+ }
+
+ const response = await this.http.idServerRequest<{
+ mappings: { [address: string]: string };
+ }>(Method.Post, "/lookup", params, IdentityPrefix.V2, identityAccessToken);
+
+ if (!response?.["mappings"]) return []; // no results
+
+ const foundAddresses: { address: string; mxid: string }[] = [];
+ for (const hashed of Object.keys(response["mappings"])) {
+ const mxid = response["mappings"][hashed];
+ const plainAddress = localMapping[hashed];
+ if (!plainAddress) {
+ throw new Error("Identity server returned more results than expected");
+ }
+
+ foundAddresses.push({ address: plainAddress, mxid });
+ }
+ return foundAddresses;
+ }
+
+ /**
+ * Looks up the public Matrix ID mapping for a given 3rd party
+ * identifier from the identity server
+ *
+ * @param medium - The medium of the threepid, eg. 'email'
+ * @param address - The textual address of the threepid
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: A threepid mapping
+ * object or the empty object if no mapping
+ * exists
+ * @returns Rejects: with an error response.
+ */
+ public async lookupThreePid(medium: string, address: string, identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ // Note: we're using the V2 API by calling this function, but our
+ // function contract requires a V1 response. We therefore have to
+ // convert it manually.
+ const response = await this.identityHashedLookup([[address, medium]], identityAccessToken);
+ const result = response.find((p) => p.address === address);
+ if (!result) {
+ return {};
+ }
+
+ const mapping = {
+ address,
+ medium,
+ mxid: result.mxid,
+
+ // We can't reasonably fill these parameters:
+ // not_before
+ // not_after
+ // ts
+ // signatures
+ };
+
+ return mapping;
+ }
+
+ /**
+ * Looks up the public Matrix ID mappings for multiple 3PIDs.
+ *
+ * @param query - Array of arrays containing
+ * [medium, address]
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: Lookup results from IS.
+ * @returns Rejects: with an error response.
+ */
+ public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ // Note: we're using the V2 API by calling this function, but our
+ // function contract requires a V1 response. We therefore have to
+ // convert it manually.
+ const response = await this.identityHashedLookup(
+ // We have to reverse the query order to get [address, medium] pairs
+ query.map((p) => [p[1], p[0]]),
+ identityAccessToken,
+ );
+
+ const v1results: [medium: string, address: string, mxid: string][] = [];
+ for (const mapping of response) {
+ const originalQuery = query.find((p) => p[1] === mapping.address);
+ if (!originalQuery) {
+ throw new Error("Identity sever returned unexpected results");
+ }
+
+ v1results.push([
+ originalQuery[0], // medium
+ mapping.address,
+ mapping.mxid,
+ ]);
+ }
+
+ return { threepids: v1results };
+ }
+
+ /**
+ * Get account info from the identity server. This is useful as a neutral check
+ * to verify that other APIs are likely to approve access by testing that the
+ * token is valid, terms have been agreed, etc.
+ *
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: an object with account info.
+ * @returns Rejects: with an error response.
+ */
+ public getIdentityAccount(identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ return this.http.idServerRequest(Method.Get, "/account", undefined, IdentityPrefix.V2, identityAccessToken);
+ }
+
+ /**
+ * Send an event to a specific list of devices.
+ * This is a low-level API that simply wraps the HTTP API
+ * call to send to-device messages. We recommend using
+ * queueToDevice() which is a higher level API.
+ *
+ * @param eventType - type of event to send
+ * content to send. Map from user_id to device_id to content object.
+ * @param txnId - transaction id. One will be made up if not
+ * supplied.
+ * @returns Promise which resolves: to an empty object `{}`
+ */
+ public sendToDevice(eventType: string, contentMap: SendToDeviceContentMap, txnId?: string): Promise<{}> {
+ const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
+ $eventType: eventType,
+ $txnId: txnId ? txnId : this.makeTxnId(),
+ });
+
+ const body = {
+ messages: utils.recursiveMapToObject(contentMap),
+ };
+
+ const targets = new Map<string, string[]>();
+
+ for (const [userId, deviceMessages] of contentMap) {
+ targets.set(userId, Array.from(deviceMessages.keys()));
+ }
+
+ logger.log(`PUT ${path}`, targets);
+
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * Sends events directly to specific devices using Matrix's to-device
+ * messaging system. The batch will be split up into appropriately sized
+ * batches for sending and stored in the store so they can be retried
+ * later if they fail to send. Retries will happen automatically.
+ * @param batch - The to-device messages to send
+ */
+ public queueToDevice(batch: ToDeviceBatch): Promise<void> {
+ return this.toDeviceMessageQueue.queueBatch(batch);
+ }
+
+ /**
+ * Get the third party protocols that can be reached using
+ * this HS
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> {
+ return this.http
+ .authedRequest<Record<string, IProtocol>>(Method.Get, "/thirdparty/protocols")
+ .then((response) => {
+ // sanity check
+ if (!response || typeof response !== "object") {
+ throw new Error(`/thirdparty/protocols did not return an object: ${response}`);
+ }
+ return response;
+ });
+ }
+
+ /**
+ * Get information on how a specific place on a third party protocol
+ * may be reached.
+ * @param protocol - The protocol given in getThirdpartyProtocols()
+ * @param params - Protocol-specific parameters, as given in the
+ * response to getThirdpartyProtocols()
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyLocation(
+ protocol: string,
+ params: { searchFields?: string[] },
+ ): Promise<IThirdPartyLocation[]> {
+ const path = utils.encodeUri("/thirdparty/location/$protocol", {
+ $protocol: protocol,
+ });
+
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ /**
+ * Get information on how a specific user on a third party protocol
+ * may be reached.
+ * @param protocol - The protocol given in getThirdpartyProtocols()
+ * @param params - Protocol-specific parameters, as given in the
+ * response to getThirdpartyProtocols()
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> {
+ // TODO: Types
+ const path = utils.encodeUri("/thirdparty/user/$protocol", {
+ $protocol: protocol,
+ });
+
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise<any> {
+ // TODO: Types
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ return this.http.requestOtherUrl(Method.Get, url);
+ }
+
+ public agreeToTerms(
+ serviceType: SERVICE_TYPES,
+ baseUrl: string,
+ accessToken: string,
+ termsUrls: string[],
+ ): Promise<{}> {
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ const headers = {
+ Authorization: "Bearer " + accessToken,
+ };
+ return this.http.requestOtherUrl(
+ Method.Post,
+ url,
+ {
+ user_accepts: termsUrls,
+ },
+ { headers },
+ );
+ }
+
+ /**
+ * Reports an event as inappropriate to the server, which may then notify the appropriate people.
+ * @param roomId - The room in which the event being reported is located.
+ * @param eventId - The event to report.
+ * @param score - The score to rate this content as where -100 is most offensive and 0 is inoffensive.
+ * @param reason - The reason the content is being reported. May be blank.
+ * @returns Promise which resolves to an empty object if successful
+ */
+ public reportEvent(roomId: string, eventId: string, score: number, reason: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/report/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId,
+ });
+
+ return this.http.authedRequest(Method.Post, path, undefined, { score, reason });
+ }
+
+ /**
+ * Fetches or paginates a room hierarchy as defined by MSC2946.
+ * Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server.
+ * @param roomId - The ID of the space-room to use as the root of the summary.
+ * @param limit - The maximum number of rooms to return per page.
+ * @param maxDepth - The maximum depth in the tree from the root room to return.
+ * @param suggestedOnly - Whether to only return rooms with suggested=true.
+ * @param fromToken - The opaque token to paginate a previous request.
+ * @returns the response, with next_batch & rooms fields.
+ */
+ public getRoomHierarchy(
+ roomId: string,
+ limit?: number,
+ maxDepth?: number,
+ suggestedOnly = false,
+ fromToken?: string,
+ ): Promise<IRoomHierarchy> {
+ const path = utils.encodeUri("/rooms/$roomId/hierarchy", {
+ $roomId: roomId,
+ });
+
+ const queryParams: QueryDict = {
+ suggested_only: String(suggestedOnly),
+ max_depth: maxDepth?.toString(),
+ from: fromToken,
+ limit: limit?.toString(),
+ };
+
+ return this.http
+ .authedRequest<IRoomHierarchy>(Method.Get, path, queryParams, undefined, {
+ prefix: ClientPrefix.V1,
+ })
+ .catch((e) => {
+ if (e.errcode === "M_UNRECOGNIZED") {
+ // fall back to the prefixed hierarchy API.
+ return this.http.authedRequest<IRoomHierarchy>(Method.Get, path, queryParams, undefined, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2946",
+ });
+ }
+
+ throw e;
+ });
+ }
+
+ /**
+ * Creates a new file tree space with the given name. The client will pick
+ * defaults for how it expects to be able to support the remaining API offered
+ * by the returned class.
+ *
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ * @param name - The name of the tree space.
+ * @returns Promise which resolves to the created space.
+ */
+ public async unstableCreateFileTree(name: string): Promise<MSC3089TreeSpace> {
+ const { room_id: roomId } = await this.createRoom({
+ name: name,
+ preset: Preset.PrivateChat,
+ power_level_content_override: {
+ ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
+ users: {
+ [this.getUserId()!]: 100,
+ },
+ },
+ creation_content: {
+ [RoomCreateTypeField]: RoomType.Space,
+ },
+ initial_state: [
+ {
+ type: UNSTABLE_MSC3088_PURPOSE.name,
+ state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name,
+ content: {
+ [UNSTABLE_MSC3088_ENABLED.name]: true,
+ },
+ },
+ {
+ type: EventType.RoomEncryption,
+ state_key: "",
+ content: {
+ algorithm: olmlib.MEGOLM_ALGORITHM,
+ },
+ },
+ ],
+ });
+ return new MSC3089TreeSpace(this, roomId);
+ }
+
+ /**
+ * Gets a reference to a tree space, if the room ID given is a tree space. If the room
+ * does not appear to be a tree space then null is returned.
+ *
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ * @param roomId - The room ID to get a tree space reference for.
+ * @returns The tree space, or null if not a tree space.
+ */
+ public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace | null {
+ const room = this.getRoom(roomId);
+ if (room?.getMyMembership() !== "join") return null;
+
+ const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
+ const purposeEvent = room.currentState.getStateEvents(
+ UNSTABLE_MSC3088_PURPOSE.name,
+ UNSTABLE_MSC3089_TREE_SUBTYPE.name,
+ );
+
+ if (!createEvent) throw new Error("Expected single room create event");
+
+ if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null;
+ if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null;
+
+ return new MSC3089TreeSpace(this, roomId);
+ }
+
+ /**
+ * Perform a single MSC3575 sliding sync request.
+ * @param req - The request to make.
+ * @param proxyBaseUrl - The base URL for the sliding sync proxy.
+ * @param abortSignal - Optional signal to abort request mid-flight.
+ * @returns The sliding sync response, or a standard error.
+ * @throws on non 2xx status codes with an object with a field "httpStatus":number.
+ */
+ public slidingSync(
+ req: MSC3575SlidingSyncRequest,
+ proxyBaseUrl?: string,
+ abortSignal?: AbortSignal,
+ ): Promise<MSC3575SlidingSyncResponse> {
+ const qps: Record<string, any> = {};
+ if (req.pos) {
+ qps.pos = req.pos;
+ delete req.pos;
+ }
+ if (req.timeout) {
+ qps.timeout = req.timeout;
+ delete req.timeout;
+ }
+ const clientTimeout = req.clientTimeout;
+ delete req.clientTimeout;
+ return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3575",
+ baseUrl: proxyBaseUrl,
+ localTimeoutMs: clientTimeout,
+ abortSignal,
+ });
+ }
+
+ /**
+ * @deprecated use supportsThreads() instead
+ */
+ public supportsExperimentalThreads(): boolean {
+ logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
+ return this.clientOpts?.experimentalThreadSupport || false;
+ }
+
+ /**
+ * A helper to determine thread support
+ * @returns a boolean to determine if threads are enabled
+ */
+ public supportsThreads(): boolean {
+ return this.clientOpts?.threadSupport || false;
+ }
+
+ /**
+ * A helper to determine intentional mentions support
+ * @returns a boolean to determine if intentional mentions are enabled
+ * @experimental
+ */
+ public supportsIntentionalMentions(): boolean {
+ return this.clientOpts?.intentionalMentions || false;
+ }
+
+ /**
+ * Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
+ * Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
+ * @param roomIdOrAlias - The ID or alias of the room to get the summary of.
+ * @param via - The list of servers which know about the room if only an ID was provided.
+ */
+ public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
+ const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
+ return this.http.authedRequest(Method.Get, path, { via }, undefined, {
+ prefix: "/_matrix/client/unstable/im.nheko.summary",
+ });
+ }
+
+ /**
+ * Processes a list of threaded events and adds them to their respective timelines
+ * @param room - the room the adds the threaded events
+ * @param threadedEvents - an array of the threaded events
+ * @param toStartOfTimeline - the direction in which we want to add the events
+ */
+ public processThreadEvents(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
+ room.processThreadedEvents(threadedEvents, toStartOfTimeline);
+ }
+
+ /**
+ * Processes a list of thread roots and creates a thread model
+ * @param room - the room to create the threads in
+ * @param threadedEvents - an array of thread roots
+ * @param toStartOfTimeline - the direction
+ */
+ public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
+ room.processThreadRoots(threadedEvents, toStartOfTimeline);
+ }
+
+ public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void {
+ this.processAggregatedTimelineEvents(room, events);
+ }
+
+ /**
+ * Calls aggregation functions for event types that are aggregated
+ * Polls and location beacons
+ * @param room - room the events belong to
+ * @param events - timeline events to be processed
+ * @returns
+ */
+ public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void {
+ if (!events?.length) return;
+ if (!room) return;
+
+ room.currentState.processBeaconEvents(events, this);
+ room.processPollEvents(events);
+ }
+
+ /**
+ * Fetches information about the user for the configured access token.
+ */
+ public async whoami(): Promise<IWhoamiResponse> {
+ return this.http.authedRequest(Method.Get, "/account/whoami");
+ }
+
+ /**
+ * Find the event_id closest to the given timestamp in the given direction.
+ * @returns Resolves: A promise of an object containing the event_id and
+ * origin_server_ts of the closest event to the timestamp in the given direction
+ * @returns Rejects: when the request fails (module:http-api.MatrixError)
+ */
+ public async timestampToEvent(
+ roomId: string,
+ timestamp: number,
+ dir: Direction,
+ ): Promise<TimestampToEventResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
+ $roomId: roomId,
+ });
+ const queryParams = {
+ ts: timestamp.toString(),
+ dir: dir,
+ };
+
+ try {
+ return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
+ prefix: ClientPrefix.V1,
+ });
+ } catch (err) {
+ // Fallback to the prefixed unstable endpoint. Since the stable endpoint is
+ // new, we should also try the unstable endpoint before giving up. We can
+ // remove this fallback request in a year (remove after 2023-11-28).
+ if (
+ (<MatrixError>err).errcode === "M_UNRECOGNIZED" &&
+ // XXX: The 400 status code check should be removed in the future
+ // when Synapse is compliant with MSC3743.
+ ((<MatrixError>err).httpStatus === 400 ||
+ // This the correct standard status code for an unsupported
+ // endpoint according to MSC3743. Not Found and Method Not Allowed
+ // both indicate that this endpoint+verb combination is
+ // not supported.
+ (<MatrixError>err).httpStatus === 404 ||
+ (<MatrixError>err).httpStatus === 405)
+ ) {
+ return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3030",
+ });
+ }
+
+ throw err;
+ }
+ }
+}
+
+/**
+ * recalculates an accurate notifications count on event decryption.
+ * Servers do not have enough knowledge about encrypted events to calculate an
+ * accurate notification_count
+ */
+export function fixNotificationCountOnDecryption(cli: MatrixClient, event: MatrixEvent): void {
+ const ourUserId = cli.getUserId();
+ const eventId = event.getId();
+
+ const room = cli.getRoom(event.getRoomId());
+ if (!room || !ourUserId || !eventId) return;
+
+ const oldActions = event.getPushActions();
+ const actions = cli.getPushActionsForEvent(event, true);
+
+ const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
+
+ const currentHighlightCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
+
+ // Ensure the unread counts are kept up to date if the event is encrypted
+ // We also want to make sure that the notification count goes up if we already
+ // have encrypted events to avoid other code from resetting 'highlight' to zero.
+ const oldHighlight = !!oldActions?.tweaks?.highlight;
+ const newHighlight = !!actions?.tweaks?.highlight;
+
+ let hasReadEvent;
+ if (isThreadEvent) {
+ const thread = room.getThread(event.threadRootId);
+ hasReadEvent = thread
+ ? thread.hasUserReadEvent(ourUserId, eventId)
+ : // If the thread object does not exist in the room yet, we don't
+ // want to calculate notification for this event yet. We have not
+ // restored the read receipts yet and can't accurately calculate
+ // notifications at this stage.
+ //
+ // This issue can likely go away when MSC3874 is implemented
+ true;
+ } else {
+ hasReadEvent = room.hasUserReadEvent(ourUserId, eventId);
+ }
+
+ if (hasReadEvent) {
+ // If the event has been read, ignore it.
+ return;
+ }
+
+ if (oldHighlight !== newHighlight || currentHighlightCount > 0) {
+ // TODO: Handle mentions received while the client is offline
+ // See also https://github.com/vector-im/element-web/issues/9069
+ let newCount = currentHighlightCount;
+ if (newHighlight && !oldHighlight) newCount++;
+ if (!newHighlight && oldHighlight) newCount--;
+
+ if (isThreadEvent) {
+ room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
+ } else {
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount);
+ }
+ }
+
+ // Total count is used to typically increment a room notification counter, but not loudly highlight it.
+ const currentTotalCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event);
+
+ // `notify` is used in practice for incrementing the total count
+ const newNotify = !!actions?.notify;
+
+ // The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore
+ // the server here as it's always going to tell us to increment for encrypted events.
+ if (newNotify) {
+ if (isThreadEvent) {
+ room.setThreadUnreadNotificationCount(
+ event.threadRootId,
+ NotificationCountType.Total,
+ currentTotalCount + 1,
+ );
+ } else {
+ room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1);
+ }
+ }
+}