/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
Copyright 2019 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.
 * @module client
 */

import url from "url";
import {EventEmitter} from "events";
import {MatrixBaseApis} from "./base-apis";
import {Filter} from "./filter";
import {SyncApi} from "./sync";
import {EventStatus, MatrixEvent} from "./models/event";
import {EventTimeline} from "./models/event-timeline";
import {SearchResult} from "./models/search-result";
import {StubStore} from "./store/stub";
import {createNewMatrixCall} from "./webrtc/call";
import {CallEventHandler} from './webrtc/callEventHandler';
import * as utils from './utils';
import {sleep} from './utils';
import {
    MatrixError,
    PREFIX_MEDIA_R0,
    PREFIX_UNSTABLE,
    retryNetworkOperation,
} from "./http-api";
import {getHttpUriForMxc} from "./content-repo";
import * as ContentHelpers from "./content-helpers";
import * as olmlib from "./crypto/olmlib";
import {ReEmitter} from './ReEmitter';
import {RoomList} from './crypto/RoomList';
import {logger} from './logger';
import {Crypto, isCryptoAvailable, fixBackupKey} from './crypto';
import {decodeRecoveryKey} from './crypto/recoverykey';
import {keyFromAuthData} from './crypto/key_passphrase';
import {randomString} from './randomstring';
import {PushProcessor} from "./pushprocessor";
import {encodeBase64, decodeBase64} from "./crypto/olmlib";
import { User } from "./models/user";
import {AutoDiscovery} from "./autodiscovery";
import {DEHYDRATION_ALGORITHM} from "./crypto/dehydration";

const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED = 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

function keysFromRecoverySession(sessions, decryptionKey, roomId) {
    const keys = [];
    for (const [sessionId, sessionData] of Object.entries(sessions)) {
        try {
            const decrypted = keyFromRecoverySession(sessionData, decryptionKey);
            decrypted.session_id = sessionId;
            decrypted.room_id = roomId;
            keys.push(decrypted);
        } catch (e) {
            logger.log("Failed to decrypt megolm session from backup", e);
        }
    }
    return keys;
}

function keyFromRecoverySession(session, decryptionKey) {
    return JSON.parse(decryptionKey.decrypt(
        session.session_data.ephemeral,
        session.session_data.mac,
        session.session_data.ciphertext,
    ));
}

/**
 * Construct 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.
 * @constructor
 * @extends {external:EventEmitter}
 * @extends {module:base-apis~MatrixBaseApis}
 *
 * @param {Object} opts The configuration options for this client.
 * @param {string} opts.baseUrl Required. The base URL to the client-server
 * HTTP API.
 * @param {string} opts.idBaseUrl Optional. The base identity server URL for
 * identity server requests.
 * @param {Function} opts.request Required. The function to invoke for HTTP
 * requests. The value of this property is typically <code>require("request")
 * </code> as it returns a function which meets the required interface. See
 * {@link requestFunction} for more information.
 *
 * @param {string} opts.accessToken The access_token for this user.
 *
 * @param {string} opts.userId The user ID for this user.
 *
 * @param {Object} opts.deviceToImport 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, opts.deviceId and opts.userId should **NOT** be provided
 *     (they are present in the exported data).
 *
 * @param {string} opts.pickleKey Key used to pickle olm objects or other
 *     sensitive data.
 *
 * @param {IdentityServerProvider} [opts.identityServer]
 * Optional. A provider object with one function `getAccessToken`, which is a
 * callback that returns a Promise<String> of an identity access token to supply
 * with identity requests. If the object is unset, no access token will be
 * supplied.
 * 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.
 *
 * @param {Object=} opts.store
 *    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.
 *
 * @param {module:store/session/webstorage~WebStorageSessionStore} opts.sessionStore
 *    A store to be used for end-to-end crypto session data. Most data has been
 *    migrated out of here to `cryptoStore` instead. If not specified,
 *    end-to-end crypto will be disabled. The `createClient` helper
 *    _will not_ create this store at the moment.
 *
 * @param {module:crypto.store.base~CryptoStore} opts.cryptoStore
 *    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.
 *
 * @param {string=} opts.deviceId A unique identifier for this device; used for
 *    tracking things like crypto keys and access tokens.  If not specified,
 *    end-to-end crypto will be disabled.
 *
 * @param {Object} opts.scheduler Optional. 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 module:scheduler~MatrixScheduler#setProcessFunction}.
 *
 * @param {Object} opts.queryParams Optional. Extra query parameters to append
 * to all requests with this client. Useful for application services which require
 * <code>?user_id=</code>.
 *
 * @param {Number=} opts.localTimeoutMs Optional. The default maximum amount of
 * time to wait before timing out HTTP requests. If not specified, there is no timeout.
 *
 * @param {boolean} [opts.useAuthorizationHeader = false] Set to true to use
 * Authorization header instead of query param to send the access token to the server.
 *
 * @param {boolean} [opts.timelineSupport = false] Set to true to enable
 * improved timeline support ({@link
 * module:client~MatrixClient#getEventTimeline 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.
 *
 * @param {boolean} [opts.unstableClientRelationAggregation = false]
 * Optional. Set to true to enable client-side aggregation of event relations
 * via `EventTimelineSet#getRelationsForEvent`.
 * This feature is currently unstable and the API may change without notice.
 *
 * @param {Array} [opts.verificationMethods] Optional. The verification method
 * that the application can handle.  Each element should be an item from {@link
 * module:crypto~verificationMethods verificationMethods}, or a class that
 * implements the {$link module:crypto/verification/Base verifier interface}.
 *
 * @param {boolean} [opts.forceTURN]
 * Optional. Whether relaying calls through a TURN server should be forced.
 *
 * * @param {boolean} [opts.iceCandidatePoolSize]
 * Optional. 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.
 *
 * @param {boolean} [opts.supportsCallTransfer]
 * Optional. True to advertise support for call transfers to other parties on Matrix calls.
 *
 * @param {boolean} [opts.fallbackICEServerAllowed]
 * Optional. 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.
 *
 * @param {boolean} [opts.usingExternalCrypto]
 * Optional. 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.
 *
 * @param {object} opts.cryptoCallbacks Optional. Callbacks for crypto and cross-signing.
 *     The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @param {function} [opts.cryptoCallbacks.getCrossSigningKey]
 * Optional. Function to call when a cross-signing private key is needed.
 * Secure Secret Storage will be used by default if this is unset.
 * Args:
 *    {string} type The type of key needed.  Will be one of "master",
 *      "self_signing", or "user_signing"
 *    {Uint8Array} publicKey The public key matching the expected private key.
 *        This can be passed to checkPrivateKey() along with the private key
 *        in order to check that a given private key matches what is being
 *        requested.
 *   Should return a promise that resolves with the private key as a
 *   UInt8Array or rejects with an error.
 *
 * @param {function} [opts.cryptoCallbacks.saveCrossSigningKeys]
 * Optional. Called when new private keys for cross-signing need to be saved.
 * Secure Secret Storage will be used by default if this is unset.
 * Args:
 *   {object} keys the private keys to save. Map of key name to private key
 *       as a UInt8Array. The getPrivateKey callback above will be called
 *       with the corresponding key name when the keys are required again.
 *
 * @param {function} [opts.cryptoCallbacks.shouldUpgradeDeviceVerifications]
 * Optional. Called when there are device-to-device verifications that can be
 * upgraded into cross-signing verifications.
 * Args:
 *   {object} users The users whose device verifications can be
 *     upgraded to cross-signing verifications.  This will be a map of user IDs
 *     to objects with the properties `devices` (array of the user's devices
 *     that verified their cross-signing key), and `crossSigningInfo` (the
 *     user's cross-signing information)
 * Should return a promise which resolves with an array of the user IDs who
 * should be cross-signed.
 *
 * @param {function} [opts.cryptoCallbacks.getSecretStorageKey]
 * Optional. Function called when an encryption key for secret storage
 *     is required. One or more keys will be described in the keys object.
 *     The callback function should return a promise with an array of:
 *     [<key name>, <UInt8Array private key>] or null if it cannot provide
 *     any of the keys.
 * Args:
 *   {object} keys Information about the keys:
 *       {
 *           keys: {
 *               <key name>: {
 *                   "algorithm": "m.secret_storage.v1.aes-hmac-sha2",
 *                   "passphrase": {
 *                       "algorithm": "m.pbkdf2",
 *                       "iterations": 500000,
 *                       "salt": "..."
 *                   },
 *                   "iv": "...",
 *                   "mac": "..."
 *               }, ...
 *           }
 *       }
 *   {string} name the name of the value we want to read out of SSSS, for UI purposes.
 *
 * @param {function} [opts.cryptoCallbacks.cacheSecretStorageKey]
 * Optional. Function called when a new encryption key for secret storage
 * has been created. This allows the application a chance to cache this key if
 * desired to avoid user prompts.
 * Args:
 *   {string} keyId the ID of the new key
 *   {object} keyInfo Infomation about the key as above for `getSecretStorageKey`
 *   {Uint8Array} key the new private key
 *
 * @param {function} [opts.cryptoCallbacks.onSecretRequested]
 * Optional. Function called when a request for a secret is received from another
 * device.
 * Args:
 *   {string} name The name of the secret being requested.
 *   {string} userId The user ID of the client requesting
 *   {string} deviceId The device ID of the client requesting the secret.
 *   {string} requestId The ID of the request. Used to match a
 *     corresponding `crypto.secrets.request_cancelled`. The request ID will be
 *     unique per sender, device pair.
 *   {DeviceTrustLevel} deviceTrust: The trust status of the device requesting
 *     the secret as returned by {@link module:client~MatrixClient#checkDeviceTrust}.
 */
export function MatrixClient(opts) {
    opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
    opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);

    MatrixBaseApis.call(this, opts);

    this.olmVersion = null; // Populated after initCrypto is done

    this.reEmitter = new ReEmitter(this);

    this.usingExternalCrypto = opts.usingExternalCrypto;

    this.store = opts.store || new StubStore();

    this.deviceId = opts.deviceId || null;

    const userId = (opts.userId || null);
    this.credentials = {
        userId: userId,
    };

    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) {
        const self = this;
        this.scheduler.setProcessFunction(async function(eventToSend) {
            const room = self.getRoom(eventToSend.getRoomId());
            if (eventToSend.status !== EventStatus.SENDING) {
                _updatePendingEventStatus(room, eventToSend,
                                          EventStatus.SENDING);
            }
            const res = await _sendEventHttpRequest(self, 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;
        });
    }
    this.clientRunning = false;

    // try constructing a MatrixCall to see if we are running in an environment
    // which has WebRTC. If we are, listen for and handle m.call.* events.
    const call = createNewMatrixCall(this);
    this._supportsVoip = false;
    if (call) {
        this._callEventHandler = new CallEventHandler(this);
        this._supportsVoip = true;
    } else {
        this._callEventHandler = null;
    }
    this._syncingRetry = null;
    this._syncApi = null;
    this._peekSync = null;
    this._isGuest = false;
    this._ongoingScrollbacks = {};
    this.timelineSupport = Boolean(opts.timelineSupport);
    this.urlPreviewCache = {}; // key=preview key, value=Promise for preview (may be an error)
    this._notifTimelineSet = null;
    this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;

    this._crypto = null;
    this._cryptoStore = opts.cryptoStore;
    this._sessionStore = opts.sessionStore;
    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;

    // 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);

    // The pushprocessor caches useful things, so keep one and re-use it
    this._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
    this._serverVersionsPromise = null;

    this._cachedCapabilities = null; // { capabilities: {}, lastUpdated: timestamp }

    this._clientWellKnown = undefined;
    this._clientWellKnownPromise = undefined;

    this._turnServers = [];
    this._turnServersExpiry = 0;
    this._checkTurnServersIntervalID = null;

    // 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("Event.decrypted", (event) => {
        const oldActions = event.getPushActions();
        const actions = this._pushProcessor.actionsForEvent(event);
        event.setPushActions(actions); // Might as well while we're here

        const room = this.getRoom(event.getRoomId());
        if (!room) return;

        const currentCount = room.getUnreadNotificationCount("highlight");

        // 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 && oldActions.tweaks
            ? !!oldActions.tweaks.highlight : false;
        const newHighlight = actions && actions.tweaks
            ? !!actions.tweaks.highlight : false;
        if (oldHighlight !== newHighlight || currentCount > 0) {
            // TODO: Handle mentions received while the client is offline
            // See also https://github.com/vector-im/element-web/issues/9069
            if (!room.hasUserReadEvent(this.getUserId(), event.getId())) {
                let newCount = currentCount;
                if (newHighlight && !oldHighlight) newCount++;
                if (!newHighlight && oldHighlight) newCount--;
                room.setUnreadNotificationCount("highlight", newCount);

                // Fix 'Mentions Only' rooms from not having the right badge count
                const totalCount = room.getUnreadNotificationCount('total');
                if (totalCount < newCount) {
                    room.setUnreadNotificationCount('total', newCount);
                }
            }
        }
    });

    // 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("Room.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 => {
                return Object.keys(content[eid]['m.read']).includes(this.getUserId());
            }).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 &&
                    pushActions.tweaks.highlight ? 1 : 0;
            }

            // Note: we don't need to handle 'total' notifications because the counts
            // will come from the server.
            room.setUnreadNotificationCount("highlight", highlightCount);
        }
    });
}
utils.inherits(MatrixClient, EventEmitter);
utils.extend(MatrixClient.prototype, MatrixBaseApis.prototype);

/**
 * 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.
 *
 * @return {Promise} Resolves to undefined if a device could not be dehydrated, or
 *     to the new device ID if the dehydration was successful.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.rehydrateDevice = async function() {
    if (this._crypto) {
        throw new Error("Cannot rehydrate device after crypto is initialized");
    }

    if (!this._cryptoCallbacks.getDehydrationKey) {
        return;
    }

    const getDeviceResult = 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(
            undefined,
            "POST",
            "/dehydrated_device/claim",
            undefined,
            {
                device_id: getDeviceResult.device_id,
            },
            {
                prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
            },
        );

        if (rehydrateResult.success === true) {
            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
 * @return {Promise} A promise of an object containing the dehydrated device
 */
MatrixClient.prototype.getDehydratedDevice = async function() {
    try {
        return await this._http.authedRequest(
            undefined,
            "GET",
            "/dehydrated_device",
            undefined, undefined,
            {
                prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
            },
        );
    } catch (e) {
        logger.info("could not get dehydrated device", e.toString());
        return;
    }
};

/**
 * Set the dehydration key.  This will also periodically dehydrate devices to
 * the server.
 *
 * @param {Uint8Array} key the dehydration key
 * @param {object} [keyInfo] Information about the key.  Primarily for
 *     information about how to generate the key from a passphrase.
 * @param {string} [deviceDisplayName] The device display name for the
 *     dehydrated device.
 * @return {Promise} A promise that resolves when the dehydrated device is stored.
 */
MatrixClient.prototype.setDehydrationKey = async function(
    key, keyInfo = {}, deviceDisplayName = undefined,
) {
    if (!(this._crypto)) {
        logger.warn('not dehydrating device if crypto is not enabled');
        return;
    }
    return await this._crypto._dehydrationManager.setKeyAndQueueDehydration(
        key, keyInfo, deviceDisplayName,
    );
};

/**
 * Creates a new dehydrated device (without queuing periodic dehydration)
 * @param {Uint8Array} key the dehydration key
 * @param {object} [keyInfo] Information about the key.  Primarily for
 *     information about how to generate the key from a passphrase.
 * @param {string} [deviceDisplayName] The device display name for the
 *     dehydrated device.
 * @return {Promise<String>} the device id of the newly created dehydrated device
 */
MatrixClient.prototype.createDehydratedDevice = async function(
    key, keyInfo = {}, deviceDisplayName = undefined,
) {
    if (!(this._crypto)) {
        logger.warn('not dehydrating device if crypto is not enabled');
        return;
    }
    await this._crypto._dehydrationManager.setKey(
        key, keyInfo, deviceDisplayName,
    );
    return await this._crypto._dehydrationManager.dehydrateDevice();
};

MatrixClient.prototype.exportDevice = async function() {
    if (!(this._crypto)) {
        logger.warn('not exporting device if crypto is not enabled');
        return;
    }
    return {
        userId: this.credentials.userId,
        deviceId: this.deviceId,
        olmDevice: await this._crypto._olmDevice.export(),
    };
};

/**
 * Clear any data out of the persistent stores used by the client.
 *
 * @returns {Promise} Promise which resolves when the stores have been cleared.
 */
