UNPKG

matrix-js-sdk

Version:
1,386 lines (1,124 loc) 82.2 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.Room = exports.NotificationCountType = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _events = require("events"); var _eventTimelineSet = require("./event-timeline-set"); var _eventTimeline = require("./event-timeline"); var _contentRepo = require("../content-repo"); var utils = _interopRequireWildcard(require("../utils")); var _event = require("./event"); var _roomMember = require("./room-member"); var _roomSummary = require("./room-summary"); var _logger = require("../logger"); var _ReEmitter = require("../ReEmitter"); var _event2 = require("../@types/event"); var _client = require("../client"); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) { symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); } keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be // the same as the common default room version whereas SAFE_ROOM_VERSIONS are the // room versions which are considered okay for people to run without being asked // to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers // return an m.room_versions capability. const KNOWN_SAFE_ROOM_VERSION = '6'; const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; function synthesizeReceipt(userId, event, receiptType) { // console.log("synthesizing receipt for "+event.getId()); // This is really ugly because JS has no way to express an object literal // where the name of a key comes from an expression const fakeReceipt = { content: {}, type: "m.receipt", room_id: event.getRoomId() }; fakeReceipt.content[event.getId()] = {}; fakeReceipt.content[event.getId()][receiptType] = {}; fakeReceipt.content[event.getId()][receiptType][userId] = { ts: event.getTs() }; return new _event.MatrixEvent(fakeReceipt); } let NotificationCountType; exports.NotificationCountType = NotificationCountType; (function (NotificationCountType) { NotificationCountType["Highlight"] = "highlight"; NotificationCountType["Total"] = "total"; })(NotificationCountType || (exports.NotificationCountType = NotificationCountType = {})); class Room extends _events.EventEmitter { // Pending in-flight requests { string: MatrixEvent } // receipts should clobber based on receipt_type and user_id pairs hence // the form of this structure. This is sub-optimal for the exposed APIs // which pass in an event ID and get back some receipts, so we also store // a pre-cached list for this purpose. // { receipt_type: { user_id: IReceipt } } // { event_id: IReceipt2[] } // only receipts that came from the server, not synthesized ones // any filtered timeline sets we're maintaining for this room // filter_id: timelineSet // read by megolm via getter; boolean value - null indicates "use global value" // flags to stop logspam about missing m.room.create events // XXX: These should be read-only // $tagName: { $metadata: $value } // $eventType: $event // legacy fields /** * Construct a new Room. * * <p>For a room, we store an ordered sequence of timelines, which may or may not * be continuous. Each timeline lists a series of events, as well as tracking * the room state at the start and the end of the timeline. It also tracks * forward and backward pagination tokens, as well as containing links to the * next timeline in the sequence. * * <p>There is one special timeline - the 'live' timeline, which represents the * timeline to which events are being added in real-time as they are received * from the /sync API. Note that you should not retain references to this * timeline - even if it is the current timeline right now, it may not remain * so if the server gives us a timeline gap in /sync. * * <p>In order that we can find events from their ids later, we also maintain a * map from event_id to timeline and index. * * @constructor * @alias module:models/room * @param {string} roomId Required. The ID of this room. * @param {MatrixClient} client Required. The client, used to lazy load members. * @param {string} myUserId Required. The ID of the syncing user. * @param {Object=} opts Configuration options * @param {*} opts.storageToken Optional. The token which a data store can use * to remember the state of the room. What this means is dependent on the store * implementation. * * @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, * accessible via {@link module:models/room#getPendingEvents}. Default: * "chronological". * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved * timeline support. * @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. * * @prop {string} roomId The ID of this room. * @prop {string} name The human-readable display name for this room. * @prop {string} normalizedName The un-homoglyphed name for this room. * @prop {Array<MatrixEvent>} timeline The live event timeline for this room, * with the oldest event at index 0. Present for backwards compatibility - * prefer getLiveTimeline().getEvents(). * @prop {object} tags Dict of room tags; the keys are the tag name and the values * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } * @prop {object} accountData Dict of per-room account_data events; the keys are the * event type and the values are the events. * @prop {RoomState} oldState The state of the room at the time of the oldest * event in the live timeline. Present for backwards compatibility - * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). * @prop {RoomState} currentState The state of the room at the time of the * newest event in the timeline. Present for backwards compatibility - * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). * @prop {RoomSummary} summary The room summary. * @prop {*} storageToken A token which a data store can use to remember * the state of the room. */ constructor(roomId, client, myUserId, opts = {}) { super(); // In some cases, we add listeners for every displayed Matrix event, so it's // common to have quite a few more than the default limit. this.roomId = roomId; this.client = client; this.myUserId = myUserId; this.opts = opts; (0, _defineProperty2.default)(this, "reEmitter", void 0); (0, _defineProperty2.default)(this, "txnToEvent", {}); (0, _defineProperty2.default)(this, "receipts", {}); (0, _defineProperty2.default)(this, "receiptCacheByEventId", {}); (0, _defineProperty2.default)(this, "realReceipts", {}); (0, _defineProperty2.default)(this, "notificationCounts", {}); (0, _defineProperty2.default)(this, "timelineSets", void 0); (0, _defineProperty2.default)(this, "filteredTimelineSets", {}); (0, _defineProperty2.default)(this, "pendingEventList", void 0); (0, _defineProperty2.default)(this, "blacklistUnverifiedDevices", null); (0, _defineProperty2.default)(this, "selfMembership", null); (0, _defineProperty2.default)(this, "summaryHeroes", null); (0, _defineProperty2.default)(this, "getTypeWarning", false); (0, _defineProperty2.default)(this, "getVersionWarning", false); (0, _defineProperty2.default)(this, "membersPromise", void 0); (0, _defineProperty2.default)(this, "name", void 0); (0, _defineProperty2.default)(this, "normalizedName", void 0); (0, _defineProperty2.default)(this, "tags", {}); (0, _defineProperty2.default)(this, "accountData", {}); (0, _defineProperty2.default)(this, "summary", null); (0, _defineProperty2.default)(this, "storageToken", void 0); (0, _defineProperty2.default)(this, "timeline", void 0); (0, _defineProperty2.default)(this, "oldState", void 0); (0, _defineProperty2.default)(this, "currentState", void 0); this.setMaxListeners(100); this.reEmitter = new _ReEmitter.ReEmitter(this); opts.pendingEventOrdering = opts.pendingEventOrdering || _client.PendingEventOrdering.Chronological; if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { throw new Error("opts.pendingEventOrdering MUST be either 'chronological' or " + "'detached'. Got: '" + opts.pendingEventOrdering + "'"); } this.name = roomId; // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new _eventTimelineSet.EventTimelineSet(this, opts)]; this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); this.fixUpLegacyTimelineFields(); if (this.opts.pendingEventOrdering == "detached") { this.pendingEventList = []; const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId)); if (serializedPendingEventList) { JSON.parse(serializedPendingEventList).forEach(async serializedEvent => { const event = new _event.MatrixEvent(serializedEvent); if (event.getType() === _event2.EventType.RoomMessageEncrypted) { await event.attemptDecryption(this.client.crypto); } event.setStatus(_event.EventStatus.NOT_SENT); this.addPendingEvent(event, event.getTxnId()); }); } } // awaited by getEncryptionTargetMembers while room members are loading if (!this.opts.lazyLoadMembers) { this.membersPromise = Promise.resolve(false); } else { this.membersPromise = null; } } /** * Bulk decrypt critical events in a room * * Critical events represents the minimal set of events to decrypt * for a typical UI to function properly * * - Last event of every room (to generate likely message preview) * - All events up to the read receipt (to calculate an accurate notification count) * * @returns {Promise} Signals when all events have been decrypted */ decryptCriticalEvents() { const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId(), true); const events = this.getLiveTimeline().getEvents(); const readReceiptTimelineIndex = events.findIndex(matrixEvent => { return matrixEvent.event.event_id === readReceiptEventId; }); const decryptionPromises = events.slice(readReceiptTimelineIndex).filter(event => event.shouldAttemptDecryption()).reverse().map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); return Promise.allSettled(decryptionPromises); } /** * Bulk decrypt events in a room * * @returns {Promise} Signals when all events have been decrypted */ decryptAllEvents() { const decryptionPromises = this.getUnfilteredTimelineSet().getLiveTimeline().getEvents().filter(event => event.shouldAttemptDecryption()).reverse().map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); return Promise.allSettled(decryptionPromises); } /** * Gets the version of the room * @returns {string} The version of the room, or null if it could not be determined */ getVersion() { const createEvent = this.currentState.getStateEvents(_event2.EventType.RoomCreate, ""); if (!createEvent) { if (!this.getVersionWarning) { _logger.logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); this.getVersionWarning = true; } return '1'; } const ver = createEvent.getContent()['room_version']; if (ver === undefined) return '1'; return ver; } /** * Determines whether this room needs to be upgraded to a new version * @returns {string?} What version the room should be upgraded to, or null if * the room does not require upgrading at this time. * @deprecated Use #getRecommendedVersion() instead */ shouldUpgradeToVersion() { // TODO: Remove this function. // This makes assumptions about which versions are safe, and can easily // be wrong. Instead, people are encouraged to use getRecommendedVersion // which determines a safer value. This function doesn't use that function // because this is not async-capable, and to avoid breaking the contract // we're deprecating this. if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { return KNOWN_SAFE_ROOM_VERSION; } return null; } /** * Determines the recommended room version for the room. This returns an * object with 3 properties: <code>version</code> as the new version the * room should be upgraded to (may be the same as the current version); * <code>needsUpgrade</code> to indicate if the room actually can be * upgraded (ie: does the current version not match?); and <code>urgent</code> * to indicate if the new version patches a vulnerability in a previous * version. * @returns {Promise<{version: string, needsUpgrade: boolean, urgent: boolean}>} * Resolves to the version the room should be upgraded to. */ async getRecommendedVersion() { const capabilities = await this.client.getCapabilities(); let versionCap = capabilities["m.room_versions"]; if (!versionCap) { versionCap = { default: KNOWN_SAFE_ROOM_VERSION, available: {} }; for (const safeVer of SAFE_ROOM_VERSIONS) { versionCap.available[safeVer] = _client.RoomVersionStability.Stable; } } let result = this.checkVersionAgainstCapability(versionCap); if (result.urgent && result.needsUpgrade) { // Something doesn't feel right: we shouldn't need to update // because the version we're on should be in the protocol's // namespace. This usually means that the server was updated // before the client was, making us think the newest possible // room version is not stable. As a solution, we'll refresh // the capability we're using to determine this. _logger.logger.warn("Refreshing room version capability because the server looks " + "to be supporting a newer room version we don't know about."); const caps = await this.client.getCapabilities(true); versionCap = caps["m.room_versions"]; if (!versionCap) { _logger.logger.warn("No room version capability - assuming upgrade required."); return result; } else { result = this.checkVersionAgainstCapability(versionCap); } } return result; } checkVersionAgainstCapability(versionCap) { const currentVersion = this.getVersion(); _logger.logger.log(`[${this.roomId}] Current version: ${currentVersion}`); _logger.logger.log(`[${this.roomId}] Version capability: `, versionCap); const result = { version: currentVersion, needsUpgrade: false, urgent: false }; // If the room is on the default version then nothing needs to change if (currentVersion === versionCap.default) return result; const stableVersions = Object.keys(versionCap.available).filter(v => versionCap.available[v] === 'stable'); // Check if the room is on an unstable version. We determine urgency based // off the version being in the Matrix spec namespace or not (if the version // is in the current namespace and unstable, the room is probably vulnerable). if (!stableVersions.includes(currentVersion)) { result.version = versionCap.default; result.needsUpgrade = true; result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); if (result.urgent) { _logger.logger.warn(`URGENT upgrade required on ${this.roomId}`); } else { _logger.logger.warn(`Non-urgent upgrade required on ${this.roomId}`); } return result; } // The room is on a stable, but non-default, version by this point. // No upgrade needed. return result; } /** * Determines whether the given user is permitted to perform a room upgrade * @param {String} userId The ID of the user to test against * @returns {boolean} True if the given user is permitted to upgrade the room */ userMayUpgradeRoom(userId) { return this.currentState.maySendStateEvent(_event2.EventType.RoomTombstone, userId); } /** * Get the list of pending sent events for this room * * @return {module:models/event.MatrixEvent[]} A list of the sent events * waiting for remote echo. * * @throws If <code>opts.pendingEventOrdering</code> was not 'detached' */ getPendingEvents() { if (this.opts.pendingEventOrdering !== "detached") { throw new Error("Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering); } return this.pendingEventList; } /** * Removes a pending event for this room * * @param {string} eventId * @return {boolean} True if an element was removed. */ removePendingEvent(eventId) { if (this.opts.pendingEventOrdering !== "detached") { throw new Error("Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering); } const removed = utils.removeElement(this.pendingEventList, function (ev) { return ev.getId() == eventId; }, false); this.savePendingEvents(); return removed; } /** * Check whether the pending event list contains a given event by ID. * If pending event ordering is not "detached" then this returns false. * * @param {string} eventId The event ID to check for. * @return {boolean} */ hasPendingEvent(eventId) { if (this.opts.pendingEventOrdering !== "detached") { return false; } return this.pendingEventList.some(event => event.getId() === eventId); } /** * Get a specific event from the pending event list, if configured, null otherwise. * * @param {string} eventId The event ID to check for. * @return {MatrixEvent} */ getPendingEvent(eventId) { if (this.opts.pendingEventOrdering !== "detached") { return null; } return this.pendingEventList.find(event => event.getId() === eventId); } /** * Get the live unfiltered timeline for this room. * * @return {module:models/event-timeline~EventTimeline} live timeline */ getLiveTimeline() { return this.getUnfilteredTimelineSet().getLiveTimeline(); } /** * Get the timestamp of the last message in the room * * @return {number} the timestamp of the last message in the room */ getLastActiveTimestamp() { const timeline = this.getLiveTimeline(); const events = timeline.getEvents(); if (events.length) { const lastEvent = events[events.length - 1]; return lastEvent.getTs(); } else { return Number.MIN_SAFE_INTEGER; } } /** * @return {string} the membership type (join | leave | invite) for the logged in user */ getMyMembership() { return this.selfMembership; } /** * If this room is a DM we're invited to, * try to find out who invited us * @return {string} user id of the inviter */ getDMInviter() { if (this.myUserId) { const me = this.getMember(this.myUserId); if (me) { return me.getDMInviter(); } } if (this.selfMembership === "invite") { // fall back to summary information const memberCount = this.getInvitedAndJoinedMemberCount(); if (memberCount == 2 && this.summaryHeroes.length) { return this.summaryHeroes[0]; } } } /** * Assuming this room is a DM room, tries to guess with which user. * @return {string} user id of the other member (could be syncing user) */ guessDMUserId() { const me = this.getMember(this.myUserId); if (me) { const inviterId = me.getDMInviter(); if (inviterId) { return inviterId; } } // remember, we're assuming this room is a DM, // so returning the first member we find should be fine const hasHeroes = Array.isArray(this.summaryHeroes) && this.summaryHeroes.length; if (hasHeroes) { return this.summaryHeroes[0]; } const members = this.currentState.getMembers(); const anyMember = members.find(m => m.userId !== this.myUserId); if (anyMember) { return anyMember.userId; } // it really seems like I'm the only user in the room // so I probably created a room with just me in it // and marked it as a DM. Ok then return this.myUserId; } getAvatarFallbackMember() { const memberCount = this.getInvitedAndJoinedMemberCount(); if (memberCount > 2) { return; } const hasHeroes = Array.isArray(this.summaryHeroes) && this.summaryHeroes.length; if (hasHeroes) { const availableMember = this.summaryHeroes.map(userId => { return this.getMember(userId); }).find(member => !!member); if (availableMember) { return availableMember; } } const members = this.currentState.getMembers(); // could be different than memberCount // as this includes left members if (members.length <= 2) { const availableMember = members.find(m => { return m.userId !== this.myUserId; }); if (availableMember) { return availableMember; } } // if all else fails, try falling back to a user, // and create a one-off member for it if (hasHeroes) { const availableUser = this.summaryHeroes.map(userId => { return this.client.getUser(userId); }).find(user => !!user); if (availableUser) { const member = new _roomMember.RoomMember(this.roomId, availableUser.userId); member.user = availableUser; return member; } } } /** * Sets the membership this room was received as during sync * @param {string} membership join | leave | invite */ updateMyMembership(membership) { const prevMembership = this.selfMembership; this.selfMembership = membership; if (prevMembership !== membership) { if (membership === "leave") { this.cleanupAfterLeaving(); } this.emit("Room.myMembership", this, membership, prevMembership); } } async loadMembersFromServer() { const lastSyncToken = this.client.store.getSyncToken(); const queryString = utils.encodeParams({ not_membership: "leave", at: lastSyncToken }); const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, { $roomId: this.roomId }); const http = this.client.http; const response = await http.authedRequest(undefined, "GET", path); return response.chunk; } async loadMembers() { // were the members loaded from the server? let fromServer = false; let rawMembersEvents = await this.client.store.getOutOfBandMembers(this.roomId); if (rawMembersEvents === null) { fromServer = true; rawMembersEvents = await this.loadMembersFromServer(); _logger.logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); } const memberEvents = rawMembersEvents.map(this.client.getEventMapper()); return { memberEvents, fromServer }; } /** * Preloads the member list in case lazy loading * of memberships is in use. Can be called multiple times, * it will only preload once. * @return {Promise} when preloading is done and * accessing the members on the room will take * all members in the room into account */ loadMembersIfNeeded() { if (this.membersPromise) { return this.membersPromise; } // mark the state so that incoming messages while // the request is in flight get marked as superseding // the OOB members this.currentState.markOutOfBandMembersStarted(); const inMemoryUpdate = this.loadMembers().then(result => { this.currentState.setOutOfBandMembers(result.memberEvents); // now the members are loaded, start to track the e2e devices if needed if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) { this.client.crypto.trackRoomDevices(this.roomId); } return result.fromServer; }).catch(err => { // allow retries on fail this.membersPromise = null; this.currentState.markOutOfBandMembersFailed(); throw err; }); // update members in storage, but don't wait for it inMemoryUpdate.then(fromServer => { if (fromServer) { const oobMembers = this.currentState.getMembers().filter(m => m.isOutOfBand()).map(m => m.events.member.event); _logger.logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`); const store = this.client.store; return store.setOutOfBandMembers(this.roomId, oobMembers) // swallow any IDB error as we don't want to fail // because of this .catch(err => { _logger.logger.log("LL: storing OOB room members failed, oh well", err); }); } }).catch(err => { // as this is not awaited anywhere, // at least show the error in the console _logger.logger.error(err); }); this.membersPromise = inMemoryUpdate; return this.membersPromise; } /** * Removes the lazily loaded members from storage if needed */ async clearLoadedMembersIfNeeded() { if (this.opts.lazyLoadMembers && this.membersPromise) { await this.loadMembersIfNeeded(); await this.client.store.clearOutOfBandMembers(this.roomId); this.currentState.clearOutOfBandMembers(); this.membersPromise = null; } } /** * called when sync receives this room in the leave section * to do cleanup after leaving a room. Possibly called multiple times. */ cleanupAfterLeaving() { this.clearLoadedMembersIfNeeded().catch(err => { _logger.logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`); _logger.logger.log(err); }); } /** * Reset the live timeline of all timelineSets, and start new ones. * * <p>This is used when /sync returns a 'limited' timeline. * * @param {string=} backPaginationToken token for back-paginating the new timeline * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, * if absent or null, all timelines are reset, removing old ones (including the previous live * timeline which would otherwise be unable to paginate forwards without this token). * Removing just the old live timeline whilst preserving previous ones is not supported. */ resetLiveTimeline(backPaginationToken, forwardPaginationToken) { for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].resetLiveTimeline(backPaginationToken, forwardPaginationToken); } this.fixUpLegacyTimelineFields(); } /** * Fix up this.timeline, this.oldState and this.currentState * * @private */ fixUpLegacyTimelineFields() { // maintain this.timeline as a reference to the live timeline, // and this.oldState and this.currentState as references to the // state at the start and end of that timeline. These are more // for backwards-compatibility than anything else. this.timeline = this.getLiveTimeline().getEvents(); this.oldState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.BACKWARDS); this.currentState = this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS); } /** * Returns whether there are any devices in the room that are unverified * * Note: Callers should first check if crypto is enabled on this device. If it is * disabled, then we aren't tracking room devices at all, so we can't answer this, and an * error will be thrown. * * @return {boolean} the result */ async hasUnverifiedDevices() { if (!this.client.isRoomEncrypted(this.roomId)) { return false; } const e2eMembers = await this.getEncryptionTargetMembers(); for (const member of e2eMembers) { const devices = this.client.getStoredDevicesForUser(member.userId); if (devices.some(device => device.isUnverified())) { return true; } } return false; } /** * Return the timeline sets for this room. * @return {EventTimelineSet[]} array of timeline sets for this room */ getTimelineSets() { return this.timelineSets; } /** * Helper to return the main unfiltered timeline set for this room * @return {EventTimelineSet} room's unfiltered timeline set */ getUnfilteredTimelineSet() { return this.timelineSets[0]; } /** * Get the timeline which contains the given event from the unfiltered set, if any * * @param {string} eventId event ID to look for * @return {?module:models/event-timeline~EventTimeline} timeline containing * the given event, or null if unknown */ getTimelineForEvent(eventId) { return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); } /** * Add a new timeline to this room's unfiltered timeline set * * @return {module:models/event-timeline~EventTimeline} newly-created timeline */ addTimeline() { return this.getUnfilteredTimelineSet().addTimeline(); } /** * Get an event which is stored in our unfiltered timeline set * * @param {string} eventId event ID to look for * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown */ findEventById(eventId) { return this.getUnfilteredTimelineSet().findEventById(eventId); } /** * Get one of the notification counts for this room * @param {String} type The type of notification count to get. default: 'total' * @return {Number} The notification count, or undefined if there is no count * for this type. */ getUnreadNotificationCount(type = NotificationCountType.Total) { return this.notificationCounts[type]; } /** * Set one of the notification counts for this room * @param {String} type The type of notification count to set. * @param {Number} count The new count */ setUnreadNotificationCount(type, count) { this.notificationCounts[type] = count; } setSummary(summary) { const heroes = summary["m.heroes"]; const joinedCount = summary["m.joined_member_count"]; const invitedCount = summary["m.invited_member_count"]; if (Number.isInteger(joinedCount)) { this.currentState.setJoinedMemberCount(joinedCount); } if (Number.isInteger(invitedCount)) { this.currentState.setInvitedMemberCount(invitedCount); } if (Array.isArray(heroes)) { // be cautious about trusting server values, // and make sure heroes doesn't contain our own id // just to be sure this.summaryHeroes = heroes.filter(userId => { return userId !== this.myUserId; }); } } /** * Whether to send encrypted messages to devices within this room. * @param {Boolean} value true to blacklist unverified devices, null * to use the global value for this room. */ setBlacklistUnverifiedDevices(value) { this.blacklistUnverifiedDevices = value; } /** * Whether to send encrypted messages to devices within this room. * @return {Boolean} true if blacklisting unverified devices, null * if the global value should be used for this room. */ getBlacklistUnverifiedDevices() { return this.blacklistUnverifiedDevices; } /** * Get the avatar URL for a room if one was set. * @param {String} baseUrl The homeserver base URL. See * {@link module:client~MatrixClient#getHomeserverUrl}. * @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} allowDefault True to allow an identicon for this room if an * avatar URL wasn't explicitly set. Default: true. (Deprecated) * @return {?string} the avatar URL or null. */ getAvatarUrl(baseUrl, width, height, resizeMethod, allowDefault = true) { const roomAvatarEvent = this.currentState.getStateEvents(_event2.EventType.RoomAvatar, ""); if (!roomAvatarEvent && !allowDefault) { return null; } const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; if (mainUrl) { return (0, _contentRepo.getHttpUriForMxc)(baseUrl, mainUrl, width, height, resizeMethod); } return null; } /** * Get the mxc avatar url for the room, if one was set. * @return {string} the mxc avatar url or falsy */ getMxcAvatarUrl() { var _this$currentState$ge, _this$currentState$ge2; return ((_this$currentState$ge = this.currentState.getStateEvents(_event2.EventType.RoomAvatar, "")) === null || _this$currentState$ge === void 0 ? void 0 : (_this$currentState$ge2 = _this$currentState$ge.getContent()) === null || _this$currentState$ge2 === void 0 ? void 0 : _this$currentState$ge2.url) || null; } /** * Get the aliases this room has according to the room's state * The aliases returned by this function may not necessarily * still point to this room. * @return {array} The room's alias as an array of strings */ getAliases() { const aliasStrings = []; const aliasEvents = this.currentState.getStateEvents(_event2.EventType.RoomAliases); if (aliasEvents) { for (let i = 0; i < aliasEvents.length; ++i) { const aliasEvent = aliasEvents[i]; if (Array.isArray(aliasEvent.getContent().aliases)) { const filteredAliases = aliasEvent.getContent().aliases.filter(a => { if (typeof a !== "string") return false; if (a[0] !== '#') return false; if (!a.endsWith(`:${aliasEvent.getStateKey()}`)) return false; // It's probably valid by here. return true; }); Array.prototype.push.apply(aliasStrings, filteredAliases); } } } return aliasStrings; } /** * Get this room's canonical alias * The alias returned by this function may not necessarily * still point to this room. * @return {?string} The room's canonical alias, or null if there is none */ getCanonicalAlias() { const canonicalAlias = this.currentState.getStateEvents(_event2.EventType.RoomCanonicalAlias, ""); if (canonicalAlias) { return canonicalAlias.getContent().alias || null; } return null; } /** * Get this room's alternative aliases * @return {array} The room's alternative aliases, or an empty array */ getAltAliases() { const canonicalAlias = this.currentState.getStateEvents(_event2.EventType.RoomCanonicalAlias, ""); if (canonicalAlias) { return canonicalAlias.getContent().alt_aliases || []; } return []; } /** * Add events to a timeline * * <p>Will fire "Room.timeline" for each event added. * * @param {MatrixEvent[]} events A list of events to add. * * @param {boolean} toStartOfTimeline True to add these events to the start * (oldest) instead of the end (newest) of the timeline. If true, the oldest * event will be the <b>last</b> element of 'events'. * * @param {module:models/event-timeline~EventTimeline} timeline timeline to * add events to. * * @param {string=} paginationToken token for the next batch of events * * @fires module:client~MatrixClient#event:"Room.timeline" * */ addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken) { timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken); } /** * Get a member from the current room state. * @param {string} userId The user ID of the member. * @return {RoomMember} The member or <code>null</code>. */ getMember(userId) { return this.currentState.getMember(userId); } /** * Get all currently loaded members from the current * room state. * @returns {RoomMember[]} Room members */ getMembers() { return this.currentState.getMembers(); } /** * Get a list of members whose membership state is "join". * @return {RoomMember[]} A list of currently joined members. */ getJoinedMembers() { return this.getMembersWithMembership("join"); } /** * Returns the number of joined members in this room * This method caches the result. * This is a wrapper around the method of the same name in roomState, returning * its result for the room's current state. * @return {number} The number of members in this room whose membership is 'join' */ getJoinedMemberCount() { return this.currentState.getJoinedMemberCount(); } /** * Returns the number of invited members in this room * @return {number} The number of members in this room whose membership is 'invite' */ getInvitedMemberCount() { return this.currentState.getInvitedMemberCount(); } /** * Returns the number of invited + joined members in this room * @return {number} The number of members in this room whose membership is 'invite' or 'join' */ getInvitedAndJoinedMemberCount() { return this.getInvitedMemberCount() + this.getJoinedMemberCount(); } /** * Get a list of members with given membership state. * @param {string} membership The membership state. * @return {RoomMember[]} A list of members with the given membership state. */ getMembersWithMembership(membership) { return this.currentState.getMembers().filter(function (m) { return m.membership === membership; }); } /** * Get a list of members we should be encrypting for in this room * @return {Promise<RoomMember[]>} A list of members who * we should encrypt messages for in this room. */ async getEncryptionTargetMembers() { await this.loadMembersIfNeeded(); let members = this.getMembersWithMembership("join"); if (this.shouldEncryptForInvitedMembers()) { members = members.concat(this.getMembersWithMembership("invite")); } return members; } /** * Determine whether we should encrypt messages for invited users in this room * @return {boolean} if we should encrypt messages for invited users */ shouldEncryptForInvitedMembers() { var _ev$getContent; const ev = this.currentState.getStateEvents(_event2.EventType.RoomHistoryVisibility, ""); return (ev === null || ev === void 0 ? void 0 : (_ev$getContent = ev.getContent()) === null || _ev$getContent === void 0 ? void 0 : _ev$getContent.history_visibility) !== "joined"; } /** * Get the default room name (i.e. what a given user would see if the * room had no m.room.name) * @param {string} userId The userId from whose perspective we want * to calculate the default name * @return {string} The default room name */ getDefaultRoomName(userId) { return this.calculateRoomName(userId, true); } /** * Check if the given user_id has the given membership state. * @param {string} userId The user ID to check. * @param {string} membership The membership e.g. <code>'join'</code> * @return {boolean} True if this user_id has the given membership state. */ hasMembershipState(userId, membership) { const member = this.getMember(userId); if (!member) { return false; } return member.membership === membership; } /** * Add a timelineSet for this room with the given filter * @param {Filter} filter The filter to be applied to this timelineSet * @return {EventTimelineSet} The timelineSet */ getOrCreateFilteredTimelineSet(filter) { if (this.filteredTimelineSets[filter.filterId]) { return this.filteredTimelineSets[filter.filterId]; } const opts = Object.assign({ filter: filter }, this.opts); const timelineSet = new _eventTimelineSet.EventTimelineSet(this, opts); this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); this.filteredTimelineSets[filter.filterId] = timelineSet; this.timelineSets.push(timelineSet); // populate up the new timelineSet with filtered events from our live // unfiltered timeline. // // XXX: This is risky as our timeline // may have grown huge and so take a long time to filter. // see https://github.com/vector-im/vector-web/issues/2109 const unfilteredLiveTimeline = this.getLiveTimeline(); unfilteredLiveTimeline.getEvents().forEach(function (event) { timelineSet.addLiveEvent(event); }); // find the earliest unfiltered timeline let timeline = unfilteredLiveTimeline; while (timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS)) { timeline = timeline.getNeighbouringTimeline(_eventTimeline.EventTimeline.BACKWARDS); } timelineSet.getLiveTimeline().setPaginationToken(timeline.getPaginationToken(_eventTimeline.EventTimeline.BACKWARDS), _eventTimeline.EventTimeline.BACKWARDS); // alternatively, we could try to do something like this to try and re-paginate // in the filtered events from nothing, but Mark says it's an abuse of the API // to do so: // // timelineSet.resetLiveTimeline( // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) // ); return timelineSet; } /** * Forget the timelineSet for this room with the given filter * * @param {Filter} filter the filter whose timelineSet is to be forgotten */ removeFilteredTimelineSet(filter) { const timelineSet = this.filteredTimelineSets[filter.filterId]; delete this.filteredTimelineSets[filter.filterId]; const i = this.timelineSets.indexOf(timelineSet); if (i > -1) { this.timelineSets.splice(i, 1); } } /** * Add an event to the end of this room's live timelines. Will fire * "Room.timeline". * * @param {MatrixEvent} event Event to be added * @param {string?} duplicateStrategy 'ignore' or 'replace' * @param {boolean} fromCache whether the sync response came from cache * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ addLiveEvent(event, duplicateStrategy, fromCache = false) { if (event.isRedaction()) { const redactId = event.event.redacts; // if we know about this event, redact its contents now. const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); if (redactedEvent) { redactedEvent.makeRedacted(event); // If this is in the current state, replace it with the redacted version if (redactedEvent.getStateKey()) { const currentStateEvent = this.currentState.getStateEvents(redactedEvent.getType(), redactedEvent.getStateKey()); if (currentStateEvent.getId() === redactedEvent.getId()) { this.currentState.setStateEvents([redactedEvent]); } } this.emit("Room.redaction", event, this); // TODO: we stash user displaynames (among other things) in // RoomMember objects which are then attached to other events // (in the sender and target fields). We should get those // RoomMember objects to update themselves when the events that // they are based on are changed. } // FIXME: apply redactions to notification list // NB: We continue to add the redaction event to the timeline so // clients can say "so and so redacted an event" if they wish to. Also // this may be needed to trigger an update. } if (event.getUnsigned().transaction_id) { const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; if (existingEvent) { // remote echo of an event we sent earlier this.handleRemoteEcho(event, existingEvent); return; } } // add to our timeline sets for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); } // synthesize and inject implicit read receipts // Done after adding the event because otherwise the app would get a read receipt // pointing to an event that wasn't yet in the timeline // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. if (event.sender && event.getType() !== _event2.EventType.RoomRedaction) { this.addReceipt(synthesizeReceipt(event.sender.userId, event, "m.read"), true); // Any live events from a user could be taken as implicit // presence information: evidence that they are currently active. // ...except in a world where we use 'user.currentlyActive' to reduce // presence spam, this isn't very useful - we'll get a transition when // they are no longer currently active anyway. So don't bother to // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. } } /** * Add a pending outgoing event to this room. * * <p>The event is added to either the pendingEventList, or the live timeline, * depending on the setting of opts.pendingEventOrdering. * * <p>This is an internal method, intended for use by MatrixClient. * * @param {module:models/event.MatrixEvent} event The event to add. * * @param {string} txnId Transaction id for this outgoing event * * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" * * @throws if the event doesn't have status SENDING, or we aren't given a * unique transaction id. */ addPendingEvent(event, txnId) { if (event.status !== _event.EventStatus.SENDING && event.status !== _event.EventStatus.NOT_SENT) { throw new Error("addPendingEvent called on an event with status " + event.status); } if (this.txnToEvent[txnId]) { throw new Error("addPendingEvent called on an event with known txnId " + txnId); } // call setEventMetadata to set up event.sender etc // as event is shared over all timelineSets, we set up its metadata based // on the unfiltered timelineSet. _eventTimeline.EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(_eventTimeline.EventTimeline.FORWARDS), false); this.txnToEvent[txnId] = event; if (this.opts.pendingEventOrdering == "detached") { if (this.pendingEventList.some(e => e.status === _event.EventStatus.NOT_SENT)) { _logger.logger.warn("Setting event as NOT_SENT due to messages in the same state"); event.setStatus(_event.EventStatus.NOT_SENT); } this.pendingEventList.push(event); this.savePendingEvents(); if (event.isRelation()) { // For pending events, add them to the relations collection immediately. // (The alternate case below already covers this as part of adding to // the timeline set.) this.aggregateNonLiveRelation(event); } if (event.isRedaction()) { const redactId = event.event.redacts; let redactedEvent = this.pendingEventList && this.pendingEventList.find(e => e.getId() === redactId); if (!redactedEvent) { redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); } if (redactedEvent) { redactedEvent.markLocallyRedacted(event); this.emit("Room.redaction", event, this); } } } else { for (let