MatrixClient.prototype.clearStores = function() {
    if (this._clientRunning) {
        throw new Error("Cannot clear stores while client is running");
    }

    const promises = [];

    promises.push(this.store.deleteAllData());
    if (this._cryptoStore) {
        promises.push(this._cryptoStore.deleteAllData());
    }
    return Promise.all(promises);
};

/**
 * Get the user-id of the logged-in user
 *
 * @return {?string} MXID for the logged-in user, or null if not logged in
 */
MatrixClient.prototype.getUserId = function() {
    if (this.credentials && this.credentials.userId) {
        return this.credentials.userId;
    }
    return null;
};

/**
 * Get the domain for this client's MXID
 * @return {?string} Domain of this MXID
 */
MatrixClient.prototype.getDomain = function() {
    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".
 * @return {?string} The user ID localpart or null.
 */
MatrixClient.prototype.getUserIdLocalpart = function() {
    if (this.credentials && this.credentials.userId) {
        return this.credentials.userId.split(":")[0].substring(1);
    }
    return null;
};

/**
 * Get the device ID of this client
 * @return {?string} device ID
 */
MatrixClient.prototype.getDeviceId = function() {
    return this.deviceId;
};


/**
 * Check if the runtime environment supports VoIP calling.
 * @return {boolean} True if VoIP is supported.
 */
MatrixClient.prototype.supportsVoip = function() {
    return this._supportsVoip;
};

/**
 * Set whether VoIP calls are forced to use only TURN
 * candidates. This is the same as the forceTURN option
 * when creating the client.
 * @param {bool} forceTURN True to force use of TURN servers
 */
MatrixClient.prototype.setForceTURN = function(forceTURN) {
    this._forceTURN = forceTURN;
};

/**
 * Set whether to advertise transfer support to other parties on Matrix calls.
 * @param {bool} supportsCallTransfer True to advertise the 'm.call.transferee' capability
 */
MatrixClient.prototype.setSupportsCallTransfer = function(supportsCallTransfer) {
    this._supportsCallTransfer = supportsCallTransfer;
};

/**
 * Get the current sync state.
 * @return {?string} the sync state, which may be null.
 * @see module:client~MatrixClient#event:"sync"
 */
MatrixClient.prototype.getSyncState = function() {
    if (!this._syncApi) {
        return null;
    }
    return this._syncApi.getSyncState();
};

/**
 * 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.
 * @return {?Object}
 */
MatrixClient.prototype.getSyncStateData = function() {
    if (!this._syncApi) {
        return null;
    }
    return this._syncApi.getSyncStateData();
};

/**
 * Whether the initial sync has completed.
 * @return {boolean} True if at least on sync has happened.
 */
MatrixClient.prototype.isInitialSyncComplete = function() {
    const state = this.getSyncState();
    if (!state) {
        return false;
    }
    return state === "PREPARED" || state === "SYNCING";
};

/**
 * Return whether the client is configured for a guest account.
 * @return {boolean} True if this is a guest access_token (or no token is supplied).
 */
MatrixClient.prototype.isGuest = function() {
    return this._isGuest;
};

/**
 * Return the provided scheduler, if any.
 * @return {?module:scheduler~MatrixScheduler} The scheduler or null
 */
MatrixClient.prototype.getScheduler = function() {
    return this.scheduler;
};

/**
 * Set whether this client is a guest account. <b>This method is experimental
 * and may change without warning.</b>
 * @param {boolean} isGuest True if this is a guest account.
 */
MatrixClient.prototype.setGuest = function(isGuest) {
    // 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._isGuest = isGuest;
};

/**
 * Retry a backed off syncing request immediately. This should only be used when
 * the user <b>explicitly</b> attempts to retry their lost connection.
 * @return {boolean} True if this resulted in a request being retried.
 */
MatrixClient.prototype.retryImmediately = function() {
    return this._syncApi.retryImmediately();
};

/**
 * Return the global notification EventTimelineSet, if any
 *
 * @return {EventTimelineSet} the globl notification EventTimelineSet
 */
MatrixClient.prototype.getNotifTimelineSet = function() {
    return this._notifTimelineSet;
};

/**
 * Set the global notification EventTimelineSet
 *
 * @param {EventTimelineSet} notifTimelineSet
 */
MatrixClient.prototype.setNotifTimelineSet = function(notifTimelineSet) {
    this._notifTimelineSet = notifTimelineSet;
};

/**
 * Gets the capabilities of the homeserver. Always returns an object of
 * capability keys and their options, which may be empty.
 * @param {boolean} fresh True to ignore any cached values.
 * @return {Promise} Resolves to the capabilities of the homeserver
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getCapabilities = function(fresh=false) {
    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);
        }
    }

    // We swallow errors because we need a default object anyhow
    return this._http.authedRequest(
        undefined, "GET", "/capabilities",
    ).catch((e) => {
        logger.error(e);
        return null; // otherwise consume the error
    }).then((r) => {
        if (!r) 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: capabilities,
            expiration: now + cacheMs,
        };

        logger.log("Caching capabilities: ", capabilities);
        return capabilities;
    });
};

// Crypto bits
// ===========

/**
 * Initialise support for end-to-end encryption in this client
 *
 * 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.
 */
MatrixClient.prototype.initCrypto = async function() {
    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._crypto) {
        logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
        return;
    }

    if (!this._sessionStore) {
        // this is temporary, the sessionstore is supposed to be going away
        throw new Error(`Cannot enable encryption: no sessionStore provided`);
    }
    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,
        this._sessionStore,
        userId, this.deviceId,
        this.store,
        this._cryptoStore,
        this._roomList,
        this._verificationMethods,
    );

    this.reEmitter.reEmit(crypto, [
        "crypto.keyBackupFailed",
        "crypto.keyBackupSessionsRemaining",
        "crypto.roomKeyRequest",
        "crypto.roomKeyRequestCancellation",
        "crypto.warning",
        "crypto.devicesUpdated",
        "crypto.willUpdateDevices",
        "deviceVerificationChanged",
        "userTrustStatusChanged",
        "crossSigning.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);
    this._crypto = crypto;
};


/**
 * Is end-to-end crypto enabled for this client.
 * @return {boolean} True if end-to-end is enabled.
 */
MatrixClient.prototype.isCryptoEnabled = function() {
    return this._crypto !== null;
};

/**
 * Get the Ed25519 key for this device
 *
 * @return {?string} base64-encoded ed25519 key. Null if crypto is
 *    disabled.
 */
MatrixClient.prototype.getDeviceEd25519Key = function() {
    if (!this._crypto) {
        return null;
    }
    return this._crypto.getDeviceEd25519Key();
};

/**
 * Get the Curve25519 key for this device
 *
 * @return {?string} base64-encoded curve25519 key. Null if crypto is
 *    disabled.
 */
MatrixClient.prototype.getDeviceCurve25519Key = function() {
    if (!this._crypto) {
        return null;
    }
    return this._crypto.getDeviceCurve25519Key();
};

/**
 * Upload the device keys to the homeserver.
 * @return {object} A promise that will resolve when the keys are uploaded.
 */
MatrixClient.prototype.uploadKeys = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    return this._crypto.uploadDeviceKeys();
};

/**
 * Download the keys for a list of users and stores the keys in the session
 * store.
 * @param {Array} userIds The users to fetch.
 * @param {bool} forceDownload Always download the keys even if cached.
 *
 * @return {Promise} A promise which resolves to a map userId->deviceId->{@link
 * module:crypto~DeviceInfo|DeviceInfo}.
 */
MatrixClient.prototype.downloadKeys = function(userIds, forceDownload) {
    if (this._crypto === null) {
        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 {string} userId the user to list keys for.
 *
 * @return {module:crypto/deviceinfo[]} list of devices
 */
MatrixClient.prototype.getStoredDevicesForUser = function(userId) {
    if (this._crypto === null) {
        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 {string} userId the user to list keys for.
 * @param {string} deviceId unique identifier for the device
 *
 * @return {module:crypto/deviceinfo} device or null
 */
MatrixClient.prototype.getStoredDevice = function(userId, deviceId) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.getStoredDevice(userId, deviceId) || null;
};

/**
 * Mark the given device as verified
 *
 * @param {string} userId owner of the device
 * @param {string} deviceId unique identifier for the device or user's
 * cross-signing public key ID.
 *
 * @param {boolean=} verified whether to mark the device as verified. defaults
 *   to 'true'.
 *
 * @returns {Promise}
 *
 * @fires module:client~event:MatrixClient"deviceVerificationChanged"
 */
MatrixClient.prototype.setDeviceVerified = function(userId, deviceId, verified) {
    if (verified === undefined) {
        verified = true;
    }
    const prom = _setDeviceVerification(this, userId, deviceId, verified, 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._crypto.checkKeyBackup();
    }
    return prom;
};

/**
 * Mark the given device as blocked/unblocked
 *
 * @param {string} userId owner of the device
 * @param {string} deviceId unique identifier for the device or user's
 * cross-signing public key ID.
 *
 * @param {boolean=} blocked whether to mark the device as blocked. defaults
 *   to 'true'.
 *
 * @returns {Promise}
 *
 * @fires module:client~event:MatrixClient"deviceVerificationChanged"
 */
MatrixClient.prototype.setDeviceBlocked = function(userId, deviceId, blocked) {
    if (blocked === undefined) {
        blocked = true;
    }
    return _setDeviceVerification(this, userId, deviceId, null, blocked);
};

/**
 * Mark the given device as known/unknown
 *
 * @param {string} userId owner of the device
 * @param {string} deviceId unique identifier for the device or user's
 * cross-signing public key ID.
 *
 * @param {boolean=} known whether to mark the device as known. defaults
 *   to 'true'.
 *
 * @returns {Promise}
 *
 * @fires module:client~event:MatrixClient"deviceVerificationChanged"
 */
MatrixClient.prototype.setDeviceKnown = function(userId, deviceId, known) {
    if (known === undefined) {
        known = true;
    }
    return _setDeviceVerification(this, userId, deviceId, null, null, known);
};

async function _setDeviceVerification(
    client, userId, deviceId, verified, blocked, known,
) {
    if (!client._crypto) {
        throw new Error("End-to-End encryption disabled");
    }
    await client._crypto.setDeviceVerification(
        userId, deviceId, verified, blocked, known,
    );
}

/**
 * Request a key verification from another user, using a DM.
 *
 * @param {string} userId the user to request verification with
 * @param {string} roomId the room to use for verification
 *
 * @returns {Promise<module:crypto/verification/request/VerificationRequest>} resolves to a VerificationRequest
 *    when the request has been sent to the other party.
 */
MatrixClient.prototype.requestVerificationDM = function(userId, roomId) {
    if (this._crypto === null) {
        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 {string} roomId the room to use for verification
 *
 * @returns {module:crypto/verification/request/VerificationRequest?} the VerificationRequest that is in progress, if any
 */
MatrixClient.prototype.findVerificationRequestDMInProgress = function(roomId) {
    if (this._crypto === null) {
        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 {string} userId the ID of the user to query
 *
 * @returns {module:crypto/verification/request/VerificationRequest[]} the VerificationRequests that are in progress
 */
MatrixClient.prototype.getVerificationRequestsToDeviceInProgress = function(userId) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.getVerificationRequestsToDeviceInProgress(userId);
};

/**
 * Request a key verification from another user.
 *
 * @param {string} userId the user to request verification with
 * @param {Array} devices array of device IDs to send requests to.  Defaults to
 *    all devices owned by the user
 *
 * @returns {Promise<module:crypto/verification/request/VerificationRequest>} resolves to a VerificationRequest
 *    when the request has been sent to the other party.
 */
MatrixClient.prototype.requestVerification = function(userId, devices) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.requestVerification(userId, devices);
};

/**
 * Begin a key verification.
 *
 * @param {string} method the verification method to use
 * @param {string} userId the user to verify keys with
 * @param {string} deviceId the device to verify
 *
 * @returns {module:crypto/verification/Base} a verification object
 */
MatrixClient.prototype.beginKeyVerification = function(
    method, userId, deviceId,
) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.beginKeyVerification(method, userId, deviceId);
};

/**
 * 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 {boolean} value whether to blacklist all unverified devices by default
 */
MatrixClient.prototype.setGlobalBlacklistUnverifiedDevices = function(value) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    this._crypto.setGlobalBlacklistUnverifiedDevices(value);
};

/**
 * @return {boolean} whether to blacklist all unverified devices by default
 */
MatrixClient.prototype.getGlobalBlacklistUnverifiedDevices = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.getGlobalBlacklistUnverifiedDevices();
};

/**
 * 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 {boolean} value whether error on unknown devices
 */
MatrixClient.prototype.setGlobalErrorOnUnknownDevices = function(value) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    this._crypto.setGlobalErrorOnUnknownDevices(value);
};

/**
 * @return {boolean} whether to error on unknown devices
 *
 * This API is currently UNSTABLE and may change or be removed without notice.
 */
MatrixClient.prototype.getGlobalErrorOnUnknownDevices = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    return this._crypto.getGlobalErrorOnUnknownDevices();
};

/**
 * Add methods that call the corresponding method in this._crypto
 *
 * @param {class} MatrixClient the class to add the method to
 * @param {string} names the names of the methods to call
 */
function wrapCryptoFuncs(MatrixClient, names) {
    for (const name of names) {
        MatrixClient.prototype[name] = function(...args) {
            if (!this._crypto) { // eslint-disable-line no-invalid-this
                throw new Error("End-to-end encryption disabled");
            }

            return this._crypto[name](...args); // eslint-disable-line no-invalid-this
        };
    }
}

/**
 * Get the user's cross-signing key ID.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#getCrossSigningId
 * @param {string} [type=master] The type of key to get the ID of.  One of
 *     "master", "self_signing", or "user_signing".  Defaults to "master".
 *
 * @returns {string} the key ID
 */

/**
 * Get the cross signing information for a given user.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#getStoredCrossSigningForUser
 * @param {string} userId the user ID to get the cross-signing info for.
 *
 * @returns {CrossSigningInfo} the cross signing information for the user.
 */

/**
 * Check whether a given user is trusted.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#checkUserTrust
 * @param {string} userId The ID of the user to check.
 *
 * @returns {UserTrustLevel}
 */

/**
 * Check whether a given device is trusted.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#checkDeviceTrust
 * @param {string} userId The ID of the user whose devices is to be checked.
 * @param {string} deviceId The ID of the device to check
 *
 * @returns {DeviceTrustLevel}
 */

/**
 * 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.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#checkOwnCrossSigningTrust
 */

/**
 * 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.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#checkCrossSigningPrivateKey
 * @param {Uint8Array} privateKey The private key
 * @param {string} expectedPublicKey The public key
 * @returns {boolean} true if the key matches, otherwise false
 */

/**
 * Perform any background tasks that can be done before a message is ready to
 * send, in order to speed up sending of the message.
 *
 * @function module:client~MatrixClient#prepareToEncrypt
 * @param {module:models/room} room the room the event is in
 */

/**
 * 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.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#isCrossSigningReady
 * @return {bool} True if cross-signing is ready to be used on this device
 */

/**
 * 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.
 *
 * @function module:client~MatrixClient#bootstrapCrossSigning
 * @param {function} opts.authUploadDeviceSigningKeys Function
 * called to await an interactive auth flow when uploading device signing keys.
 * @param {bool} [opts.setupNewCrossSigning] Optional. Reset even if keys
 * already exist.
 * Args:
 *     {function} A function that makes the request requiring auth. Receives the
 *     auth data as an object. Can be called multiple times, first with an empty
 *     authDict, to obtain the flows.
 */

wrapCryptoFuncs(MatrixClient, [
    "getCrossSigningId",
    "getStoredCrossSigningForUser",
    "checkUserTrust",
    "checkDeviceTrust",
    "checkOwnCrossSigningTrust",
    "checkCrossSigningPrivateKey",
    "legacyDeviceVerification",
    "prepareToEncrypt",
    "isCrossSigningReady",
    "bootstrapCrossSigning",
    "getCryptoTrustCrossSignedDevices",
    "setCryptoTrustCrossSignedDevices",
    "countSessionsNeedingBackup",
]);

/**
 * Get information about the encryption of an event
 *
 * @function module:client~MatrixClient#getEventEncryptionInfo
 *
 * @param {module:models/event.MatrixEvent} event event to be checked
 *
 * @return {object} An object with the fields:
 *    - encrypted: whether the event is encrypted (if not encrypted, some of the
 *      other properties may not be set)
 *    - senderKey: the sender's key
 *    - algorithm: the algorithm used to encrypt the event
 *    - authenticated: whether we can be sure that the owner of the senderKey
 *      sent the event
 *    - sender: the sender's device information, if available
 *    - mismatchedSender: if the event's ed25519 and curve25519 keys don't match
 *      (only meaningful if `sender` is set)
 */

/**
 * Create a recovery key from a user-supplied passphrase.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#createRecoveryKeyFromPassphrase
 * @param {string} password Passphrase string that can be entered by the user
 *     when restoring the backup as an alternative to entering the recovery key.
 *     Optional.
 * @returns {Promise<Object>} 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.
 */

/**
 * 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.
 *
 * @function module:client~MatrixClient#isSecretStorageReady
 * @return {bool} True if secret storage is ready to be used on this device
 */

/**
 * 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
 *
 * @function module:client~MatrixClient#bootstrapSecretStorage
 * @param {function} [opts.createSecretStorageKey] Optional. Function
 * called to await a secret storage key creation flow.
 * Returns:
 *     {Promise<Object>} 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.
 * @param {object} [opts.keyBackupInfo] The current key backup object. If passed,
 * the passphrase and recovery key from this backup will be used.
 * @param {bool} [opts.setupNewKeyBackup] If true, a new key backup version will be
 * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
 * is supplied.
 * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist.
 * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's
 *     current key backup passphrase. Should return a promise that resolves with a Buffer
 *     containing the key, or rejects if the key cannot be obtained.
 * Returns:
 *     {Promise} A promise which resolves to key creation data for
 *     SecretStorage#addKey: an object with `passphrase` etc fields.
 */

/**
 * Add a key for encrypting secrets.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#addSecretStorageKey
 * @param {string} algorithm the algorithm used by the key
 * @param {object} opts the options for the algorithm.  The properties used
 *     depend on the algorithm given.
 * @param {string} [keyName] the name of the key.  If not given, a random
 *     name will be generated.
 *
 * @return {object} An object with:
 *     keyId: {string} the ID of the key
 *     keyInfo: {object} details about the key (iv, mac, passphrase)
 */

/**
 * Check whether we have a key with a given ID.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#hasSecretStorageKey
 * @param {string} [keyId = default key's ID] The ID of the key to check
 *     for. Defaults to the default key ID if not provided.
 * @return {boolean} Whether we have the key.
 */

/**
 * Store an encrypted secret on the server.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#storeSecret
 * @param {string} name The name of the secret
 * @param {string} secret The secret contents.
 * @param {Array} 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).
 */

/**
 * Get a secret from storage.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#getSecret
 * @param {string} name the name of the secret
 *
 * @return {string} the contents of the secret
 */

/**
 * Check if a secret is stored on the server.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#isSecretStored
 * @param {string} name the name of the secret
 * @param {boolean} checkKey check if the secret is encrypted by a trusted
 *     key
 *
 * @return {object?} 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
 */

/**
 * Request a secret from another device.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#requestSecret
 * @param {string} name the name of the secret to request
 * @param {string[]} devices the devices to request the secret from
 *
 * @return {string} the contents of the secret
 */

/**
 * Get the current default key ID for encrypting secrets.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#getDefaultSecretStorageKeyId
 *
 * @return {string} The default key ID or null if no default key ID is set
 */

/**
 * Set the current default key ID for encrypting secrets.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @function module:client~MatrixClient#setDefaultSecretStorageKeyId
 * @param {string} keyId The new default key ID
 */

/**
 * 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.
 *
 * @function module:client~MatrixClient#checkSecretStoragePrivateKey
 * @param {Uint8Array} privateKey The private key
 * @param {string} expectedPublicKey The public key
 * @returns {boolean} true if the key matches, otherwise false
 */

wrapCryptoFuncs(MatrixClient, [
    "getEventEncryptionInfo",
    "createRecoveryKeyFromPassphrase",
    "isSecretStorageReady",
    "bootstrapSecretStorage",
    "addSecretStorageKey",
    "hasSecretStorageKey",
    "storeSecret",
    "getSecret",
    "isSecretStored",
    "requestSecret",
    "getDefaultSecretStorageKeyId",
    "setDefaultSecretStorageKeyId",
    "checkSecretStorageKey",
    "checkSecretStoragePrivateKey",
]);

/**
 * Get e2e information on the device that sent an event
 *
 * @param {MatrixEvent} event event to be checked
 *
 * @return {Promise<module:crypto/deviceinfo?>}
 */
MatrixClient.prototype.getEventSenderDeviceInfo = async function(event) {
    if (!this._crypto) {
        return null;
    }

    return this._crypto.getEventSenderDeviceInfo(event);
};

/**
 * Check if the sender of an event is verified
 *
 * @param {MatrixEvent} event event to be checked
 *
 * @return {boolean} true if the sender of this event has been verified using
 * {@link module:client~MatrixClient#setDeviceVerified|setDeviceVerified}.
 */
MatrixClient.prototype.isEventSenderVerified = async function(event) {
    const device = await this.getEventSenderDeviceInfo(event);
    if (!device) {
        return false;
    }
    return device.isVerified();
};

/**
 * Cancel a room key request for this event if one is ongoing and resend the
 * request.
 * @param  {MatrixEvent} event event of which to cancel and resend the room
 *                            key request.
 * @return {Promise} A promise that will resolve when the key request is queued
 */
MatrixClient.prototype.cancelAndResendEventRoomKeyRequest = function(event) {
    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 {string} roomId The room ID to enable encryption in.
 * @param {object} config The encryption config for the room.
 * @return {Promise} A promise that will resolve when encryption is set up.
 */
MatrixClient.prototype.setRoomEncryption = function(roomId, config) {
    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 {string} roomId the room id to query.
 * @return {bool} whether encryption is enabled.
 */
MatrixClient.prototype.isRoomEncrypted = function(roomId) {
    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("m.room.encryption", "");
    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);
};

/**
 * Forces the current outbound group session to be discarded such
 * that another one will be created next time an event is sent.
 *
 * @param {string} roomId The ID of the room to discard the session for
 *
 * This should not normally be necessary.
 */
MatrixClient.prototype.forceDiscardSession = function(roomId) {
    if (!this._crypto) {
        throw new Error("End-to-End encryption disabled");
    }
    this._crypto.forceDiscardSession(roomId);
};

/**
 * Get a list containing all of the room keys
 *
 * This should be encrypted before returning it to the user.
 *
 * @return {Promise} a promise which resolves to a list of
 *    session export objects
 */
MatrixClient.prototype.exportRoomKeys = function() {
    if (!this._crypto) {
        return Promise.reject(new Error("End-to-end encryption disabled"));
    }
    return this._crypto.exportRoomKeys();
};

/**
 * Import a list of room keys previously exported by exportRoomKeys
 *
 * @param {Object[]} keys a list of session export objects
 * @param {Object} opts
 * @param {Function} opts.progressCallback called with an object that has a "stage" param
 *
 * @return {Promise} a promise which resolves when the keys
 *    have been imported
 */
MatrixClient.prototype.importRoomKeys = function(keys, opts) {
    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} Object with backup info (as returned by
 *     getKeyBackupVersion) in backupInfo and
 *     trust information (as returned by isKeyBackupTrusted)
 *     in trustInfo.
 */
MatrixClient.prototype.checkKeyBackup = function() {
    return this._crypto.checkKeyBackup();
};

/**
 * Get information about the current key backup.
 * @returns {Promise} Information object from API or null
 */
MatrixClient.prototype.getKeyBackupVersion = function() {
    return this._http.authedRequest(
        undefined, "GET", "/room_keys/version", undefined, undefined,
        {prefix: PREFIX_UNSTABLE},
    ).then((res) => {
        if (res.algorithm !== olmlib.MEGOLM_BACKUP_ALGORITHM) {
            const err = "Unknown backup algorithm: " + res.algorithm;
            return Promise.reject(err);
        } else if (!(typeof res.auth_data === "object")
                   || !res.auth_data.public_key) {
            const err = "Invalid backup data returned";
            return Promise.reject(err);
        } else {
            return res;
        }
    }).catch((e) => {
        if (e.errcode === 'M_NOT_FOUND') {
            return null;
        } else {
            throw e;
        }
    });
};

/**
 * @param {object} info key backup info dict from getKeyBackupVersion()
 * @return {object} {
 *     usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
 *     sigs: [
 *         valid: [bool],
 *         device: [DeviceInfo],
 *     ]
 * }
 */
MatrixClient.prototype.isKeyBackupTrusted = function(info) {
    return this._crypto.isKeyBackupTrusted(info);
};

/**
 * @returns {bool} 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.
 */
MatrixClient.prototype.getKeyBackupEnabled = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    if (!this._crypto._checkedForBackup) {
        return null;
    }
    return Boolean(this._crypto.backupKey);
};

/**
 * Enable backing up of keys, using data previously returned from
 * getKeyBackupVersion.
 *
 * @param {object} info Backup information object as returned by getKeyBackupVersion
 */
MatrixClient.prototype.enableKeyBackup = function(info) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    this._crypto.backupInfo = info;
    if (this._crypto.backupKey) this._crypto.backupKey.free();
    this._crypto.backupKey = new global.Olm.PkEncryption();
    this._crypto.backupKey.set_recipient_key(info.auth_data.public_key);

    this.emit('crypto.keyBackupStatus', true);

    // There may be keys left over from a partially completed backup, so
    // schedule a send to check.
    this._crypto.scheduleKeyBackupSend();
};

/**
 * Disable backing up of keys.
 */
MatrixClient.prototype.disableKeyBackup = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    this._crypto.backupInfo = null;
    if (this._crypto.backupKey) this._crypto.backupKey.free();
    this._crypto.backupKey = null;

    this.emit('crypto.keyBackupStatus', false);
};

/**
 * 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 {string} password Passphrase string that can be entered by the user
 *     when restoring the backup as an alternative to entering the recovery key.
 *     Optional.
 * @param {boolean} [opts.secureSecretStorage = false] Whether to use Secure
 *     Secret Storage to store the key encrypting key backups.
 *     Optional, defaults to false.
 *
 * @returns {Promise<object>} Object that can be passed to createKeyBackupVersion and
 *     additionally has a 'recovery_key' member with the user-facing recovery key string.
 */
MatrixClient.prototype.prepareKeyBackupVersion = async function(
    password,
    { secureSecretStorage = false } = {},
) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    const { keyInfo, encodedPrivateKey, privateKey } =
        await this.createRecoveryKeyFromPassphrase(password);

    if (secureSecretStorage) {
        await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
        logger.info("Key backup private key stored in secret storage");
    }

    // Reshape objects into form expected for key backup
    const authData = {
        public_key: keyInfo.pubkey,
    };
    if (keyInfo.passphrase) {
        authData.private_key_salt = keyInfo.passphrase.salt;
        authData.private_key_iterations = keyInfo.passphrase.iterations;
    }
    return {
        algorithm: olmlib.MEGOLM_BACKUP_ALGORITHM,
        auth_data: authData,
        recovery_key: encodedPrivateKey,
    };
};

/**
 * Check whether the key backup private key is stored in secret storage.
 * @return {Promise<object?>} 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
 */
MatrixClient.prototype.isKeyBackupKeyStored = async function() {
    return this.isSecretStored("m.megolm_backup.v1", false /* checkKey */);
};

/**
 * Create a new key backup version and enable it, using the information return
 * from prepareKeyBackupVersion.
 *
 * @param {object} info Info object from prepareKeyBackupVersion
 * @returns {Promise<object>} Object with 'version' param indicating the version created
 */
MatrixClient.prototype.createKeyBackupVersion = async function(info) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    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.
    await this._crypto._signObject(data.auth_data);

    if (
        this._cryptoCallbacks.getCrossSigningKey &&
        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.
        await this._crypto._crossSigningInfo.signObject(data.auth_data, "master");
    }

    const res = await this._http.authedRequest(
        undefined, "POST", "/room_keys/version", undefined, data,
        {prefix: PREFIX_UNSTABLE},
    );

    // 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;
};

MatrixClient.prototype.deleteKeyBackupVersion = function(version) {
    if (this._crypto === null) {
        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.backupInfo && this._crypto.backupInfo.version === version) {
        this.disableKeyBackup();
    }

    const path = utils.encodeUri("/room_keys/version/$version", {
        $version: version,
    });

    return this._http.authedRequest(
        undefined, "DELETE", path, undefined, undefined,
        {prefix: PREFIX_UNSTABLE},
    );
};

MatrixClient.prototype._makeKeyBackupPath = function(roomId, sessionId, version) {
    let path;
    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: version };
    return {
        path: path,
        queryData: queryData,
    };
};

/**
 * Back up session keys to the homeserver.
 * @param {string} roomId ID of the room that the keys are for Optional.
 * @param {string} sessionId ID of the session that the keys are for Optional.
 * @param {integer} version backup version Optional.
 * @param {object} data Object keys to send
 * @return {Promise} a promise that will resolve when the keys
 * are uploaded
 */
MatrixClient.prototype.sendKeyBackup = function(roomId, sessionId, version, data) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    const path = this._makeKeyBackupPath(roomId, sessionId, version);
    return this._http.authedRequest(
        undefined, "PUT", path.path, path.queryData, data,
        {prefix: PREFIX_UNSTABLE},
    );
};

/**
 * Marks all group sessions as needing to be backed up and schedules them to
 * upload in the background as soon as possible.
 */
MatrixClient.prototype.scheduleAllGroupSessionsForBackup = async function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    await this._crypto.scheduleAllGroupSessionsForBackup();
};

/**
 * Marks all group sessions as needing to be backed up without scheduling
 * them to upload in the background.
 * @returns {Promise<int>} Resolves to the number of sessions requiring a backup.
 */
MatrixClient.prototype.flagAllGroupSessionsForBackup = function() {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    return this._crypto.flagAllGroupSessionsForBackup();
};

MatrixClient.prototype.isValidRecoveryKey = function(recoveryKey) {
    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 {string} password Passphrase
 * @param {object} backupInfo Backup metadata from `checkKeyBackup`
 * @return {Promise<Uint8Array>} key backup key
 */
MatrixClient.prototype.keyBackupKeyFromPassword = function(
    password, backupInfo,
) {
    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 {string} recoveryKey The recovery key
 * @return {Uint8Array} key backup key
 */
MatrixClient.prototype.keyBackupKeyFromRecoveryKey = function(recoveryKey) {
    return decodeRecoveryKey(recoveryKey);
};

MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY = 'RESTORE_BACKUP_ERROR_BAD_KEY';

/**
 * Restore from an existing key backup via a passphrase.
 *
 * @param {string} password Passphrase
 * @param {string} [targetRoomId] Room ID to target a specific room.
 * Restores all rooms if omitted.
 * @param {string} [targetSessionId] Session ID to target a specific session.
 * Restores all sessions if omitted.
 * @param {object} backupInfo Backup metadata from `checkKeyBackup`
 * @param {object} opts Optional params such as callbacks
 * @return {Promise<object>} Status of restoration with `total` and `imported`
 * key counts.
 */
MatrixClient.prototype.restoreKeyBackupWithPassword = async function(
    password, targetRoomId, targetSessionId, backupInfo, opts,
) {
    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 {object} backupInfo Backup metadata from `checkKeyBackup`
 * @param {string} [targetRoomId] Room ID to target a specific room.
 * Restores all rooms if omitted.
 * @param {string} [targetSessionId] Session ID to target a specific session.
 * Restores all sessions if omitted.
 * @param {object} opts Optional params such as callbacks
 * @return {Promise<object>} Status of restoration with `total` and `imported`
 * key counts.
 */
MatrixClient.prototype.restoreKeyBackupWithSecretStorage = async function(
    backupInfo, targetRoomId, targetSessionId, opts,
) {
    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 [keyId] = await this._crypto.getSecretStorageKey();
        await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]);
    }

    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 {string} recoveryKey Encoded recovery key
 * @param {string} [targetRoomId] Room ID to target a specific room.
 * Restores all rooms if omitted.
 * @param {string} [targetSessionId] Session ID to target a specific session.
 * Restores all sessions if omitted.
 * @param {object} backupInfo Backup metadata from `checkKeyBackup`
 * @param {object} opts Optional params such as callbacks

 * @return {Promise<object>} Status of restoration with `total` and `imported`
 * key counts.
 */
MatrixClient.prototype.restoreKeyBackupWithRecoveryKey = function(
    recoveryKey, targetRoomId, targetSessionId, backupInfo, opts,
) {
    const privKey = decodeRecoveryKey(recoveryKey);
    return this._restoreKeyBackup(
        privKey, targetRoomId, targetSessionId, backupInfo, opts,
    );
};

/**
 * Restore from an existing key backup using a cached key, or fail
 *
 * @param {string} [targetRoomId] Room ID to target a specific room.
 * Restores all rooms if omitted.
 * @param {string} [targetSessionId] Session ID to target a specific session.
 * Restores all sessions if omitted.
 * @param {object} backupInfo Backup metadata from `checkKeyBackup`
 * @param {object} opts Optional params such as callbacks
 * @return {Promise<object>} Status of restoration with `total` and `imported`
 * key counts.
 */
MatrixClient.prototype.restoreKeyBackupWithCache = async function(
    targetRoomId, targetSessionId, backupInfo, opts,
) {
    const privKey = await this._crypto.getSessionBackupPrivateKey();
    if (!privKey) {
        throw new Error("Couldn't get key");
    }
    return this._restoreKeyBackup(
        privKey, targetRoomId, targetSessionId, backupInfo, opts,
    );
};

MatrixClient.prototype._restoreKeyBackup = function(
    privKey, targetRoomId, targetSessionId, backupInfo,
    {
        cacheCompleteCallback, // For sequencing during tests
        progressCallback,
    }={},
) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }
    let totalKeyCount = 0;
    let keys = [];

    const path = this._makeKeyBackupPath(
        targetRoomId, targetSessionId, backupInfo.version,
    );

    const decryption = new global.Olm.PkDecryption();
    let backupPubKey;
    try {
        backupPubKey = decryption.init_with_private_key(privKey);
    } catch (e) {
        decryption.free();
        throw e;
    }

    // If the pubkey computed from the private data we've been given
    // doesn't match the one in the auth_data, the user has enetered
    // a different recovery key / the wrong passphrase.
    if (backupPubKey !== backupInfo.auth_data.public_key) {
        return Promise.reject({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",
        });
    }

    return this._http.authedRequest(
        undefined, "GET", path.path, path.queryData, undefined,
        {prefix: PREFIX_UNSTABLE},
    ).then((res) => {
        if (res.rooms) {
            for (const [roomId, roomData] of Object.entries(res.rooms)) {
                if (!roomData.sessions) continue;

                totalKeyCount += Object.keys(roomData.sessions).length;
                const roomKeys = keysFromRecoverySession(
                    roomData.sessions, decryption, roomId,
                );
                for (const k of roomKeys) {
                    k.room_id = roomId;
                    keys.push(k);
                }
            }
        } else if (res.sessions) {
            totalKeyCount = Object.keys(res.sessions).length;
            keys = keysFromRecoverySession(
                res.sessions, decryption, targetRoomId, keys,
            );
        } else {
            totalKeyCount = 1;
            try {
                const key = keyFromRecoverySession(res, decryption);
                key.room_id = targetRoomId;
                key.session_id = targetSessionId;
                keys.push(key);
            } catch (e) {
                logger.log("Failed to decrypt megolm session from backup", e);
            }
        }

        return this.importRoomKeys(keys, {
            progressCallback,
            untrusted: true,
            source: "backup",
        });
    }).then(() => {
        return this._crypto.setTrustedBackupPubKey(backupPubKey);
    }).then(() => {
        return {total: totalKeyCount, imported: keys.length};
    }).finally(() => {
        decryption.free();
    });
};

MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, version) {
    if (this._crypto === null) {
        throw new Error("End-to-end encryption disabled");
    }

    const path = this._makeKeyBackupPath(roomId, sessionId, version);
    return this._http.authedRequest(
        undefined, "DELETE", path.path, path.queryData, undefined,
        {prefix: PREFIX_UNSTABLE},
    );
};

/**
 * Share shared-history decryption keys with the given users.
 *
 * @param {string} roomId the room for which keys should be shared.
 * @param {array} userIds a list of users to share with.  The keys will be sent to
 *     all of the user's current devices.
 */
MatrixClient.prototype.sendSharedHistoryKeys = async function(roomId, userIds) {
    if (this._crypto === null) {
        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 = {};
    for (const [userId, devices] of Object.entries(deviceInfos)) {
        devicesByUser[userId] = Object.values(devices);
    }

    const alg = this._crypto._getRoomDecryptor(roomId, roomEncryption.algorithm);
    if (alg.sendSharedHistoryInboundSessions) {
        await alg.sendSharedHistoryInboundSessions(devicesByUser);
    } else {
        logger.warning("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
    }
};

// Group ops
// =========
// Operations on groups that come down the sync stream (ie. ones the
// user is a member of or invited to)

/**
 * Get the group for the given group ID.
 * This function will return a valid group for any group for which a Group event
 * has been emitted.
 * @param {string} groupId The group ID
 * @return {Group} The Group or null if the group is not known or there is no data store.
 */
MatrixClient.prototype.getGroup = function(groupId) {
    return this.store.getGroup(groupId);
};

/**
 * Retrieve all known groups.
 * @return {Group[]} A list of groups, or an empty list if there is no data store.
 */
MatrixClient.prototype.getGroups = function() {
    return this.store.getGroups();
};

/**
 * Get the config for the media repository.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves with an object containing the config.
 */
MatrixClient.prototype.getMediaConfig = function(callback) {
    return this._http.authedRequest(
        callback, "GET", "/config", undefined, undefined, {
            prefix: PREFIX_MEDIA_R0,
        },
    );
};

// Room ops
// ========

/**
 * 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 {string} roomId The room ID
 * @return {Room} The Room or null if it doesn't exist or there is no data store.
 */
MatrixClient.prototype.getRoom = function(roomId) {
    return this.store.getRoom(roomId);
};

/**
 * Retrieve all known rooms.
 * @return {Room[]} A list of rooms, or an empty list if there is no data store.
 */
MatrixClient.prototype.getRooms = function() {
    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.
 * @return {Room[]} A list of rooms, or an empty list if there is no data store.
 */
MatrixClient.prototype.getVisibleRooms = function() {
    const allRooms = this.store.getRooms();

    const replacedRooms = new Set();
    for (const r of allRooms) {
        const createEvent = r.currentState.getStateEvents('m.room.create', '');
        // invites are included in this list and we don't know their create events yet
        if (createEvent) {
            const predecessor = createEvent.getContent()['predecessor'];
            if (predecessor && predecessor['room_id']) {
                replacedRooms.add(predecessor['room_id']);
            }
        }
    }

    return allRooms.filter((r) => {
        const tombstone = r.currentState.getStateEvents('m.room.tombstone', '');
        if (tombstone && replacedRooms.has(r.roomId)) {
            return false;
        }
        return true;
    });
};

/**
 * Retrieve a user.
 * @param {string} userId The user ID to retrieve.
 * @return {?User} A user or null if there is no data store or the user does
 * not exist.
 */
MatrixClient.prototype.getUser = function(userId) {
    return this.store.getUser(userId);
};

/**
 * Retrieve all known users.
 * @return {User[]} A list of users, or an empty list if there is no data store.
 */
MatrixClient.prototype.getUsers = function() {
    return this.store.getUsers();
};

// User Account Data operations
// ============================

/**
 * Set account data event for the current user.
 * It will retry the request up to 5 times.
 * @param {string} eventType The event type
 * @param {Object} contents the contents object for the event
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setAccountData = function(eventType, contents, callback) {
    const path = utils.encodeUri("/user/$userId/account_data/$type", {
        $userId: this.credentials.userId,
        $type: eventType,
    });
    const promise = retryNetworkOperation(5, () => {
        return this._http.authedRequest(undefined, "PUT", path, undefined, contents);
    });
    if (callback) {
        promise.then(result => callback(null, result), callback);
    }
    return promise;
};

/**
 * Get account data event of given type for the current user.
 * @param {string} eventType The event type
 * @return {?object} The contents of the given account data event
 */
MatrixClient.prototype.getAccountData = function(eventType) {
    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 {string} eventType The event type
 * @return {Promise} Resolves: The contents of the given account
 * data event.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getAccountDataFromServer = async function(eventType) {
    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();
    }
    const path = utils.encodeUri("/user/$userId/account_data/$type", {
        $userId: this.credentials.userId,
        $type: eventType,
    });
    try {
        const result = await this._http.authedRequest(
            undefined, "GET", path, undefined,
        );
        return result;
    } catch (e) {
        if (e.data && e.data.errcode === 'M_NOT_FOUND') {
            return null;
        }
        throw e;
    }
};

/**
 * Gets the users that are ignored by this client
 * @returns {string[]} The array of users that are ignored (empty if none)
 */
MatrixClient.prototype.getIgnoredUsers = function() {
    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 {string[]} userIds the user IDs to ignore
 * @param {module:client.callback} [callback] Optional.
 * @return {Promise} Resolves: Account data event
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setIgnoredUsers = function(userIds, callback) {
    const content = {ignored_users: {}};
    userIds.map((u) => content.ignored_users[u] = {});
    return this.setAccountData("m.ignored_user_list", content, callback);
};

/**
 * Gets whether or not a specific user is being ignored by this client.
 * @param {string} userId the user ID to check
 * @returns {boolean} true if the user is ignored, false otherwise
 */
MatrixClient.prototype.isUserIgnored = function(userId) {
    return this.getIgnoredUsers().indexOf(userId) !== -1;
};

// Room operations
// ===============

/**
 * Join a room. If you have already joined the room, this will no-op.
 * @param {string} roomIdOrAlias The room ID or room alias to join.
 * @param {Object} opts Options when joining the room.
 * @param {boolean} opts.syncRoom True to do a room initial sync on the resulting
 * room. If false, the <strong>returned Room object will have no current state.
 * </strong> Default: true.
 * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite,
 *                                     the signing URL is passed in this parameter.
 * @param {string[]} opts.viaServers The server names to try and join through in
 *                                   addition to those that are automatically chosen.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: Room object.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) {
    // to help people when upgrading..
    if (utils.isFunction(opts)) {
        throw new Error("Expected 'opts' object, got function.");
    }
    opts = opts || {};
    if (opts.syncRoom === undefined) {
        opts.syncRoom = true;
    }

    const room = this.getRoom(roomIdOrAlias);
    if (room && room.hasMembershipState(this.credentials.userId, "join")) {
        return Promise.resolve(room);
    }

    let sign_promise = Promise.resolve();

    if (opts.inviteSignUrl) {
        sign_promise = this._http.requestOtherUrl(
            undefined, 'POST',
            opts.inviteSignUrl, { mxid: this.credentials.userId },
        );
    }

    const queryString = {};
    if (opts.viaServers) {
        queryString["server_name"] = opts.viaServers;
    }

    const reqOpts = {qsStringifyOptions: {arrayFormat: 'repeat'}};

    const self = this;
    const prom = new Promise((resolve, reject) => {
        sign_promise.then(function(signed_invite_object) {
            const data = {};
            if (signed_invite_object) {
                data.third_party_signed = signed_invite_object;
            }

            const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias});
            return self._http.authedRequest(
                undefined, "POST", path, queryString, data, reqOpts);
        }).then(function(res) {
            const roomId = res.room_id;
            const syncApi = new SyncApi(self, self._clientOpts);
            const room = syncApi.createRoom(roomId);
            if (opts.syncRoom) {
                // v2 will do this for us
                // return syncApi.syncRoom(room);
            }
            return Promise.resolve(room);
        }).then(function(room) {
            _resolve(callback, resolve, room);
        }, function(err) {
            _reject(callback, reject, err);
        });
    });
    return prom;
};

/**
 * Resend an event.
 * @param {MatrixEvent} event The event to resend.
 * @param {Room} room Optional. The room the event is in. Will update the
 * timeline entry if provided.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.resendEvent = function(event, room) {
    _updatePendingEventStatus(room, event, EventStatus.SENDING);
    return _sendEvent(this, room, event);
};

/**
 * Cancel a queued or unsent event.
 *
 * @param {MatrixEvent} event   Event to cancel
 * @throws Error if the event is not in QUEUED or NOT_SENT state
 */
MatrixClient.prototype.cancelPendingEvent = function(event) {
    if ([EventStatus.QUEUED, EventStatus.NOT_SENT].indexOf(event.status) < 0) {
        throw new Error("cannot cancel an event with status " + event.status);
    }

    // first tell the scheduler to forget about it, if it's queued
    if (this.scheduler) {
        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());
    _updatePendingEventStatus(room, event, EventStatus.CANCELLED);
};

/**
 * @param {string} roomId
 * @param {string} name
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setRoomName = function(roomId, name, callback) {
    return this.sendStateEvent(roomId, "m.room.name", {name: name},
                               undefined, callback);
};

/**
 * @param {string} roomId
 * @param {string} topic
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setRoomTopic = function(roomId, topic, callback) {
    return this.sendStateEvent(roomId, "m.room.topic", {topic: topic},
                               undefined, callback);
};

/**
 * @param {string} roomId
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getRoomTags = function(roomId, callback) {
    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/", {
        $userId: this.credentials.userId,
        $roomId: roomId,
    });
    return this._http.authedRequest(
        callback, "GET", path, undefined,
    );
};

/**
 * @param {string} roomId
 * @param {string} tagName name of room tag to be set
 * @param {object} metadata associated with that tag to be stored
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setRoomTag = function(roomId, tagName, metadata, callback) {
    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
        $userId: this.credentials.userId,
        $roomId: roomId,
        $tag: tagName,
    });
    return this._http.authedRequest(
        callback, "PUT", path, undefined, metadata,
    );
};

/**
 * @param {string} roomId
 * @param {string} tagName name of room tag to be removed
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.deleteRoomTag = function(roomId, tagName, callback) {
    const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
        $userId: this.credentials.userId,
        $roomId: roomId,
        $tag: tagName,
    });
    return this._http.authedRequest(
        callback, "DELETE", path, undefined, undefined,
    );
};

/**
 * @param {string} roomId
 * @param {string} eventType event type to be set
 * @param {object} content event content
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setRoomAccountData = function(roomId, eventType,
                                                     content, callback) {
    const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
        $userId: this.credentials.userId,
        $roomId: roomId,
        $type: eventType,
    });
    return this._http.authedRequest(
        callback, "PUT", path, undefined, content,
    );
};

/**
 * Set a user's power level.
 * @param {string} roomId
 * @param {string} userId
 * @param {Number} powerLevel
 * @param {MatrixEvent} event
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setPowerLevel = function(roomId, userId, powerLevel,
                                                event, callback) {
    let content = {
        users: {},
    };
    if (event && event.getType() === "m.room.power_levels") {
        // 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());
    }
    content.users[userId] = powerLevel;
    const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
        $roomId: roomId,
    });
    return this._http.authedRequest(
        callback, "PUT", path, undefined, content,
    );
};

/**
 * @param {string} roomId
 * @param {string} eventType
 * @param {Object} content
 * @param {string} txnId Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId,
                                            callback) {
    return this._sendCompleteEvent(roomId, {
        type: eventType,
        content: content,
    }, txnId, callback);
};
/**
 * @param {string} roomId
 * @param {object} 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 {string} txnId the txnId.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype._sendCompleteEvent = function(roomId, eventObject, txnId,
                                            callback) {
    if (utils.isFunction(txnId)) {
        callback = txnId; txnId = undefined;
    }

    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);

    // 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 && targetId.startsWith("~")) {
        const target = room.getPendingEvents().find(e => e.getId() === targetId);
        target.once("Event.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'.
    if (room) {
        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 _sendEvent(this, room, localEvent, callback);
};


// encrypts the event if necessary
// adds the event to the queue, or sends it
// marks the event as sent/unsent
// returns a promise which resolves with the result of the send request
function _sendEvent(client, room, event, callback) {
    // 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(function() {
        const encryptionPromise = _encryptEventIfNeeded(client, event, room);

        if (!encryptionPromise) {
            return null;
        }

        _updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
        return encryptionPromise.then(() => {
            _updatePendingEventStatus(room, event, EventStatus.SENDING);
        });
    }).then(function() {
        let promise;
        // this event may be queued
        if (client.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 = client.scheduler.queueEvent(event);
            if (promise && client.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.
                _updatePendingEventStatus(room, event, EventStatus.QUEUED);
            }
        }

        if (!promise) {
            promise = _sendEventHttpRequest(client, event);
            if (room) {
                promise = promise.then(res => {
                    room.updatePendingEvent(event, EventStatus.SENT, res.event_id);
                    return res;
                });
            }
        }
        return promise;
    }).then(function(res) {  // the request was sent OK
        if (callback) {
            callback(null, res);
        }
        return res;
    }, function(err) {
        // the request failed to send.
        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;
            _updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
            // also put the event object on the error: the caller will need this
            // to resend or cancel the event
            err.event = event;

            if (callback) {
                callback(err);
            }
        } catch (err2) {
            logger.error("Exception in error handler!", err2.stack || err);
        }
        throw err;
    });
}

/**
 * Encrypt an event according to the configuration of the room, if necessary.
 *
 * @param {MatrixClient} client
 *
 * @param {module:models/event.MatrixEvent} event  event to be sent
 *
 * @param {module:models/room?} room destination room. Null if the destination
 *     is not a room we have seen over the sync pipe.
 *
 * @return {Promise?} Promise which resolves when the event has been
 *     encrypted, or null if nothing was needed
 */

function _encryptEventIfNeeded(client, event, room) {
    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 (!client.isRoomEncrypted(event.getRoomId())) {
        // looks like this room isn't encrypted.
        return null;
    }

    if (!client._crypto && client.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() === "m.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 (!client._crypto) {
        throw new Error(
            "This room is configured to use encryption, but your client does " +
            "not support encryption.",
        );
    }

    return client._crypto.encryptEvent(event, room);
}
/**
 * Returns the eventType that should be used taking encryption into account
 * for a given eventType.
 * @param {MatrixClient} client the client
 * @param {string} roomId the room for the events `eventType` relates to
 * @param {string} eventType the event type
 * @return {string} the event type taking encryption into account
 */
function _getEncryptedIfNeededEventType(client, roomId, eventType) {
    if (eventType === "m.reaction") {
        return eventType;
    }
    const isEncrypted = client.isRoomEncrypted(roomId);
    return isEncrypted ? "m.room.encrypted" : eventType;
}

function _updatePendingEventStatus(room, event, newStatus) {
    if (room) {
        room.updatePendingEvent(event, newStatus);
    } else {
        event.setStatus(newStatus);
    }
}

function _sendEventHttpRequest(client, event) {
    let txnId = event.getTxnId();
    if (!txnId) {
        txnId = client.makeTxnId();
        event.setTxnId(txnId);
    }

    const pathParams = {
        $roomId: event.getRoomId(),
        $eventType: event.getWireType(),
        $stateKey: event.getStateKey(),
        $txnId: txnId,
    };

    let path;

    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, Object.assign({
            $redactsEventId: event.event.redacts,
        }, pathParams));
    } else {
        path = utils.encodeUri(
            "/rooms/$roomId/send/$eventType/$txnId", pathParams,
        );
    }

    return client._http.authedRequest(
        undefined, "PUT", path, undefined, event.getWireContent(),
    ).then((res) => {
        logger.log(
            `Event sent to ${event.getRoomId()} with event id ${res.event_id}`,
        );
        return res;
    });
}

/**
 * @param {string} roomId
 * @param {string} eventId
 * @param {string} [txnId]  transaction id. One will be made up if not
 *    supplied.
 * @param {object|module:client.callback} callbackOrOpts
 *    Options to pass on, may contain `reason`.
 *    Can be callback for backwards compatibility.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.redactEvent = function(roomId, eventId, txnId, callbackOrOpts) {
    const opts = typeof(callbackOrOpts) === 'object' ? callbackOrOpts : {};
    const reason = opts.reason;
    const callback = typeof(callbackOrOpts) === 'function' ? callbackOrOpts : undefined;
    return this._sendCompleteEvent(roomId, {
        type: "m.room.redaction",
        content: { reason: reason },
        redacts: eventId,
    }, txnId, callback);
};

/**
 * @param {string} roomId
 * @param {Object} content
 * @param {string} txnId Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendMessage = function(roomId, content, txnId, callback) {
    if (utils.isFunction(txnId)) {
        callback = txnId; txnId = undefined;
    }
    return this.sendEvent(
        roomId, "m.room.message", content, txnId, callback,
    );
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} txnId Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendTextMessage = function(roomId, body, txnId, callback) {
    const content = ContentHelpers.makeTextMessage(body);
    return this.sendMessage(roomId, content, txnId, callback);
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} txnId Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendNotice = function(roomId, body, txnId, callback) {
    const content = ContentHelpers.makeNotice(body);
    return this.sendMessage(roomId, content, txnId, callback);
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} txnId Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendEmoteMessage = function(roomId, body, txnId, callback) {
    const content = ContentHelpers.makeEmoteMessage(body);
    return this.sendMessage(roomId, content, txnId, callback);
};

/**
 * @param {string} roomId
 * @param {string} url
 * @param {Object} info
 * @param {string} text
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendImageMessage = function(roomId, url, info, text, callback) {
    if (utils.isFunction(text)) {
        callback = text; text = undefined;
    }
    if (!text) {
        text = "Image";
    }
    const content = {
         msgtype: "m.image",
         url: url,
         info: info,
         body: text,
    };
    return this.sendMessage(roomId, content, callback);
};

/**
 * @param {string} roomId
 * @param {string} url
 * @param {Object} info
 * @param {string} text
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendStickerMessage = function(roomId, url, info, text, callback) {
    if (utils.isFunction(text)) {
        callback = text; text = undefined;
    }
    if (!text) {
        text = "Sticker";
    }
    const content = {
         url: url,
         info: info,
         body: text,
    };
    return this.sendEvent(
        roomId, "m.sticker", content, callback, undefined,
    );
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} htmlBody
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendHtmlMessage = function(roomId, body, htmlBody, callback) {
    const content = ContentHelpers.makeHtmlMessage(body, htmlBody);
    return this.sendMessage(roomId, content, callback);
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} htmlBody
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendHtmlNotice = function(roomId, body, htmlBody, callback) {
    const content = ContentHelpers.makeHtmlNotice(body, htmlBody);
    return this.sendMessage(roomId, content, callback);
};

/**
 * @param {string} roomId
 * @param {string} body
 * @param {string} htmlBody
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendHtmlEmote = function(roomId, body, htmlBody, callback) {
    const content = ContentHelpers.makeHtmlEmote(body, htmlBody);
    return this.sendMessage(roomId, content, callback);
};

/**
 * Send a receipt.
 * @param {Event} event The event being acknowledged
 * @param {string} receiptType The kind of receipt e.g. "m.read"
 * @param {object} opts Additional content to send alongside the receipt.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendReceipt = function(event, receiptType, opts, callback) {
    if (typeof(opts) === 'function') {
        callback = opts;
        opts = {};
    }

    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(),
    });
    const promise = this._http.authedRequest(
        callback, "POST", path, undefined, opts || {},
    );

    const room = this.getRoom(event.getRoomId());
    if (room) {
        room._addLocalEchoReceipt(this.credentials.userId, event, receiptType);
    }
    return promise;
};

/**
 * Send a read receipt.
 * @param {Event} event The event that has been read.
 * @param {object} opts The options for the read receipt.
 * @param {boolean} opts.hidden True to prevent the receipt from being sent to
 * other users and homeservers. Default false (send to everyone). <b>This
 * property is unstable and may change in the future.</b>
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendReadReceipt = async function(event, opts, callback) {
    if (typeof(opts) === 'function') {
        callback = opts;
        opts = {};
    }
    if (!opts) opts = {};

    const eventId = event.getId();
    const room = this.getRoom(event.getRoomId());
    if (room && room.hasPendingEvent(eventId)) {
        throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
    }

    const addlContent = {
        "m.hidden": Boolean(opts.hidden),
    };

    return this.sendReceipt(event, "m.read", addlContent, callback);
};

/**
 * 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 {string} roomId ID of the room that has been read
 * @param {string} rmEventId ID of the event that has been read
 * @param {string} 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 {object} opts Options for the read markers
 * @param {object} opts.hidden True to hide the receipt from other users and homeservers.
 * <b>This property is unstable and may change in the future.</b>
 * @return {Promise} Resolves: the empty object, {}.
 */
MatrixClient.prototype.setRoomReadMarkers = async function(
    roomId, rmEventId, rrEvent, opts,
) {
    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;
    if (rrEvent) {
        rrEventId = rrEvent.getId();
        if (room && room.hasPendingEvent(rrEventId)) {
            throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
        }
        if (room) {
            room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read");
        }
    }

    return this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, opts);
};

/**
 * 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 {string} url The URL to get preview data for
 * @param {Number} 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.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: Object of OG metadata.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 * May return synthesized attributes if the URL lacked OG meta.
 */
MatrixClient.prototype.getUrlPreview = function(url, ts, callback) {
    // 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 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) {
        if (callback) {
            cachedPreview.then(callback).catch(callback);
        }
        return cachedPreview;
    }

    const resp = this._http.authedRequest(
        callback, "GET", "/preview_url", {
            url: url,
            ts: ts,
        }, undefined, {
            prefix: PREFIX_MEDIA_R0,
        },
    );
    // TODO: Expire the URL preview cache sometimes
    this.urlPreviewCache[key] = resp;
    return resp;
};

/**
 * @param {string} roomId
 * @param {boolean} isTyping
 * @param {Number} timeoutMs
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.sendTyping = function(roomId, isTyping, timeoutMs, callback) {
    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.credentials.userId,
    });
    const data = {
        typing: isTyping,
    };
    if (isTyping) {
        data.timeout = timeoutMs ? timeoutMs : 20000;
    }
    return this._http.authedRequest(
        callback, "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 {string} roomId The room ID to search from
 * @param {boolean} 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.
 * @return {Room[]} An array of rooms representing the upgrade
 * history.
 */
MatrixClient.prototype.getRoomUpgradeHistory = function(roomId, verifyLinks=false) {
    let currentRoom = this.getRoom(roomId);
    if (!currentRoom) return [];

    const upgradeHistory = [currentRoom];

    // Work backwards first, looking at create events.
    let createEvent = currentRoom.currentState.getStateEvents("m.room.create", "");
    while (createEvent) {
        logger.log(`Looking at ${createEvent.getId()}`);
        const predecessor = createEvent.getContent()['predecessor'];
        if (predecessor && predecessor['room_id']) {
            logger.log(`Looking at predecessor ${predecessor['room_id']}`);
            const refRoom = this.getRoom(predecessor['room_id']);
            if (!refRoom) break; // end of the chain

            if (verifyLinks) {
                const tombstone = refRoom.currentState
                    .getStateEvents("m.room.tombstone", "");

                if (!tombstone
                    || tombstone.getContent()['replacement_room'] !== refRoom.roomId) {
                    break;
                }
            }

            // Insert at the front because we're working backwards from the currentRoom
            upgradeHistory.splice(0, 0, refRoom);
            createEvent = refRoom.currentState.getStateEvents("m.room.create", "");
        } else {
            // No further create events to look at
            break;
        }
    }

    // Work forwards next, looking at tombstone events
    let tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", "");
    while (tombstoneEvent) {
        const refRoom = this.getRoom(tombstoneEvent.getContent()['replacement_room']);
        if (!refRoom) break; // end of the chain
        if (refRoom.roomId === currentRoom.roomId) break; // Tombstone is referencing it's own room

        if (verifyLinks) {
            createEvent = refRoom.currentState.getStateEvents("m.room.create", "");
            if (!createEvent || !createEvent.getContent()['predecessor']) break;

            const predecessor = createEvent.getContent()['predecessor'];
            if (predecessor['room_id'] !== currentRoom.roomId) break;
        }

        // Push to the end because we're looking forwards
        upgradeHistory.push(refRoom);
        const roomIds = new Set(upgradeHistory.map((ref) => ref.roomId));
        if (roomIds.size < upgradeHistory.length) {
           // The last room added to the list introduced a previous roomId
           // To avoid recursion, return the last rooms - 1
           return upgradeHistory.slice(0, upgradeHistory.length - 1);
        }

        // Set the current room to the reference room so we know where we're at
        currentRoom = refRoom;
        tombstoneEvent = currentRoom.currentState.getStateEvents("m.room.tombstone", "");
    }

    return upgradeHistory;
};

/**
 * @param {string} roomId
 * @param {string} userId
 * @param {module:client.callback} callback Optional.
 * @param {string} reason Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.invite = function(roomId, userId, callback, reason) {
    return _membershipChange(this, roomId, userId, "invite", reason,
        callback);
};

/**
 * Invite a user to a room based on their email address.
 * @param {string} roomId The room to invite the user to.
 * @param {string} email The email address to invite.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.inviteByEmail = function(roomId, email, callback) {
    return this.inviteByThreePid(
        roomId, "email", email, callback,
    );
};

/**
 * Invite a user to a room based on a third-party identifier.
 * @param {string} roomId The room to invite the user to.
 * @param {string} medium The medium to invite the user e.g. "email".
 * @param {string} address The address for the specified medium.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.inviteByThreePid = async function(
    roomId,
    medium,
    address,
    callback,
) {
    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 = {
        id_server: identityServerUrl,
        medium: medium,
        address: address,
    };

    if (
        this.identityServer &&
        this.identityServer.getAccessToken &&
        await this.doesServerAcceptIdentityAccessToken()
    ) {
        const identityAccessToken = await this.identityServer.getAccessToken();
        if (identityAccessToken) {
            params.id_access_token = identityAccessToken;
        }
    }

    return this._http.authedRequest(callback, "POST", path, undefined, params);
};

/**
 * @param {string} roomId
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.leave = function(roomId, callback) {
    return _membershipChange(this, roomId, undefined, "leave", undefined,
        callback);
};

/**
 * 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 {string} roomId The room ID to start leaving at
 * @param {boolean} includeFuture If true, the whole chain (past and future) of
 * upgraded rooms will be left.
 * @return {Promise} Resolves when completed with an object keyed
 * by room ID and value of the error encountered when leaving or null.
 */
MatrixClient.prototype.leaveRoomChain = function(roomId, includeFuture=true) {
    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: Error}
    const promises = [];

    const doLeave = (roomId) => {
        return this.leave(roomId).then(() => {
            populationResults[roomId] = null;
        }).catch((err) => {
            populationResults[roomId] = err;
            return null; // suppress error
        });
    };

    for (const room of eligibleToLeave) {
        promises.push(doLeave(room.roomId));
    }

    return Promise.all(promises).then(() => populationResults);
};

/**
 * @param {string} roomId
 * @param {string} userId
 * @param {string} reason Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.ban = function(roomId, userId, reason, callback) {
    return _membershipChange(this, roomId, userId, "ban", reason,
        callback);
};

/**
 * @param {string} roomId
 * @param {boolean} deleteRoom True to delete the room from the store on success.
 * Default: true.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.forget = function(roomId, deleteRoom, callback) {
    if (deleteRoom === undefined) {
        deleteRoom = true;
    }
    const promise = _membershipChange(this, roomId, undefined, "forget", undefined,
        callback);
    if (!deleteRoom) {
        return promise;
    }
    const self = this;
    return promise.then(function(response) {
        self.store.removeRoom(roomId);
        self.emit("deleteRoom", roomId);
        return response;
    });
};

/**
 * @param {string} roomId
 * @param {string} userId
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: Object (currently empty)
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.unban = function(roomId, userId, callback) {
    // unbanning != set their state to leave: this used to be
    // the case, but was then changed so that leaving was always
    // a revoking of priviledge, 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(
        callback, "POST", path, undefined, data,
    );
};

/**
 * @param {string} roomId
 * @param {string} userId
 * @param {string} reason Optional.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.kick = function(roomId, userId, reason, callback) {
    return _setMembershipState(
        this, roomId, userId, "leave", reason, callback,
    );
};

/**
 * This is an internal method.
 * @param {MatrixClient} client
 * @param {string} roomId
 * @param {string} userId
 * @param {string} membershipValue
 * @param {string} reason
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
function _setMembershipState(client, roomId, userId, membershipValue, reason,
                             callback) {
    if (utils.isFunction(reason)) {
        callback = reason; reason = undefined;
    }

    const path = utils.encodeUri(
        "/rooms/$roomId/state/m.room.member/$userId",
        { $roomId: roomId, $userId: userId},
    );

    return client._http.authedRequest(callback, "PUT", path, undefined, {
        membership: membershipValue,
        reason: reason,
    });
}

/**
 * This is an internal method.
 * @param {MatrixClient} client
 * @param {string} roomId
 * @param {string} userId
 * @param {string} membership
 * @param {string} reason
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
function _membershipChange(client, roomId, userId, membership, reason, callback) {
    if (utils.isFunction(reason)) {
        callback = reason; reason = undefined;
    }

    const path = utils.encodeUri("/rooms/$room_id/$membership", {
        $room_id: roomId,
        $membership: membership,
    });
    return client._http.authedRequest(
        callback, "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 {MatrixEvent} event The event to get push actions for.
 * @return {module:pushprocessor~PushAction} A dict of actions to perform.
 */
MatrixClient.prototype.getPushActionsForEvent = function(event) {
    if (!event.getPushActions()) {
        event.setPushActions(this._pushProcessor.actionsForEvent(event));
    }
    return event.getPushActions();
};

// Profile operations
// ==================

/**
 * @param {string} info The kind of info to set (e.g. 'avatar_url')
 * @param {Object} data The JSON object to set.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setProfileInfo = function(info, data, callback) {
    const path = utils.encodeUri("/profile/$userId/$info", {
        $userId: this.credentials.userId,
        $info: info,
    });
    return this._http.authedRequest(
        callback, "PUT", path, undefined, data,
    );
};

/**
 * @param {string} name
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: {} an empty object.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setDisplayName = async function(name, callback) {
    const prom = await this.setProfileInfo(
        "displayname", { displayname: name }, callback,
    );
    // 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("User.displayName", user.events.presence, user);
    }
    return prom;
};

/**
 * @param {string} url
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: {} an empty object.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setAvatarUrl = async function(url, callback) {
    const prom = await this.setProfileInfo(
        "avatar_url", { avatar_url: url }, callback,
    );
    // 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("User.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 {string} mxcUrl The MXC URL
 * @param {Number} width The desired width of the thumbnail.
 * @param {Number} height The desired height of the thumbnail.
 * @param {string} resizeMethod The thumbnail resize method to use, either
 * "crop" or "scale".
 * @param {Boolean} 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.
 * @return {?string} the avatar URL or null.
 */
MatrixClient.prototype.mxcUrlToHttp =
        function(mxcUrl, width, height, resizeMethod, allowDirectLinks) {
    return getHttpUriForMxc(
        this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks,
    );
};

/**
 * Sets a new status message for the user. The message may be null/falsey
 * to clear the message.
 * @param {string} newMessage The new message to set.
 * @return {Promise} Resolves: to nothing
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype._unstable_setStatusMessage = function(newMessage) {
    const type = "im.vector.user_status";
    return Promise.all(this.getRooms().map((room) => {
        const isJoined = room.getMyMembership() === "join";
        const looksLikeDm = room.getInvitedAndJoinedMemberCount() === 2;
        if (!isJoined || !looksLikeDm) {
            return Promise.resolve();
        }
        // Check power level separately as it's a bit more expensive.
        const maySend = room.currentState.mayClientSendStateEvent(type, this);
        if (!maySend) {
            return Promise.resolve();
        }
        return this.sendStateEvent(room.roomId, type, {
            status: newMessage,
        }, this.getUserId());
    }));
};

/**
 * @param {Object} opts Options to apply
 * @param {string} opts.presence One of "online", "offline" or "unavailable"
 * @param {string} opts.status_msg The status message to attach.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 * @throws If 'presence' isn't a valid presence enum value.
 */
MatrixClient.prototype.setPresence = function(opts, callback) {
    const path = utils.encodeUri("/presence/$userId/status", {
        $userId: this.credentials.userId,
    });

    if (typeof opts === "string") {
      opts = { presence: opts };
    }

    const validStates = ["offline", "online", "unavailable"];
    if (validStates.indexOf(opts.presence) == -1) {
        throw new Error("Bad presence value: " + opts.presence);
    }
    return this._http.authedRequest(
        callback, "PUT", path, undefined, opts,
    );
};

/**
 * @param {string} userId The user to get presence for
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: The presence state for this user.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getPresence = function(userId, callback) {
    const path = utils.encodeUri("/presence/$userId/status", {
        $userId: userId,
    });

    return this._http.authedRequest(callback, "GET", path, undefined, undefined);
};

/**
 * 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} room The room to get older messages in.
 * @param {Integer} limit Optional. The maximum number of previous events to
 * pull in. Default: 30.
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: Room. If you are at the beginning
 * of the timeline, <code>Room.oldState.paginationToken</code> will be
 * <code>null</code>.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.scrollback = function(room, limit, callback) {
    if (utils.isFunction(limit)) {
        callback = limit; limit = undefined;
    }
    limit = limit || 30;
    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 self = this;
    const prom = new Promise((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(function() {
            return self._createMessagesRequest(
                room.roomId,
                room.oldState.paginationToken,
                limit,
                'b');
        }).then(function(res) {
            const matrixEvents = utils.map(res.chunk, _PojoToMatrixEventMapper(self));
            if (res.state) {
                const stateEvents = utils.map(res.state, _PojoToMatrixEventMapper(self));
                room.currentState.setUnknownStateEvents(stateEvents);
            }
            room.addEventsToTimeline(matrixEvents, true, room.getLiveTimeline());
            room.oldState.paginationToken = res.end;
            if (res.chunk.length === 0) {
                room.oldState.paginationToken = null;
            }
            self.store.storeEvents(room, matrixEvents, res.end, true);
            self._ongoingScrollbacks[room.roomId] = null;
            _resolve(callback, resolve, room);
        }, function(err) {
            self._ongoingScrollbacks[room.roomId] = {
                errorTs: Date.now(),
            };
            _reject(callback, reject, err);
        });
    });

    info = {
        promise: prom,
        errorTs: null,
    };

    this._ongoingScrollbacks[room.roomId] = info;
    return prom;
};

/**
 * 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.
 *
 * @param {EventTimelineSet} timelineSet  The timelineSet to look for the event in
 * @param {string} eventId  The ID of the event to look for
 *
 * @return {Promise} Resolves:
 *    {@link module:models/event-timeline~EventTimeline} including the given
 *    event
 */
MatrixClient.prototype.getEventTimeline = function(timelineSet, eventId) {
    // 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.getTimelineForEvent(eventId)) {
        return Promise.resolve(timelineSet.getTimelineForEvent(eventId));
    }

    const path = utils.encodeUri(
        "/rooms/$roomId/context/$eventId", {
            $roomId: timelineSet.room.roomId,
            $eventId: eventId,
        },
    );

    let params = 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 self = this;
    const promise =
        self._http.authedRequest(undefined, "GET", path, params,
    ).then(function(res) {
        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);
        }

        // 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();
        const events = res.events_after
            .concat([res.event])
            .concat(res.events_before);
        const matrixEvents = utils.map(events, self.getEventMapper());

        let timeline = timelineSet.getTimelineForEvent(matrixEvents[0].getId());
        if (!timeline) {
            timeline = timelineSet.addTimeline();
            timeline.initialiseState(utils.map(res.state,
                                               self.getEventMapper()));
            timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
        } else {
            const stateEvents = utils.map(res.state, self.getEventMapper());
            timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(stateEvents);
        }
        timelineSet.addEventsToTimeline(matrixEvents, true, timeline, res.start);

        // 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.
        const tl = timelineSet.getTimelineForEvent(eventId) || timeline;
        return tl;
    });
    return promise;
};

/**
 * 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 {string} roomId
 * @param {string} fromToken
 * @param {number} limit the maximum amount of events the retrieve
 * @param {string} dir 'f' or 'b'
 * @param {Filter} timelineFilter the timeline filter to pass
 * @return {Promise}
 */
MatrixClient.prototype._createMessagesRequest =
function(roomId, fromToken, limit, dir, timelineFilter = undefined) {
    const path = utils.encodeUri(
        "/rooms/$roomId/messages", {$roomId: roomId},
    );
    if (limit === undefined) {
        limit = 30;
    }
    const params = {
        from: fromToken,
        limit: limit,
        dir: dir,
    };

    let filter = 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());
    }
    if (filter) {
        params.filter = JSON.stringify(filter);
    }
    return this._http.authedRequest(undefined, "GET", path, params);
};

/**
 * Take an EventTimeline, and back/forward-fill results.
 *
 * @param {module:models/event-timeline~EventTimeline} eventTimeline timeline
 *    object to be updated
 * @param {Object}   [opts]
 * @param {bool}     [opts.backwards = false]  true to fill backwards,
 *    false to go forwards
 * @param {number}   [opts.limit = 30]         number of events to request
 *
 * @return {Promise} Resolves to a boolean: false if there are no
 *    events and we reached either end of the timeline; else true.
 */
MatrixClient.prototype.paginateEventTimeline = function(eventTimeline, opts) {
    const isNotifTimeline = (eventTimeline.getTimelineSet() === this._notifTimelineSet);

    // 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);
    if (!token) {
        // no token - no results.
        return Promise.resolve(false);
    }

    const pendingRequest = eventTimeline._paginationRequests[dir];

    if (pendingRequest) {
        // already a request in progress - return the existing promise
        return pendingRequest;
    }

    let path;
    let params;
    let promise;
    const self = this;

    if (isNotifTimeline) {
        path = "/notifications";
        params = {
            limit: ('limit' in opts) ? opts.limit : 30,
            only: 'highlight',
        };

        if (token && token !== "end") {
            params.from = token;
        }

        promise = this._http.authedRequest(
            undefined, "GET", path, params, undefined,
        ).then(function(res) {
            const token = res.next_token;
            const matrixEvents = [];

            for (let i = 0; i < res.notifications.length; i++) {
                const notification = res.notifications[i];
                const event = self.getEventMapper()(notification.event);
                event.setPushActions(
                    PushProcessor.actionListToActionsObject(notification.actions),
                );
                event.event.room_id = notification.room_id; // XXX: gutwrenching
                matrixEvents[i] = event;
            }

            eventTimeline.getTimelineSet()
                .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);

            // 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 res.next_token ? true : false;
        }).finally(function() {
            eventTimeline._paginationRequests[dir] = null;
        });
        eventTimeline._paginationRequests[dir] = promise;
    } else {
        const room = this.getRoom(eventTimeline.getRoomId());
        if (!room) {
            throw new Error("Unknown room " + eventTimeline.getRoomId());
        }

        promise = this._createMessagesRequest(
            eventTimeline.getRoomId(),
            token,
            opts.limit,
            dir,
            eventTimeline.getFilter());
        promise.then(function(res) {
            if (res.state) {
                const roomState = eventTimeline.getState(dir);
                const stateEvents = utils.map(res.state, self.getEventMapper());
                roomState.setUnknownStateEvents(stateEvents);
            }
            const token = res.end;
            const matrixEvents = utils.map(res.chunk, self.getEventMapper());
            eventTimeline.getTimelineSet()
                .addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);

            // 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(function() {
            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.
 */
MatrixClient.prototype.resetNotifTimelineSet = function() {
    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', null);

    // 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 {String} roomId The room to attempt to peek into.
 * @return {Promise} Resolves: Room object
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.peekInRoom = function(roomId) {
    if (this._peekSync) {
        this._peekSync.stopPeeking();
    }
    this._peekSync = new SyncApi(this, this._clientOpts);
    return this._peekSync.peek(roomId);
};

/**
 * Stop any ongoing room peeking.
 */
MatrixClient.prototype.stopPeeking = function() {
    if (this._peekSync) {
        this._peekSync.stopPeeking();
        this._peekSync = null;
    }
};

/**
 * Set r/w flags for guest access in a room.
 * @param {string} roomId The room to configure guest access in.
 * @param {Object} opts Options
 * @param {boolean} opts.allowJoin True to allow guests to join this room. This
 * implicitly gives guests write access. If false or not given, guests are
 * explicitly forbidden from joining the room.
 * @param {boolean} opts.allowRead True to set history visibility to
 * be world_readable. This gives guests read access *from this point forward*.
 * If false or not given, history visibility is not modified.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setGuestAccess = function(roomId, opts) {
    const writePromise = this.sendStateEvent(roomId, "m.room.guest_access", {
        guest_access: opts.allowJoin ? "can_join" : "forbidden",
    });

    let readPromise = Promise.resolve();
    if (opts.allowRead) {
        readPromise = this.sendStateEvent(roomId, "m.room.history_visibility", {
            history_visibility: "world_readable",
        });
    }

    return Promise.all([readPromise, writePromise]);
};

// Registration/Login operations
// =============================

/**
 * 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 {string} email As requestEmailToken
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestRegisterEmailToken = function(email, clientSecret,
                                                    sendAttempt, nextLink) {
    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 {string} phoneCountry The ISO 3166-1 alpha-2 code for the country in which
 *    phoneNumber should be parsed relative to.
 * @param {string} phoneNumber The phone number, in national or international format
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestRegisterMsisdnToken = function(phoneCountry, phoneNumber,
                                                    clientSecret, sendAttempt, nextLink) {
    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 Home Server).
 *
 * @param {string} email As requestEmailToken
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestAdd3pidEmailToken = function(email, clientSecret,
                                                    sendAttempt, nextLink) {
    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 {string} phoneCountry As requestRegisterMsisdnToken
 * @param {string} phoneNumber As requestRegisterMsisdnToken
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestAdd3pidMsisdnToken = function(phoneCountry, phoneNumber,
                                                    clientSecret, sendAttempt, nextLink) {
    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 Home Server).
 *
 * requestEmailToken calls the equivalent API directly on the ID server,
 * therefore bypassing the password reset specific logic.
 *
 * @param {string} email As requestEmailToken
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @param {module:client.callback} callback Optional. As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestPasswordEmailToken = function(email, clientSecret,
                                                    sendAttempt, nextLink) {
    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 {string} phoneCountry As requestRegisterMsisdnToken
 * @param {string} phoneNumber As requestRegisterMsisdnToken
 * @param {string} clientSecret As requestEmailToken
 * @param {number} sendAttempt As requestEmailToken
 * @param {string} nextLink As requestEmailToken
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype.requestPasswordMsisdnToken = function(phoneCountry, phoneNumber,
                                                    clientSecret, sendAttempt, nextLink) {
    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 {string} endpoint The endpoint to send the request to
 * @param {object} params Parameters for the POST request
 * @return {Promise} Resolves: As requestEmailToken
 */
MatrixClient.prototype._requestTokenFromEndpoint = async function(endpoint, params) {
    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 = url.parse(this.idBaseUrl);
        if (!idServerUrl.host) {
            throw new Error("Invalid ID server URL: " + this.idBaseUrl);
        }
        postParams.id_server = idServerUrl.host;

        if (
            this.identityServer &&
            this.identityServer.getAccessToken &&
            await this.doesServerAcceptIdentityAccessToken()
        ) {
            const identityAccessToken = await this.identityServer.getAccessToken();
            if (identityAccessToken) {
                postParams.id_access_token = identityAccessToken;
            }
        }
    }

    return this._http.request(
        undefined, "POST", endpoint, undefined,
        postParams,
    );
};


// Push operations
// ===============

/**
 * Get the room-kind push rule associated with a room.
 * @param {string} scope "global" or device-specific.
 * @param {string} roomId the id of the room.
 * @return {object} the rule or undefined.
 */
MatrixClient.prototype.getRoomPushRule = function(scope, roomId) {
    // There can be only room-kind push rule per room
    // and its id is the room id.
    if (this.pushRules) {
        for (let i = 0; i < this.pushRules[scope].room.length; i++) {
            const rule = this.pushRules[scope].room[i];
            if (rule.rule_id === roomId) {
                return rule;
            }
        }
    } 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 {string} scope "global" or device-specific.
 * @param {string} roomId the id of the room.
 * @param {string} mute the mute state.
 * @return {Promise} Resolves: result object
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.setRoomMutePushRule = function(scope, roomId, mute) {
    const self = this;
    let deferred;
    let hasDontNotifyRule;

    // Get the existing room-kind push rule if any
    const roomPushRule = this.getRoomPushRule(scope, roomId);
    if (roomPushRule) {
        if (0 <= roomPushRule.actions.indexOf("dont_notify")) {
            hasDontNotifyRule = true;
        }
    }

    if (!mute) {
        // Remove the rule only if it is a muting rule
        if (hasDontNotifyRule) {
            deferred = this.deletePushRule(scope, "room", roomPushRule.rule_id);
        }
    } else {
        if (!roomPushRule) {
            deferred = this.addPushRule(scope, "room", roomId, {
                actions: ["dont_notify"],
            });
        } else if (!hasDontNotifyRule) {
            // Remove the existing one before setting the mute push rule
            // This is a workaround to SYN-590 (Push rule update fails)
            deferred = utils.defer();
            this.deletePushRule(scope, "room", roomPushRule.rule_id)
            .then(function() {
                self.addPushRule(scope, "room", roomId, {
                    actions: ["dont_notify"],
                }).then(function() {
                    deferred.resolve();
                }, function(err) {
                    deferred.reject(err);
                });
            }, function(err) {
                deferred.reject(err);
            });

            deferred = deferred.promise;
        }
    }

    if (deferred) {
        return new Promise((resolve, reject) => {
            // Update this.pushRules when the operation completes
            deferred.then(function() {
                self.getPushRules().then(function(result) {
                    self.pushRules = result;
                    resolve();
                }, function(err) {
                    reject(err);
                });
            }, function(err) {
                // Update it even if the previous operation fails. This can help the
                // app to recover when push settings has been modifed from another client
                self.getPushRules().then(function(result) {
                    self.pushRules = result;
                    reject(err);
                }, function(err2) {
                    reject(err);
                });
            });
        });
    }
};

// Search
// ======

/**
 * Perform a server-side search for messages containing the given text.
 * @param {Object} opts Options for the search.
 * @param {string} opts.query The text to query.
 * @param {string=} opts.keys The keys to search on. Defaults to all keys. One
 * of "content.body", "content.name", "content.topic".
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.searchMessageText = function(opts, callback) {
    const roomEvents = {
        search_term: opts.query,
    };

    if ('keys' in opts) {
        roomEvents.keys = opts.keys;
    }

    return this.search({
        body: {
            search_categories: {
                room_events: roomEvents,
            },
        },
    }, callback);
};

/**
 * Perform a server-side search for room events.
 *
 * The returned promise resolves to an object containing the fields:
 *
 *  * {number}  count:       estimate of the number of results
 *  * {string}  next_batch:  token for back-pagination; if undefined, there are
 *                           no more results
 *  * {Array}   highlights:  a list of words to highlight from the stemming
 *                           algorithm
 *  * {Array}   results:     a list of results
 *
 * Each entry in the results list is a {module:models/search-result.SearchResult}.
 *
 * @param {Object} opts
 * @param {string} opts.term     the term to search for
 * @param {Object} opts.filter   a JSON filter object to pass in the request
 * @return {Promise} Resolves: result object
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.searchRoomEvents = function(opts) {
    // TODO: support groups

    const body = {
        search_categories: {
            room_events: {
                search_term: opts.term,
                filter: opts.filter,
                order_by: "recent",
                event_context: {
                    before_limit: 1,
                    after_limit: 1,
                    include_profile: true,
                },
            },
        },
    };

    const searchResults = {
        _query: body,
        results: [],
        highlights: [],
    };

    return this.search({body: body}).then(
        this._processRoomEventsSearch.bind(this, searchResults),
    );
};

/**
 * Take a result from an earlier searchRoomEvents call, and backfill results.
 *
 * @param  {object} searchResults  the results object to be updated
 * @return {Promise} Resolves: updated result object
 * @return {Error} Rejects: with an error response.
 */
MatrixClient.prototype.backPaginateRoomEventsSearch = function(searchResults) {
    // 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;
    }

    const searchOpts = {
        body: searchResults._query,
        next_batch: searchResults.next_batch,
    };

    const promise = this.search(searchOpts).then(
        this._processRoomEventsSearch.bind(this, searchResults),
    ).finally(function() {
        searchResults.pendingRequest = null;
    });
    searchResults.pendingRequest = promise;

    return promise;
};

/**
 * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the
 * response from the API call and updates the searchResults
 *
 * @param {Object} searchResults
 * @param {Object} response
 * @return {Object} searchResults
 * @private
 */
MatrixClient.prototype._processRoomEventsSearch = function(searchResults, response) {
    const room_events = response.search_categories.room_events;

    searchResults.count = room_events.count;
    searchResults.next_batch = room_events.next_batch;

    // combine the highlight list with our existing list; build an object
    // to avoid O(N^2) fail
    const highlights = {};
    room_events.highlights.forEach(function(hl) {
        highlights[hl] = 1;
    });
    searchResults.highlights.forEach(function(hl) {
        highlights[hl] = 1;
    });

    // turn it back into a list.
    searchResults.highlights = Object.keys(highlights);

    // append the new results to our existing results
    for (let i = 0; i < room_events.results.length; i++) {
        const sr = SearchResult.fromJson(room_events.results[i], this.getEventMapper());
        searchResults.results.push(sr);
    }
    return searchResults;
};


/**
 * Populate the store with rooms the user has left.
 * @return {Promise} Resolves: TODO - Resolved when the rooms have
 * been added to the data store.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.syncLeftRooms = function() {
    // 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 self = this;
    const syncApi = new SyncApi(this, this._clientOpts);
    this._syncLeftRoomsPromise = syncApi.syncLeftRooms();

    // cleanup locks
    this._syncLeftRoomsPromise.then(function(res) {
        logger.log("Marking success of sync left room request");
        self._syncedLeftRooms = true; // flip the bit on success
    }).finally(function() {
        self._syncLeftRoomsPromise = null; // cleanup ongoing request state
    });

    return this._syncLeftRoomsPromise;
};

// Filters
// =======

/**
 * Create a new filter.
 * @param {Object} content The HTTP body for the request
 * @return {Filter} Resolves to a Filter object.
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.createFilter = function(content) {
    const self = this;
    const path = utils.encodeUri("/user/$userId/filter", {
        $userId: this.credentials.userId,
    });
    return this._http.authedRequest(
        undefined, "POST", path, undefined, content,
    ).then(function(response) {
        // persist the filter
        const filter = Filter.fromJson(
            self.credentials.userId, response.filter_id, content,
        );
        self.store.storeFilter(filter);
        return filter;
    });
};

/**
 * Retrieve a filter.
 * @param {string} userId The user ID of the filter owner
 * @param {string} filterId The filter ID to retrieve
 * @param {boolean} allowCached True to allow cached filters to be returned.
 * Default: True.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getFilter = function(userId, filterId, allowCached) {
    if (allowCached) {
        const filter = this.store.getFilter(userId, filterId);
        if (filter) {
            return Promise.resolve(filter);
        }
    }

    const self = this;
    const path = utils.encodeUri("/user/$userId/filter/$filterId", {
        $userId: userId,
        $filterId: filterId,
    });

    return this._http.authedRequest(
        undefined, "GET", path, undefined, undefined,
    ).then(function(response) {
        // persist the filter
        const filter = Filter.fromJson(
            userId, filterId, response,
        );
        self.store.storeFilter(filter);
        return filter;
    });
};

/**
 * @param {string} filterName
 * @param {Filter} filter
 * @return {Promise<String>} Filter ID
 */
MatrixClient.prototype.getOrCreateFilter = async function(filterName, filter) {
    const filterId = this.store.getFilterIdByName(filterName);
    let existingId = 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 (error.errcode !== "M_UNKNOWN" && 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());

    // debuglog("Created new filter ID %s: %s", createdFilter.filterId,
    //          JSON.stringify(createdFilter.getDefinition()));
    this.store.setFilterIdByName(filterName, createdFilter.filterId);
    return createdFilter.filterId;
};


/**
 * Gets a bearer token from the Home Server that the user can
 * present to a third party in order to prove their ownership
 * of the Matrix account they are logged into.
 * @return {Promise} Resolves: Token object
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.getOpenIdToken = function() {
    const path = utils.encodeUri("/user/$userId/openid/request_token", {
        $userId: this.credentials.userId,
    });

    return this._http.authedRequest(
        undefined, "POST", path, undefined, {},
    );
};


// VoIP operations
// ===============

/**
 * @param {module:client.callback} callback Optional.
 * @return {Promise} Resolves: TODO
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype.turnServer = function(callback) {
    return this._http.authedRequest(callback, "GET", "/voip/turnServer");
};

/**
 * Get the TURN servers for this home server.
 * @return {Array<Object>} The servers or an empty list.
 */
MatrixClient.prototype.getTurnServers = function() {
    return this._turnServers || [];
};

/**
 * Get the unix timestamp (in seconds) at which the current
 * TURN credentials (from getTurnServers) expire
 * @return {number} The expiry timestamp, in seconds, or null if no credentials
 */
MatrixClient.prototype.getTurnServersExpiry = function() {
    return this._turnServersExpiry;
};

MatrixClient.prototype._checkTurnServers = async function() {
    if (!this._supportsVoip) {
        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 = {
                    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;
            }
        } catch (err) {
            logger.error("Failed to get TURN URIs", err);
            // If we get a 403, there's no point in looping forever.
            if (err.httpStatus === 403) {
                logger.info("TURN access unavailable for this account: stopping credentials checks");
                if (this._checkTurnServersIntervalID !== null) global.clearInterval(this._checkTurnServersIntervalID);
                this._checkTurnServersIntervalID = null;
            }
        }
        // otherwise, if we failed for whatever reason, try again the next time we're called.
    }

    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.
 *
 * @param {boolean} allow
 */
MatrixClient.prototype.setFallbackICEServerAllowed = function(allow) {
    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 {boolean}
 */
MatrixClient.prototype.isFallbackICEServerAllowed = function() {
    return this._fallbackICEServerAllowed;
};

// Synapse-specific APIs
// =====================

/**
 * 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>
 * @return {boolean} true if the user appears to be a Synapse administrator.
 */
MatrixClient.prototype.isSynapseAdministrator = function() {
    const path = utils.encodeUri(
        "/_synapse/admin/v1/users/$userId/admin",
        { $userId: this.getUserId() },
    );
    return this._http.authedRequest(
        undefined, '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 {string} userId the User ID to look up.
 * @return {object} the whois response - see Synapse docs for information.
 */
MatrixClient.prototype.whoisSynapseUser = function(userId) {
    const path = utils.encodeUri(
        "/_synapse/admin/v1/whois/$userId",
        { $userId: userId },
    );
    return this._http.authedRequest(
        undefined, '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 {string} userId the User ID to deactivate.
 * @return {object} the deactivate response - see Synapse docs for information.
 */
MatrixClient.prototype.deactivateSynapseUser = function(userId) {
    const path = utils.encodeUri(
        "/_synapse/admin/v1/deactivate/$userId",
        { $userId: userId },
    );
    return this._http.authedRequest(
        undefined, 'POST', path, undefined, undefined, {prefix: ''},
    );
};

// Higher level APIs
// =================

// TODO: stuff to handle:
//   local echo
//   event dup suppression? - apparently we should still be doing this
//   tracking current display name / avatar per-message
//   pagination
//   re-sending (including persisting pending messages to be sent)
//   - Need a nice way to callback the app for arbitrary events like
//     displayname changes
//   due to ambiguity (or should this be on a chat-specific layer)?
//   reconnect after connectivity outages


/**
 * High level helper method to begin syncing and poll for new events. To listen for these
 * events, add a listener for {@link module:client~MatrixClient#event:"event"}
 * via {@link module:client~MatrixClient#on}. Alternatively, listen for specific
 * state change events.
 * @param {Object=} opts Options to apply when syncing.
 * @param {Number=} opts.initialSyncLimit The event <code>limit=</code> to apply
 * to initial sync. Default: 8.
 * @param {Boolean=} opts.includeArchivedRooms True to put <code>archived=true</code>
 * on the <code>/initialSync</code> request. Default: false.
 * @param {Boolean=} opts.resolveInvitesToProfiles True to do /profile requests
 * on every invite event if the displayname/avatar_url is not known for this user ID.
 * Default: false.
 *
 * @param {String=} opts.pendingEventOrdering Controls where pending messages
 * appear in a room's timeline. If "<b>chronological</b>", messages will appear
 * in the timeline when the call to <code>sendEvent</code> was made. If
 * "<b>detached</b>", pending messages will appear in a separate list,
 * accessbile via {@link module:models/room#getPendingEvents}. Default:
 * "chronological".
 *
 * @param {Number=} opts.pollTimeout The number of milliseconds to wait on /sync.
 * Default: 30000 (30 seconds).
 *
 * @param {Filter=} opts.filter The filter to apply to /sync calls. This will override
 * the opts.initialSyncLimit, which would normally result in a timeline limit filter.
 *
 * @param {Boolean=} opts.disablePresence True to perform syncing without automatically
 * updating presence.
 * @param {Boolean=} opts.lazyLoadMembers 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.
 * @param {Number=} opts.clientWellKnownPollPeriod The number of seconds between polls
 * to /.well-known/matrix/client, undefined to disable. This should be in the order of hours.
 * Default: undefined.
 */
MatrixClient.prototype.startClient = async function(opts) {
    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));
    }

    if (this._crypto) {
        this._crypto.uploadDeviceKeys();
        this._crypto.start();
    }

    // periodically poll for turn servers if we support voip
    if (this._supportsVoip) {
        this._checkTurnServersIntervalID = setInterval(() => {
            this._checkTurnServers();
        }, TURN_CHECK_INTERVAL);
        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();
    }

    // shallow-copy the opts dict before modifying and storing it
    opts = Object.assign({}, opts);

    opts.crypto = this._crypto;
    opts.canResetEntireTimeline = (roomId) => {
        if (!this._canResetTimelineCallback) {
            return false;
        }
        return this._canResetTimelineCallback(roomId);
    };
    this._clientOpts = opts;
    this._syncApi = new SyncApi(this, opts);
    this._syncApi.sync();

    if (opts.clientWellKnownPollPeriod !== undefined) {
        this._clientWellKnownIntervalID =
            setInterval(() => {
                this._fetchClientWellKnown();
            }, 1000 * opts.clientWellKnownPollPeriod);
        this._fetchClientWellKnown();
    }
};

MatrixClient.prototype._fetchClientWellKnown = async function() {
    // `getRawClientConfig` does not throw or reject on network errors, instead
    // it absorbs errors and returns `{}`.
    this._clientWellKnownPromise = AutoDiscovery.getRawClientConfig(
        this.getDomain(),
    );
    this._clientWellKnown = await this._clientWellKnownPromise;
    this.emit("WellKnown.client", this._clientWellKnown);
};

MatrixClient.prototype.getClientWellKnown = function() {
    return this._clientWellKnown;
};

MatrixClient.prototype.waitForClientWellKnown = function() {
    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 {object} opts the complete set of client options
 * @return {Promise} for store operation */
MatrixClient.prototype._storeClientOptions = function() {
    const primTypes = ["boolean", "string", "number"];
    const serializableOpts = Object.entries(this._clientOpts)
        .filter(([key, value]) => {
            return primTypes.includes(typeof value);
        })
        .reduce((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 {string} userId The userId to check.
 * @return {Promise<string[]>} Resolves to a set of rooms
 * @return {module:http-api.MatrixError} Rejects: with an error response.
 */
MatrixClient.prototype._unstable_getSharedRooms = async function(userId) {
    if (!(await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666"))) {
        throw Error('Server does not support shared_rooms API');
    }
    const path = utils.encodeUri("/uk.half-shot.msc2666/user/shared_rooms/$userId", {
        $userId: userId,
    });
    const res = await this._http.authedRequest(
        undefined, "GET", path, undefined, undefined,
        {prefix: PREFIX_UNSTABLE},
    );
    return res.joined;
};

/**
 * High level helper method to stop the client from polling and allow a
 * clean shutdown.
 */
MatrixClient.prototype.stopClient = function() {
    logger.log('stopping MatrixClient');

    this.clientRunning = false;
    // TODO: f.e. Room => self.store.storeRoom(room) ?
    if (this._syncApi) {
        this._syncApi.stop();
        this._syncApi = null;
    }
    if (this._crypto) {
        this._crypto.stop();
    }
    if (this._peekSync) {
        this._peekSync.stopPeeking();
    }
    if (this._callEventHandler) {
        this._callEventHandler.stop();
        this._callEventHandler = null;
    }

    global.clearInterval(this._checkTurnServersIntervalID);
    if (this._clientWellKnownIntervalID !== undefined) {
        global.clearInterval(this._clientWellKnownIntervalID);
    }
};

/**
 * Get the API versions supported by the server, along with any
 * unstable APIs it supports
 * @return {Promise<object>} The server /versions response
 */
MatrixClient.prototype.getVersions = function() {
    if (this._serverVersionsPromise) {
        return this._serverVersionsPromise;
    }

    this._serverVersionsPromise = this._http.request(
        undefined, // callback
        "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 = null;
        // but rethrow the exception to anything that was waiting
        throw e;
    });

    return this._serverVersionsPromise;
};

/**
 * Check if a particular spec version is supported by the server.
 * @param {string} version The spec version (such as "r0.5.0") to check for.
 * @return {Promise<bool>} Whether it is supported
 */
MatrixClient.prototype.isVersionSupported = async function(version) {
    const { versions } = await this.getVersions();
    return versions && versions.includes(version);
};

/**
 * Query the server to see if it support members lazy loading
 * @return {Promise<boolean>} true if server supports lazy loading
 */
MatrixClient.prototype.doesServerSupportLazyLoading = async function() {
    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.
 * @return {Promise<boolean>} true if id_server parameter is required
 */
MatrixClient.prototype.doesServerRequireIdServerParam = async function() {
    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.
 * @return {Promise<boolean>} true if id_access_token can be sent
 */
MatrixClient.prototype.doesServerAcceptIdentityAccessToken = async function() {
    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.
 * @return {Promise<boolean>} true if separate functions are supported
 */
MatrixClient.prototype.doesServerSupportSeparateAddAndBind = async function() {
    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.separate_add_and_bind"]);
};

/**
 * Query the server to see if it lists support for an unstable feature
 * in the /versions response
 * @param {string} feature the feature name
 * @return {Promise<boolean>} true if the feature is supported
 */
MatrixClient.prototype.doesServerSupportUnstableFeature = async function(feature) {
    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 {string} presetName The name of the preset to check.
 * @returns {Promise<boolean>} true if the server is forcing encryption
 * for the preset.
 */
MatrixClient.prototype.doesServerForceEncryptionForPreset = async function(presetName) {
    const response = await this.getVersions();
    if (!response) return false;
    const unstableFeatures = response["unstable_features"];
    return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${presetName}`];
};

/**
 * Get if lazy loading members is being used.
 * @return {boolean} Whether or not members are lazy loaded by this client
 */
MatrixClient.prototype.hasLazyLoadMembersEnabled = function() {
    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 {Function} cb The callback which will be invoked.
 */
MatrixClient.prototype.setCanResetTimelineCallback = function(cb) {
    this._canResetTimelineCallback = cb;
};

/**
 * Get the callback set via `setCanResetTimelineCallback`.
 * @return {?Function} The callback or null
 */
MatrixClient.prototype.getCanResetTimelineCallback = function() {
    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 {string} roomId the room of the event
 * @param {string} eventId the id of the event
 * @param {string} relationType the rel_type of the relations requested
 * @param {string} eventType the event type of the relations requested
 * @param {Object} opts options with optional values for the request.
 * @param {Object} opts.from the pagination token returned from a previous request as `nextBatch` to return following relations.
 * @return {Object} an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available.
 */
MatrixClient.prototype.relations =
async function(roomId, eventId, relationType, eventType, opts = {}) {
    const fetchedEventType = _getEncryptedIfNeededEventType(this, roomId, eventType);
    const result = await this.fetchRelations(
        roomId,
        eventId,
        relationType,
        fetchedEventType,
        opts);
    const mapper = this.getEventMapper();
    let originalEvent;
    if (result.original_event) {
        originalEvent = mapper(result.original_event);
    }
    let events = result.chunk.map(mapper);
    if (fetchedEventType === "m.room.encrypted") {
        const allEvents = originalEvent ? events.concat(originalEvent) : events;
        await Promise.all(allEvents.map(e => {
            return new Promise(resolve => e.once("Event.decrypted", resolve));
        }));
        events = events.filter(e => e.getType() === eventType);
    }
    if (originalEvent && relationType === "m.replace") {
        events = events.filter(e => e.getSender() === originalEvent.getSender());
    }
    return {
        originalEvent,
        events,
        nextBatch: result.next_batch,
    };
};

function _reject(callback, reject, err) {
    if (callback) {
        callback(err);
    }
    reject(err);
}

function _resolve(callback, resolve, res) {
    if (callback) {
        callback(null, res);
    }
    resolve(res);
}

function _PojoToMatrixEventMapper(client, options) {
    const preventReEmit = Boolean(options && options.preventReEmit);
    function mapper(plainOldJsObject) {
        const event = new MatrixEvent(plainOldJsObject);
        if (event.isEncrypted()) {
            if (!preventReEmit) {
                client.reEmitter.reEmit(event, [
                    "Event.decrypted",
                ]);
            }
            event.attemptDecryption(client._crypto);
        }
        if (!preventReEmit) {
            client.reEmitter.reEmit(event, ["Event.replaced"]);
        }
        return event;
    }
    return mapper;
}

/**
 * @param {object} [options]
 * @param {bool} options.preventReEmit don't reemit events emitted on an event mapped by this mapper on the client
 * @return {Function}
 */
MatrixClient.prototype.getEventMapper = function(options = undefined) {
    return _PojoToMatrixEventMapper(this, options);
};

/**
 * The app may wish to see if we have a key cached without
 * triggering a user interaction.
 * @return {object}
 */
MatrixClient.prototype.getCrossSigningCacheCallbacks = function() {
    return this._crypto && this._crypto._crossSigningInfo.getCacheCallbacks();
};

// Identity Server Operations
// ==========================

/**
 * Generates a random string suitable for use as a client secret. <strong>This
 * method is experimental and may change.</strong>
 * @return {string} A new client secret
 */
MatrixClient.prototype.generateClientSecret = function() {
    return randomString(32);
};

// MatrixClient Event JSDocs

/**
 * 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.
 *
 * @event module:client~MatrixClient#"event"
 * @param {MatrixEvent} event The matrix event which caused this event to fire.
 * @example
 * matrixClient.on("event", function(event){
 *   var sender = event.getSender();
 * });
 */

/**
 * Fires whenever the SDK receives a new to-device event.
 * @event module:client~MatrixClient#"toDeviceEvent"
 * @param {MatrixEvent} event The matrix event which caused this event to fire.
 * @example
 * matrixClient.on("toDeviceEvent", function(event){
 *   var sender = event.getSender();
 * });
 */

/**
 * 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:
 * <pre>
 *                                          +---->STOPPED
 *                                          |
 *              +----->PREPARED -------> SYNCING <--+
 *              |                        ^  |  ^    |
 *              |      CATCHUP ----------+  |  |    |
 *              |        ^                  V  |    |
 *   null ------+        |  +------- RECONNECTING   |
 *              |        V  V                       |
 *              +------->ERROR ---------------------+
 *
 * NB: 'null' will never be emitted by this event.
 *
 * </pre>
 * Transitions:
 * <ul>
 *
 * <li><code>null -> PREPARED</code> : Occurs when the initial sync is completed
 * first time. This involves setting up filters and obtaining push rules.
 *
 * <li><code>null -> ERROR</code> : Occurs when the initial sync failed first time.
 *
 * <li><code>ERROR -> PREPARED</code> : Occurs when the initial sync succeeds
 * after previously failing.
 *
 * <li><code>PREPARED -> SYNCING</code> : Occurs immediately after transitioning
 * to PREPARED. Starts listening for live updates rather than catching up.
 *
 * <li><code>SYNCING -> RECONNECTING</code> : Occurs when the live update fails.
 *
 * <li><code>RECONNECTING -> RECONNECTING</code> : Can occur if the update calls
 * continue to fail, but the keepalive calls (to /versions) succeed.
 *
 * <li><code>RECONNECTING -> ERROR</code> : Occurs when the keepalive call also fails
 *
 * <li><code>ERROR -> SYNCING</code> : Occurs when the client has performed a
 * live update after having previously failed.
 *
 * <li><code>ERROR -> ERROR</code> : Occurs when the client has failed to keepalive
 * for a second time or more.</li>
 *
 * <li><code>SYNCING -> SYNCING</code> : Occurs when the client has performed a live
 * update. This is called <i>after</i> processing.</li>
 *
 * <li><code>* -> STOPPED</code> : Occurs once the client has stopped syncing or
 * trying to sync after stopClient has been called.</li>
 * </ul>
 *
 * @event module:client~MatrixClient#"sync"
 *
 * @param {string} state An enum representing the syncing state. One of "PREPARED",
 * "SYNCING", "ERROR", "STOPPED".
 *
 * @param {?string} prevState An enum representing the previous syncing state.
 * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
 *
 * @param {?Object} data Data about this transition.
 *
 * @param {MatrixError} data.error The matrix error if <code>state=ERROR</code>.
 *
 * @param {String} data.oldSyncToken The 'since' token passed to /sync.
 *    <code>null</code> for the first successful sync since this client was
 *    started. Only present if <code>state=PREPARED</code> or
 *    <code>state=SYNCING</code>.
 *
 * @param {String} data.nextSyncToken The 'next_batch' result from /sync, which
 *    will become the 'since' token for the next call to /sync. Only present if
 *    <code>state=PREPARED</code> or <code>state=SYNCING</code>.
 *
 * @param {boolean} data.catchingUp True if we are working our way through a
 *    backlog of events after connecting. Only present if <code>state=SYNCING</code>.
 *
 * @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;
 *   }
 * });
 */

 /**
 * Fires whenever the sdk learns about a new group. <strong>This event
 * is experimental and may change.</strong>
 * @event module:client~MatrixClient#"Group"
 * @param {Group} group The newly created, fully populated group.
 * @example
 * matrixClient.on("Group", function(group){
 *   var groupId = group.groupId;
 * });
 */

 /**
 * 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>
 * @event module:client~MatrixClient#"Room"
 * @param {Room} room The newly created, fully populated room.
 * @example
 * matrixClient.on("Room", function(room){
 *   var roomId = room.roomId;
 * });
 */

 /**
 * Fires whenever a Room is removed. This will fire when you forget a room.
 * <strong>This event is experimental and may change.</strong>
 * @event module:client~MatrixClient#"deleteRoom"
 * @param {string} roomId The deleted room ID.
 * @example
 * matrixClient.on("deleteRoom", function(roomId){
 *   // update UI from getRooms()
 * });
 */

/**
 * Fires whenever an incoming call arrives.
 * @event module:client~MatrixClient#"Call.incoming"
 * @param {module:webrtc/call~MatrixCall} call The incoming call.
 * @example
 * matrixClient.on("Call.incoming", function(call){
 *   call.answer(); // auto-answer
 * });
 */

/**
 * Fires whenever the login session the JS SDK is using is no
 * longer valid and the user must log in again.
 * NB. This only fires when action is required from the user, not
 * when then login session can be renewed by using a refresh token.
 * @event module:client~MatrixClient#"Session.logged_out"
 * @example
 * matrixClient.on("Session.logged_out", function(errorObj){
 *   // show the login screen
 * });
 */

/**
 * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response
 * to a HTTP request.
 * @event module:client~MatrixClient#"no_consent"
 * @example
 * matrixClient.on("no_consent", function(message, contentUri) {
 *     console.info(message + ' Go to ' + contentUri);
 * });
 */

/**
 * Fires when a device is marked as verified/unverified/blocked/unblocked by
 * {@link module:client~MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or
 * {@link module:client~MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}.
 *
 * @event module:client~MatrixClient#"deviceVerificationChanged"
 * @param {string} userId the owner of the verified device
 * @param {string} deviceId the id of the verified device
 * @param {module:crypto/deviceinfo} deviceInfo updated device information
 */

/**
 * Fires when the trust status of a user changes
 * If userId is the userId of the logged in user, this indicated a change
 * in the trust status of the cross-signing data on the account.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @event module:client~MatrixClient#"userTrustStatusChanged"
 * @param {string} userId the userId of the user in question
 * @param {UserTrustLevel} trustLevel The new trust level of the user
 */

/**
 * Fires when the user's cross-signing keys have changed or cross-signing
 * has been enabled/disabled. The client can use getStoredCrossSigningForUser
 * with the user ID of the logged in user to check if cross-signing is
 * enabled on the account. If enabled, it can test whether the current key
 * is trusted using with checkUserTrust with the user ID of the logged
 * in user. The checkOwnCrossSigningTrust function may be used to reconcile
 * the trust in the account key.
 *
 * The cross-signing API is currently UNSTABLE and may change without notice.
 *
 * @event module:client~MatrixClient#"crossSigning.keysChanged"
 */

/**
 * Fires whenever new user-scoped account_data is added.
 * @event module:client~MatrixClient#"accountData"
 * @param {MatrixEvent} event The event describing the account_data just added
 * @param {MatrixEvent} event The previous account data, if known.
 * @example
 * matrixClient.on("accountData", function(event, oldEvent){
 *   myAccountData[event.type] = event.content;
 * });
 */

/**
 * Fires whenever the stored devices for a user have changed
 * @event module:client~MatrixClient#"crypto.devicesUpdated"
 * @param {String[]} users A list of user IDs that were updated
 * @param {bool} initialFetch If true, the store was empty (apart
 *     from our own device) and has been seeded.
 */

/**
 * Fires whenever the stored devices for a user will be updated
 * @event module:client~MatrixClient#"crypto.willUpdateDevices"
 * @param {String[]} users A list of user IDs that will be updated
 * @param {bool} initialFetch If true, the store is empty (apart
 *     from our own device) and is being seeded.
 */

/**
 * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled()
 * @event module:client~MatrixClient#"crypto.keyBackupStatus"
 * @param {bool} enabled true if key backup has been enabled, otherwise false
 * @example
 * matrixClient.on("crypto.keyBackupStatus", function(enabled){
 *   if (enabled) {
 *     [...]
 *   }
 * });
 */

/**
 * Fires when we want to suggest to the user that they restore their megolm keys
 * from backup or by cross-signing the device.
 *
 * @event module:client~MatrixClient#"crypto.suggestKeyRestore"
 */

/**
 * Fires when a key verification is requested.
 * @event module:client~MatrixClient#"crypto.verification.request"
 * @param {object} data
 * @param {MatrixEvent} data.event the original verification request message
 * @param {Array} data.methods the verification methods that can be used
 * @param {Number} data.timeout the amount of milliseconds that should be waited
 *                 before cancelling the request automatically.
 * @param {Function} data.beginKeyVerification a function to call if a key
 *     verification should be performed.  The function takes one argument: the
 *     name of the key verification method (taken from data.methods) to use.
 * @param {Function} data.cancel a function to call if the key verification is
 *     rejected.
 */

/**
 * Fires when a key verification is requested with an unknown method.
 * @event module:client~MatrixClient#"crypto.verification.request.unknown"
 * @param {string} userId the user ID who requested the key verification
 * @param {Function} cancel a function that will send a cancellation message to
 *     reject the key verification.
 */

/**
 * Fires when a secret request has been cancelled.  If the client is prompting
 * the user to ask whether they want to share a secret, the prompt can be
 * dismissed.
 *
 * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
 *
 * @event module:client~MatrixClient#"crypto.secrets.requestCancelled"
 * @param {object} data
 * @param {string} data.user_id The user ID of the client that had requested the secret.
 * @param {string} data.device_id The device ID of the client that had requested the
 *     secret.
 * @param {string} data.request_id The ID of the original request.
 */

/**
 * Fires when the client .well-known info is fetched.
 *
 * @event module:client~MatrixClient#"WellKnown.client"
 * @param {object} data The JSON object returned by the server
 */

// EventEmitter JSDocs

/**
 * The {@link https://nodejs.org/api/events.html|EventEmitter} class.
 * @external EventEmitter
 * @see {@link https://nodejs.org/api/events.html}
 */

/**
 * Adds a listener to the end of the listeners array for the specified event.
 * No checks are made to see if the listener has already been added. Multiple
 * calls passing the same combination of event and listener will result in the
 * listener being added multiple times.
 * @function external:EventEmitter#on
 * @param {string} event The event to listen for.
 * @param {Function} listener The function to invoke.
 * @return {EventEmitter} for call chaining.
 */

/**
 * Alias for {@link external:EventEmitter#on}.
 * @function external:EventEmitter#addListener
 * @param {string} event The event to listen for.
 * @param {Function} listener The function to invoke.
 * @return {EventEmitter} for call chaining.
 */

/**
 * Adds a <b>one time</b> listener for the event. This listener is invoked only
 * the next time the event is fired, after which it is removed.
 * @function external:EventEmitter#once
 * @param {string} event The event to listen for.
 * @param {Function} listener The function to invoke.
 * @return {EventEmitter} for call chaining.
 */

/**
 * Remove a listener from the listener array for the specified event.
 * <b>Caution:</b> changes array indices in the listener array behind the
 * listener.
 * @function external:EventEmitter#removeListener
 * @param {string} event The event to listen for.
 * @param {Function} listener The function to invoke.
 * @return {EventEmitter} for call chaining.
 */

/**
 * Removes all listeners, or those of the specified event. It's not a good idea
 * to remove listeners that were added elsewhere in the code, especially when
 * it's on an emitter that you didn't create (e.g. sockets or file streams).
 * @function external:EventEmitter#removeAllListeners
 * @param {string} event Optional. The event to remove listeners for.
 * @return {EventEmitter} for call chaining.
 */

/**
 * Execute each of the listeners in order with the supplied arguments.
 * @function external:EventEmitter#emit
 * @param {string} event The event to emit.
 * @param {Function} listener The function to invoke.
 * @return {boolean} true if event had listeners, false otherwise.
 */

/**
 * By default EventEmitters will print a warning if more than 10 listeners are
 * added for a particular event. This is a useful default which helps finding
 * memory leaks. Obviously not all Emitters should be limited to 10. This
 * function allows that to be increased. Set to zero for unlimited.
 * @function external:EventEmitter#setMaxListeners
 * @param {Number} n The max number of listeners.
 * @return {EventEmitter} for call chaining.
 */

// MatrixClient Callback JSDocs

/**
 * The standard MatrixClient callback interface. Functions which accept this
 * will specify 2 return arguments. These arguments map to the 2 parameters
 * specified in this callback.
 * @callback module:client.callback
 * @param {Object} err The error value, the "rejected" value or null.
 * @param {Object} data The data returned, the "resolved" value.
 */
