diff --git a/src/@types/partials.ts b/src/@types/partials.ts index 771c60b47..ecdc6525a 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -37,3 +37,5 @@ export enum Preset { TrustedPrivateChat = "trusted_private_chat", PublicChat = "public_chat", } + +export type ResizeMethod = "crop" | "scale"; diff --git a/src/client.ts b/src/client.ts index f34044081..1d54523ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -32,7 +32,6 @@ import { Group } from "./models/group"; import { EventTimeline } from "./models/event-timeline"; import { PushAction, PushProcessor } from "./pushprocessor"; import { AutoDiscovery } from "./autodiscovery"; -import { MatrixError } from "./http-api"; import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; import { ReEmitter } from './ReEmitter'; @@ -40,6 +39,7 @@ import { RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { + MatrixError, MatrixHttpApi, PREFIX_IDENTITY_V2, PREFIX_MEDIA_R0, @@ -64,7 +64,7 @@ import { import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; import { MatrixScheduler } from "./scheduler"; -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from "./matrix"; +import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo, NotificationCountType } from "./matrix"; import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store"; import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store"; @@ -94,7 +94,8 @@ import { IJoinRoomOpts, IPaginateOpts, IPresenceOpts, - IRedactOpts, IRoomDirectoryOptions, + IRedactOpts, + IRoomDirectoryOptions, ISearchOpts, ISendEventResponse, IUploadOpts, @@ -348,6 +349,26 @@ export interface IStoredClientOpts extends IStartClientOpts { canResetEntireTimeline: ResetTimelineCallback; } +export enum RoomVersionStability { + Stable = "stable", + Unstable = "unstable", +} + +export interface IRoomVersionsCapability { + default: string; + available: Record; +} + +export interface IChangePasswordCapability { + enabled: boolean; +} + +interface ICapabilities { + [key: string]: any; + "m.change_password"?: IChangePasswordCapability; + "m.room_versions"?: IRoomVersionsCapability; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -408,7 +429,7 @@ export class MatrixClient extends EventEmitter { protected serverVersionsPromise: Promise; protected cachedCapabilities: { - capabilities: Record; + capabilities: ICapabilities; expiration: number; }; protected clientWellKnown: any; @@ -529,7 +550,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (!room) return; - const currentCount = room.getUnreadNotificationCount("highlight"); + const currentCount = room.getUnreadNotificationCount(NotificationCountType.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 @@ -545,12 +566,12 @@ export class MatrixClient extends EventEmitter { let newCount = currentCount; if (newHighlight && !oldHighlight) newCount++; if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount("highlight", newCount); + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount('total'); + const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); if (totalCount < newCount) { - room.setUnreadNotificationCount('total', newCount); + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); } } } @@ -1058,7 +1079,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves to the capabilities of the homeserver * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getCapabilities(fresh = false): Promise> { + public getCapabilities(fresh = false): Promise { const now = new Date().getTime(); if (this.cachedCapabilities && !fresh) { @@ -1076,7 +1097,7 @@ export class MatrixClient extends EventEmitter { return null; // otherwise consume the error }).then((r) => { if (!r) r = {}; - const capabilities = r["capabilities"] || {}; + const capabilities: ICapabilities = r["capabilities"] || {}; // If the capabilities missed the cache, cache it for a shorter amount // of time to try and refresh them later. @@ -1085,7 +1106,7 @@ export class MatrixClient extends EventEmitter { : 60000 + (Math.random() * 5000); this.cachedCapabilities = { - capabilities: capabilities, + capabilities, expiration: now + cacheMs, }; @@ -3544,7 +3565,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (room) { - room._addLocalEchoReceipt(this.credentials.userId, event, receiptType); + room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); } return promise; } @@ -3614,7 +3635,7 @@ export class MatrixClient extends EventEmitter { throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`); } if (room) { - room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); + room.addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); } } @@ -4230,7 +4251,7 @@ export class MatrixClient extends EventEmitter { // reduce the required number of events appropriately limit = limit - numAdded; - const prom = new Promise((resolve, reject) => { + 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(() => { diff --git a/src/content-repo.ts b/src/content-repo.ts index baa91879b..287259651 100644 --- a/src/content-repo.ts +++ b/src/content-repo.ts @@ -39,7 +39,7 @@ export function getHttpUriForMxc( width: number, height: number, resizeMethod: string, - allowDirectLinks: boolean, + allowDirectLinks = false, ): string { if (typeof mxc !== "string" || !mxc) { return ''; diff --git a/src/models/event.ts b/src/models/event.ts index 246390629..04c1afd4b 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -70,11 +70,15 @@ interface IContent { "m.relates_to"?: IEventRelation; } +type StrippedState = Required>; + interface IUnsigned { age?: number; prev_sender?: string; prev_content?: IContent; redacted_because?: IEvent; + transaction_id?: string; + invite_room_state?: StrippedState[]; } export interface IEvent { diff --git a/src/models/room-state.js b/src/models/room-state.js deleted file mode 100644 index f9e76cfc3..000000000 --- a/src/models/room-state.js +++ /dev/null @@ -1,832 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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. -*/ - -/** - * @module models/room-state - */ - -import { EventEmitter } from "events"; -import { RoomMember } from "./room-member"; -import { logger } from '../logger'; -import * as utils from "../utils"; -import { EventType } from "../@types/event"; - -// possible statuses for out-of-band member loading -const OOB_STATUS_NOTSTARTED = 1; -const OOB_STATUS_INPROGRESS = 2; -const OOB_STATUS_FINISHED = 3; - -/** - * Construct room state. - * - * Room State represents the state of the room at a given point. - * It can be mutated by adding state events to it. - * There are two types of room member associated with a state event: - * normal member objects (accessed via getMember/getMembers) which mutate - * with the state to represent the current state of that room/user, eg. - * the object returned by getMember('@bob:example.com') will mutate to - * get a different display name if Bob later changes his display name - * in the room. - * There are also 'sentinel' members (accessed via getSentinelMember). - * These also represent the state of room members at the point in time - * represented by the RoomState object, but unlike objects from getMember, - * sentinel objects will always represent the room state as at the time - * getSentinelMember was called, so if Bob subsequently changes his display - * name, a room member object previously acquired with getSentinelMember - * will still have his old display name. Calling getSentinelMember again - * after the display name change will return a new RoomMember object - * with Bob's new display name. - * - * @constructor - * @param {?string} roomId Optional. The ID of the room which has this state. - * If none is specified it just tracks paginationTokens, useful for notifTimelineSet - * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. - * As the timeline might get reset while they are loading, this state needs to be inherited - * and shared when the room state is cloned for the new timeline. - * This should only be passed from clone. - * @prop {Object.} members The room member dictionary, keyed - * on the user's ID. - * @prop {Object.>} events The state - * events dictionary, keyed on the event type and then the state_key value. - * @prop {string} paginationToken The pagination token for this state. - */ -export function RoomState(roomId, oobMemberFlags = undefined) { - this.roomId = roomId; - this.members = { - // userId: RoomMember - }; - this.events = new Map(); // Map> - this.paginationToken = null; - - this._sentinels = { - // userId: RoomMember - }; - this._updateModifiedTime(); - - // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) - this._displayNameToUserIds = {}; - this._userIdsToDisplayNames = {}; - this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite - this._joinedMemberCount = null; // cache of the number of joined members - // joined members count from summary api - // once set, we know the server supports the summary api - // and we should only trust that - // we could also only trust that before OOB members - // are loaded but doesn't seem worth the hassle atm - this._summaryJoinedMemberCount = null; - // same for invited member count - this._invitedMemberCount = null; - this._summaryInvitedMemberCount = null; - - if (!oobMemberFlags) { - oobMemberFlags = { - status: OOB_STATUS_NOTSTARTED, - }; - } - this._oobMemberFlags = oobMemberFlags; -} -utils.inherits(RoomState, EventEmitter); - -/** - * Returns the number of joined members in this room - * This method caches the result. - * @return {integer} The number of members in this room whose membership is 'join' - */ -RoomState.prototype.getJoinedMemberCount = function() { - if (this._summaryJoinedMemberCount !== null) { - return this._summaryJoinedMemberCount; - } - if (this._joinedMemberCount === null) { - this._joinedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'join' ? count + 1 : count; - }, 0); - } - return this._joinedMemberCount; -}; - -/** - * Set the joined member count explicitly (like from summary part of the sync response) - * @param {number} count the amount of joined members - */ -RoomState.prototype.setJoinedMemberCount = function(count) { - this._summaryJoinedMemberCount = count; -}; -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -RoomState.prototype.getInvitedMemberCount = function() { - if (this._summaryInvitedMemberCount !== null) { - return this._summaryInvitedMemberCount; - } - if (this._invitedMemberCount === null) { - this._invitedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'invite' ? count + 1 : count; - }, 0); - } - return this._invitedMemberCount; -}; - -/** - * Set the amount of invited members in this room - * @param {number} count the amount of invited members - */ -RoomState.prototype.setInvitedMemberCount = function(count) { - this._summaryInvitedMemberCount = count; -}; - -/** - * Get all RoomMembers in this room. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembers = function() { - return Object.values(this.members); -}; - -/** - * Get all RoomMembers in this room, excluding the user IDs provided. - * @param {Array} excludedIds The user IDs to exclude. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembersExcept = function(excludedIds) { - return Object.values(this.members) - .filter((m) => !excludedIds.includes(m.userId)); -}; - -/** - * Get a room member by their user ID. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getMember = function(userId) { - return this.members[userId] || null; -}; - -/** - * Get a room member whose properties will not change with this room state. You - * typically want this if you want to attach a RoomMember to a MatrixEvent which - * may no longer be represented correctly by Room.currentState or Room.oldState. - * The term 'sentinel' refers to the fact that this RoomMember is an unchanging - * guardian for state at this particular point in time. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getSentinelMember = function(userId) { - if (!userId) return null; - let sentinel = this._sentinels[userId]; - - if (sentinel === undefined) { - sentinel = new RoomMember(this.roomId, userId); - const member = this.members[userId]; - if (member) { - sentinel.setMembershipEvent(member.events.member, this); - } - this._sentinels[userId] = sentinel; - } - return sentinel; -}; - -/** - * Get state events from the state of the room. - * @param {string} eventType The event type of the state event. - * @param {string} stateKey Optional. The state_key of the state event. If - * this is undefined then all matching state events will be - * returned. - * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was - * undefined, else a single event (or null if no match found). - */ -RoomState.prototype.getStateEvents = function(eventType, stateKey) { - if (!this.events.has(eventType)) { - // no match - return stateKey === undefined ? [] : null; - } - if (stateKey === undefined) { // return all values - return Array.from(this.events.get(eventType).values()); - } - const event = this.events.get(eventType).get(stateKey); - return event ? event : null; -}; - -/** - * Creates a copy of this room state so that mutations to either won't affect the other. - * @return {RoomState} the copy of the room state - */ -RoomState.prototype.clone = function() { - const copy = new RoomState(this.roomId, this._oobMemberFlags); - - // Ugly hack: because setStateEvents will mark - // members as susperseding future out of bound members - // if loading is in progress (through _oobMemberFlags) - // since these are not new members, we're merely copying them - // set the status to not started - // after copying, we set back the status - const status = this._oobMemberFlags.status; - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; - - Array.from(this.events.values()).forEach((eventsByStateKey) => { - copy.setStateEvents(Array.from(eventsByStateKey.values())); - }); - - // Ugly hack: see above - this._oobMemberFlags.status = status; - - if (this._summaryInvitedMemberCount !== null) { - copy.setInvitedMemberCount(this.getInvitedMemberCount()); - } - if (this._summaryJoinedMemberCount !== null) { - copy.setJoinedMemberCount(this.getJoinedMemberCount()); - } - - // copy out of band flags if needed - if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) { - // copy markOutOfBand flags - this.getMembers().forEach((member) => { - if (member.isOutOfBand()) { - const copyMember = copy.getMember(member.userId); - copyMember.markOutOfBand(); - } - }); - } - - return copy; -}; - -/** - * Add previously unknown state events. - * When lazy loading members while back-paginating, - * the relevant room state for the timeline chunk at the end - * of the chunk can be set with this method. - * @param {MatrixEvent[]} events state events to prepend - */ -RoomState.prototype.setUnknownStateEvents = function(events) { - const unknownStateEvents = events.filter((event) => { - return !this.events.has(event.getType()) || - !this.events.get(event.getType()).has(event.getStateKey()); - }); - - this.setStateEvents(unknownStateEvents); -}; - -/** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. - * @param {MatrixEvent[]} stateEvents a list of state events for this room. - * @fires module:client~MatrixClient#event:"RoomState.members" - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @fires module:client~MatrixClient#event:"RoomState.events" - */ -RoomState.prototype.setStateEvents = function(stateEvents) { - const self = this; - this._updateModifiedTime(); - - // update the core event dict - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - const lastStateEvent = self._getStateEventMatching(event); - self._setStateEvent(event); - if (event.getType() === "m.room.member") { - _updateDisplayNameCache( - self, event.getStateKey(), event.getContent().displayname, - ); - _updateThirdPartyTokenCache(self, event); - } - self.emit("RoomState.events", event, self, lastStateEvent); - }); - - // update higher level data structures. This needs to be done AFTER the - // core event dict as these structures may depend on other state events in - // the given array (e.g. disambiguating display names in one go to do both - // clashing names rather than progressively which only catches 1 of them). - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - if (event.getType() === "m.room.member") { - const userId = event.getStateKey(); - - // leave events apparently elide the displayname or avatar_url, - // so let's fake one up so that we don't leak user ids - // into the timeline - if (event.getContent().membership === "leave" || - event.getContent().membership === "ban") { - event.getContent().avatar_url = - event.getContent().avatar_url || - event.getPrevContent().avatar_url; - event.getContent().displayname = - event.getContent().displayname || - event.getPrevContent().displayname; - } - - const member = self._getOrCreateMember(userId, event); - member.setMembershipEvent(event, self); - - self._updateMember(member); - self.emit("RoomState.members", event, self, member); - } else if (event.getType() === "m.room.power_levels") { - // events with unknown state keys should be ignored - // and should not aggregate onto members power levels - if (event.getStateKey() !== "") { - return; - } - const members = Object.values(self.members); - members.forEach(function(member) { - // We only propagate `RoomState.members` event if the - // power levels has been changed - // large room suffer from large re-rendering especially when not needed - const oldLastModified = member.getLastModifiedTime(); - member.setPowerLevelEvent(event); - if (oldLastModified !== member.getLastModifiedTime()) { - self.emit("RoomState.members", event, self, member); - } - }); - - // assume all our sentinels are now out-of-date - self._sentinels = {}; - } - }); -}; - -/** - * Looks up a member by the given userId, and if it doesn't exist, - * create it and emit the `RoomState.newMember` event. - * This method makes sure the member is added to the members dictionary - * before emitting, as this is done from setStateEvents and _setOutOfBandMember. - * @param {string} userId the id of the user to look up - * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @returns {RoomMember} the member, existing or newly created. - */ -RoomState.prototype._getOrCreateMember = function(userId, event) { - let member = this.members[userId]; - if (!member) { - member = new RoomMember(this.roomId, userId); - // add member to members before emitting any events, - // as event handlers often lookup the member - this.members[userId] = member; - this.emit("RoomState.newMember", event, this, member); - } - return member; -}; - -RoomState.prototype._setStateEvent = function(event) { - if (!this.events.has(event.getType())) { - this.events.set(event.getType(), new Map()); - } - this.events.get(event.getType()).set(event.getStateKey(), event); -}; - -RoomState.prototype._getStateEventMatching = function(event) { - if (!this.events.has(event.getType())) return null; - return this.events.get(event.getType()).get(event.getStateKey()); -}; - -RoomState.prototype._updateMember = function(member) { - // this member may have a power level already, so set it. - const pwrLvlEvent = this.getStateEvents("m.room.power_levels", ""); - if (pwrLvlEvent) { - member.setPowerLevelEvent(pwrLvlEvent); - } - - // blow away the sentinel which is now outdated - delete this._sentinels[member.userId]; - - this.members[member.userId] = member; - this._joinedMemberCount = null; - this._invitedMemberCount = null; -}; - -/** - * Get the out-of-band members loading state, whether loading is needed or not. - * Note that loading might be in progress and hence isn't needed. - * @return {bool} whether or not the members of this room need to be loaded - */ -RoomState.prototype.needsOutOfBandMembers = function() { - return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED; -}; - -/** - * Mark this room state as waiting for out-of-band members, - * ensuring it doesn't ask for them to be requested again - * through needsOutOfBandMembers - */ -RoomState.prototype.markOutOfBandMembersStarted = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_INPROGRESS; -}; - -/** - * Mark this room state as having failed to fetch out-of-band members - */ -RoomState.prototype.markOutOfBandMembersFailed = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Clears the loaded out-of-band members - */ -RoomState.prototype.clearOutOfBandMembers = function() { - let count = 0; - Object.keys(this.members).forEach((userId) => { - const member = this.members[userId]; - if (member.isOutOfBand()) { - ++count; - delete this.members[userId]; - } - }); - logger.log(`LL: RoomState removed ${count} members...`); - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Sets the loaded out-of-band members. - * @param {MatrixEvent[]} stateEvents array of membership state events - */ -RoomState.prototype.setOutOfBandMembers = function(stateEvents) { - logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - logger.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`); - this._oobMemberFlags.status = OOB_STATUS_FINISHED; - stateEvents.forEach((e) => this._setOutOfBandMember(e)); -}; - -/** - * Sets a single out of band member, used by both setOutOfBandMembers and clone - * @param {MatrixEvent} stateEvent membership state event - */ -RoomState.prototype._setOutOfBandMember = function(stateEvent) { - if (stateEvent.getType() !== 'm.room.member') { - return; - } - const userId = stateEvent.getStateKey(); - const existingMember = this.getMember(userId); - // never replace members received as part of the sync - if (existingMember && !existingMember.isOutOfBand()) { - return; - } - - const member = this._getOrCreateMember(userId, stateEvent); - member.setMembershipEvent(stateEvent, this); - // needed to know which members need to be stored seperately - // as they are not part of the sync accumulator - // this is cleared by setMembershipEvent so when it's updated through /sync - member.markOutOfBand(); - - _updateDisplayNameCache(this, member.userId, member.name); - - this._setStateEvent(stateEvent); - this._updateMember(member); - this.emit("RoomState.members", stateEvent, this, member); -}; - -/** - * Set the current typing event for this room. - * @param {MatrixEvent} event The typing event - */ -RoomState.prototype.setTypingEvent = function(event) { - Object.values(this.members).forEach(function(member) { - member.setTypingEvent(event); - }); -}; - -/** - * Get the m.room.member event which has the given third party invite token. - * - * @param {string} token The token - * @return {?MatrixEvent} The m.room.member event or null - */ -RoomState.prototype.getInviteForThreePidToken = function(token) { - return this._tokenToInvite[token] || null; -}; - -/** - * Update the last modified time to the current time. - */ -RoomState.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this room state was last updated. This timestamp is - * updated when this object has received new state events. - * @return {number} The timestamp - */ -RoomState.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get user IDs with the specified or similar display names. - * @param {string} displayName The display name to get user IDs from. - * @return {string[]} An array of user IDs or an empty array. - */ -RoomState.prototype.getUserIdsWithDisplayName = function(displayName) { - return this._displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; -}; - -/** - * Returns true if userId is in room, event is not redacted and either sender of - * mxEvent or has power level sufficient to redact events other than their own. - * @param {MatrixEvent} mxEvent The event to test permission for - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given used ID can redact given event - */ -RoomState.prototype.maySendRedactionForEvent = function(mxEvent, userId) { - const member = this.getMember(userId); - if (!member || member.membership === 'leave') return false; - - if (mxEvent.status || mxEvent.isRedacted()) return false; - - // The user may have been the sender, but they can't redact their own message - // if redactions are blocked. - const canRedact = this.maySendEvent("m.room.redaction", userId); - if (mxEvent.getSender() === userId) return canRedact; - - return this._hasSufficientPowerLevelFor('redact', member.powerLevel); -}; - -/** - * Returns true if the given power level is sufficient for action - * @param {string} action The type of power level to check - * @param {number} powerLevel The power level of the member - * @return {boolean} true if the given power level is sufficient - */ -RoomState.prototype._hasSufficientPowerLevelFor = function(action, powerLevel) { - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let powerLevels = {}; - if (powerLevelsEvent) { - powerLevels = powerLevelsEvent.getContent(); - } - - let requiredLevel = 50; - if (utils.isNumber(powerLevels[action])) { - requiredLevel = powerLevels[action]; - } - - return powerLevel >= requiredLevel; -}; - -/** - * Short-form for maySendEvent('m.room.message', userId) - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * message events into the given room. - */ -RoomState.prototype.maySendMessage = function(userId) { - return this._maySendEventOfType('m.room.message', userId, false); -}; - -/** - * Returns true if the given user ID has permission to send a normal - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendEvent = function(eventType, userId) { - return this._maySendEventOfType(eventType, userId, false); -}; - -/** - * Returns true if the given MatrixClient has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {MatrixClient} cli The client to test permission for - * @return {boolean} true if the given client should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) { - if (cli.isGuest()) { - return false; - } - return this.maySendStateEvent(stateEventType, cli.credentials.userId); -}; - -/** - * Returns true if the given user ID has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendStateEvent = function(stateEventType, userId) { - return this._maySendEventOfType(stateEventType, userId, true); -}; - -/** - * Returns true if the given user ID has permission to send a normal or state - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @param {boolean} state If true, tests if the user may send a state - event of this type. Otherwise tests whether - they may send a regular event. - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { - const power_levels_event = this.getStateEvents('m.room.power_levels', ''); - - let power_levels; - let events_levels = {}; - - let state_default = 0; - let events_default = 0; - let powerLevel = 0; - if (power_levels_event) { - power_levels = power_levels_event.getContent(); - events_levels = power_levels.events || {}; - - if (Number.isFinite(power_levels.state_default)) { - state_default = power_levels.state_default; - } else { - state_default = 50; - } - - const userPowerLevel = power_levels.users && power_levels.users[userId]; - if (Number.isFinite(userPowerLevel)) { - powerLevel = userPowerLevel; - } else if (Number.isFinite(power_levels.users_default)) { - powerLevel = power_levels.users_default; - } - - if (Number.isFinite(power_levels.events_default)) { - events_default = power_levels.events_default; - } - } - - let required_level = state ? state_default : events_default; - if (Number.isFinite(events_levels[eventType])) { - required_level = events_levels[eventType]; - } - return powerLevel >= required_level; -}; - -/** - * Returns true if the given user ID has permission to trigger notification - * of type `notifLevelKey` - * @param {string} notifLevelKey The level of notification to test (eg. 'room') - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID has permission to trigger a - * notification of this type. - */ -RoomState.prototype.mayTriggerNotifOfType = function(notifLevelKey, userId) { - const member = this.getMember(userId); - if (!member) { - return false; - } - - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let notifLevel = 50; - if ( - powerLevelsEvent && - powerLevelsEvent.getContent() && - powerLevelsEvent.getContent().notifications && - utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) - ) { - notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; - } - - return member.powerLevel >= notifLevel; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -RoomState.prototype.getJoinRule = function() { - const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); - const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; - return joinRuleContent["join_rule"] || "invite"; -}; - -function _updateThirdPartyTokenCache(roomState, memberEvent) { - if (!memberEvent.getContent().third_party_invite) { - return; - } - const token = (memberEvent.getContent().third_party_invite.signed || {}).token; - if (!token) { - return; - } - const threePidInvite = roomState.getStateEvents( - "m.room.third_party_invite", token, - ); - if (!threePidInvite) { - return; - } - roomState._tokenToInvite[token] = memberEvent; -} - -function _updateDisplayNameCache(roomState, userId, displayName) { - const oldName = roomState._userIdsToDisplayNames[userId]; - delete roomState._userIdsToDisplayNames[userId]; - if (oldName) { - // Remove the old name from the cache. - // We clobber the user_id > name lookup but the name -> [user_id] lookup - // means we need to remove that user ID from that array rather than nuking - // the lot. - const strippedOldName = utils.removeHiddenChars(oldName); - - const existingUserIds = roomState._displayNameToUserIds[strippedOldName]; - if (existingUserIds) { - // remove this user ID from this array - const filteredUserIDs = existingUserIds.filter((id) => id !== userId); - roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs; - } - } - - roomState._userIdsToDisplayNames[userId] = displayName; - - const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); - // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js - if (strippedDisplayname) { - if (!roomState._displayNameToUserIds[strippedDisplayname]) { - roomState._displayNameToUserIds[strippedDisplayname] = []; - } - roomState._displayNameToUserIds[strippedDisplayname].push(userId); - } -} - -/** - * Fires whenever the event dictionary in room state is updated. - * @event module:client~MatrixClient#"RoomState.events" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.events dictionary - * was updated. - * @param {MatrixEvent} prevEvent The event being replaced by the new state, if - * known. Note that this can differ from `getPrevContent()` on the new state event - * as this is the store's view of the last state, not the previous state provided - * by the server. - * @example - * matrixClient.on("RoomState.events", function(event, state, prevEvent){ - * var newStateEvent = event; - * }); - */ - -/** - * Fires whenever a member in the members dictionary is updated in any way. - * @event module:client~MatrixClient#"RoomState.members" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated. - * @param {RoomMember} member The room member that was updated. - * @example - * matrixClient.on("RoomState.members", function(event, state, member){ - * var newMembershipState = member.membership; - * }); - */ - - /** - * Fires whenever a member is added to the members dictionary. The RoomMember - * will not be fully populated yet (e.g. no membership state) but will already - * be available in the members dictionary. - * @event module:client~MatrixClient#"RoomState.newMember" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated with a new entry. - * @param {RoomMember} member The room member that was added. - * @example - * matrixClient.on("RoomState.newMember", function(event, state, member){ - * // add event listeners on 'member' - * }); - */ diff --git a/src/models/room-state.ts b/src/models/room-state.ts new file mode 100644 index 000000000..6b914a1d4 --- /dev/null +++ b/src/models/room-state.ts @@ -0,0 +1,825 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/room-state + */ + +import { EventEmitter } from "events"; + +import { RoomMember } from "./room-member"; +import { logger } from '../logger'; +import * as utils from "../utils"; +import { EventType } from "../@types/event"; +import { MatrixEvent } from "./event"; +import { MatrixClient } from "../client"; + +// possible statuses for out-of-band member loading +enum OobStatus { + NotStarted, + InProgress, + Finished, +} + +export class RoomState extends EventEmitter { + private sentinels: Record = {}; // userId: RoomMember + // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) + private displayNameToUserIds: Record = {}; + private userIdsToDisplayNames: Record = {}; + private tokenToInvite: Record = {}; // 3pid invite state_key to m.room.member invite + private joinedMemberCount: number = null; // cache of the number of joined members + // joined members count from summary api + // once set, we know the server supports the summary api + // and we should only trust that + // we could also only trust that before OOB members + // are loaded but doesn't seem worth the hassle atm + private summaryJoinedMemberCount: number = null; + // same for invited member count + private invitedMemberCount: number = null; + private summaryInvitedMemberCount: number = null; + private modified: number; + + // XXX: Should be read-only + public members: Record = {}; // userId: RoomMember + public events = new Map>(); // Map> + public paginationToken: string = null; + + /** + * Construct room state. + * + * Room State represents the state of the room at a given point. + * It can be mutated by adding state events to it. + * There are two types of room member associated with a state event: + * normal member objects (accessed via getMember/getMembers) which mutate + * with the state to represent the current state of that room/user, eg. + * the object returned by getMember('@bob:example.com') will mutate to + * get a different display name if Bob later changes his display name + * in the room. + * There are also 'sentinel' members (accessed via getSentinelMember). + * These also represent the state of room members at the point in time + * represented by the RoomState object, but unlike objects from getMember, + * sentinel objects will always represent the room state as at the time + * getSentinelMember was called, so if Bob subsequently changes his display + * name, a room member object previously acquired with getSentinelMember + * will still have his old display name. Calling getSentinelMember again + * after the display name change will return a new RoomMember object + * with Bob's new display name. + * + * @constructor + * @param {?string} roomId Optional. The ID of the room which has this state. + * If none is specified it just tracks paginationTokens, useful for notifTimelineSet + * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. + * As the timeline might get reset while they are loading, this state needs to be inherited + * and shared when the room state is cloned for the new timeline. + * This should only be passed from clone. + * @prop {Object.} members The room member dictionary, keyed + * on the user's ID. + * @prop {Object.>} events The state + * events dictionary, keyed on the event type and then the state_key value. + * @prop {string} paginationToken The pagination token for this state. + */ + constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) { + super(); + this.updateModifiedTime(); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * @return {number} The number of members in this room whose membership is 'join' + */ + public getJoinedMemberCount(): number { + if (this.summaryJoinedMemberCount !== null) { + return this.summaryJoinedMemberCount; + } + if (this.joinedMemberCount === null) { + this.joinedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'join' ? count + 1 : count; + }, 0); + } + return this.joinedMemberCount; + } + + /** + * Set the joined member count explicitly (like from summary part of the sync response) + * @param {number} count the amount of joined members + */ + public setJoinedMemberCount(count: number): void { + this.summaryJoinedMemberCount = count; + } + + /** + * Returns the number of invited members in this room + * @return {number} The number of members in this room whose membership is 'invite' + */ + public getInvitedMemberCount(): number { + if (this.summaryInvitedMemberCount !== null) { + return this.summaryInvitedMemberCount; + } + if (this.invitedMemberCount === null) { + this.invitedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'invite' ? count + 1 : count; + }, 0); + } + return this.invitedMemberCount; + } + + /** + * Set the amount of invited members in this room + * @param {number} count the amount of invited members + */ + public setInvitedMemberCount(count: number): void { + this.summaryInvitedMemberCount = count; + } + + /** + * Get all RoomMembers in this room. + * @return {Array} A list of RoomMembers. + */ + public getMembers(): RoomMember[] { + return Object.values(this.members); + } + + /** + * Get all RoomMembers in this room, excluding the user IDs provided. + * @param {Array} excludedIds The user IDs to exclude. + * @return {Array} A list of RoomMembers. + */ + public getMembersExcept(excludedIds: string[]): RoomMember[] { + return this.getMembers().filter((m) => !excludedIds.includes(m.userId)); + } + + /** + * Get a room member by their user ID. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getMember(userId: string): RoomMember | null { + return this.members[userId] || null; + } + + /** + * Get a room member whose properties will not change with this room state. You + * typically want this if you want to attach a RoomMember to a MatrixEvent which + * may no longer be represented correctly by Room.currentState or Room.oldState. + * The term 'sentinel' refers to the fact that this RoomMember is an unchanging + * guardian for state at this particular point in time. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getSentinelMember(userId: string): RoomMember | null { + if (!userId) return null; + let sentinel = this.sentinels[userId]; + + if (sentinel === undefined) { + sentinel = new RoomMember(this.roomId, userId); + const member = this.members[userId]; + if (member) { + sentinel.setMembershipEvent(member.events.member, this); + } + this.sentinels[userId] = sentinel; + } + return sentinel; + } + + /** + * Get state events from the state of the room. + * @param {string} eventType The event type of the state event. + * @param {string} stateKey Optional. The state_key of the state event. If + * this is undefined then all matching state events will be + * returned. + * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was + * undefined, else a single event (or null if no match found). + */ + public getStateEvents(eventType: string): MatrixEvent[]; + public getStateEvents(eventType: string, stateKey: string): MatrixEvent; + public getStateEvents(eventType: string, stateKey?: string) { + if (!this.events.has(eventType)) { + // no match + return stateKey === undefined ? [] : null; + } + if (stateKey === undefined) { // return all values + return Array.from(this.events.get(eventType).values()); + } + const event = this.events.get(eventType).get(stateKey); + return event ? event : null; + } + + /** + * Creates a copy of this room state so that mutations to either won't affect the other. + * @return {RoomState} the copy of the room state + */ + public clone(): RoomState { + const copy = new RoomState(this.roomId, this.oobMemberFlags); + + // Ugly hack: because setStateEvents will mark + // members as susperseding future out of bound members + // if loading is in progress (through oobMemberFlags) + // since these are not new members, we're merely copying them + // set the status to not started + // after copying, we set back the status + const status = this.oobMemberFlags.status; + this.oobMemberFlags.status = OobStatus.NotStarted; + + Array.from(this.events.values()).forEach((eventsByStateKey) => { + copy.setStateEvents(Array.from(eventsByStateKey.values())); + }); + + // Ugly hack: see above + this.oobMemberFlags.status = status; + + if (this.summaryInvitedMemberCount !== null) { + copy.setInvitedMemberCount(this.getInvitedMemberCount()); + } + if (this.summaryJoinedMemberCount !== null) { + copy.setJoinedMemberCount(this.getJoinedMemberCount()); + } + + // copy out of band flags if needed + if (this.oobMemberFlags.status == OobStatus.Finished) { + // copy markOutOfBand flags + this.getMembers().forEach((member) => { + if (member.isOutOfBand()) { + const copyMember = copy.getMember(member.userId); + copyMember.markOutOfBand(); + } + }); + } + + return copy; + } + + /** + * Add previously unknown state events. + * When lazy loading members while back-paginating, + * the relevant room state for the timeline chunk at the end + * of the chunk can be set with this method. + * @param {MatrixEvent[]} events state events to prepend + */ + public setUnknownStateEvents(events: MatrixEvent[]): void { + const unknownStateEvents = events.filter((event) => { + return !this.events.has(event.getType()) || + !this.events.get(event.getType()).has(event.getStateKey()); + }); + + this.setStateEvents(unknownStateEvents); + } + + /** + * Add an array of one or more state MatrixEvents, overwriting + * any existing state with the same {type, stateKey} tuple. Will fire + * "RoomState.events" for every event added. May fire "RoomState.members" + * if there are m.room.member events. + * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @fires module:client~MatrixClient#event:"RoomState.members" + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @fires module:client~MatrixClient#event:"RoomState.events" + */ + public setStateEvents(stateEvents: MatrixEvent[]) { + this.updateModifiedTime(); + + // update the core event dict + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + const lastStateEvent = this.getStateEventMatching(event); + this.setStateEvent(event); + if (event.getType() === EventType.RoomMember) { + this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname); + this.updateThirdPartyTokenCache(event); + } + this.emit("RoomState.events", event, this, lastStateEvent); + }); + + // update higher level data structures. This needs to be done AFTER the + // core event dict as these structures may depend on other state events in + // the given array (e.g. disambiguating display names in one go to do both + // clashing names rather than progressively which only catches 1 of them). + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + if (event.getType() === EventType.RoomMember) { + const userId = event.getStateKey(); + + // leave events apparently elide the displayname or avatar_url, + // so let's fake one up so that we don't leak user ids + // into the timeline + if (event.getContent().membership === "leave" || + event.getContent().membership === "ban") { + event.getContent().avatar_url = + event.getContent().avatar_url || + event.getPrevContent().avatar_url; + event.getContent().displayname = + event.getContent().displayname || + event.getPrevContent().displayname; + } + + const member = this.getOrCreateMember(userId, event); + member.setMembershipEvent(event, this); + + this.updateMember(member); + this.emit("RoomState.members", event, this, member); + } else if (event.getType() === EventType.RoomPowerLevels) { + // events with unknown state keys should be ignored + // and should not aggregate onto members power levels + if (event.getStateKey() !== "") { + return; + } + const members = Object.values(this.members); + members.forEach((member) => { + // We only propagate `RoomState.members` event if the + // power levels has been changed + // large room suffer from large re-rendering especially when not needed + const oldLastModified = member.getLastModifiedTime(); + member.setPowerLevelEvent(event); + if (oldLastModified !== member.getLastModifiedTime()) { + this.emit("RoomState.members", event, this, member); + } + }); + + // assume all our sentinels are now out-of-date + this.sentinels = {}; + } + }); + } + + /** + * Looks up a member by the given userId, and if it doesn't exist, + * create it and emit the `RoomState.newMember` event. + * This method makes sure the member is added to the members dictionary + * before emitting, as this is done from setStateEvents and setOutOfBandMember. + * @param {string} userId the id of the user to look up + * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @returns {RoomMember} the member, existing or newly created. + */ + private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember { + let member = this.members[userId]; + if (!member) { + member = new RoomMember(this.roomId, userId); + // add member to members before emitting any events, + // as event handlers often lookup the member + this.members[userId] = member; + this.emit("RoomState.newMember", event, this, member); + } + return member; + } + + private setStateEvent(event: MatrixEvent): void { + if (!this.events.has(event.getType())) { + this.events.set(event.getType(), new Map()); + } + this.events.get(event.getType()).set(event.getStateKey(), event); + } + + private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { + if (!this.events.has(event.getType())) return null; + return this.events.get(event.getType()).get(event.getStateKey()); + } + + private updateMember(member: RoomMember): void { + // this member may have a power level already, so set it. + const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + if (pwrLvlEvent) { + member.setPowerLevelEvent(pwrLvlEvent); + } + + // blow away the sentinel which is now outdated + delete this.sentinels[member.userId]; + + this.members[member.userId] = member; + this.joinedMemberCount = null; + this.invitedMemberCount = null; + } + + /** + * Get the out-of-band members loading state, whether loading is needed or not. + * Note that loading might be in progress and hence isn't needed. + * @return {boolean} whether or not the members of this room need to be loaded + */ + public needsOutOfBandMembers(): boolean { + return this.oobMemberFlags.status === OobStatus.NotStarted; + } + + /** + * Mark this room state as waiting for out-of-band members, + * ensuring it doesn't ask for them to be requested again + * through needsOutOfBandMembers + */ + public markOutOfBandMembersStarted(): void { + if (this.oobMemberFlags.status !== OobStatus.NotStarted) { + return; + } + this.oobMemberFlags.status = OobStatus.InProgress; + } + + /** + * Mark this room state as having failed to fetch out-of-band members + */ + public markOutOfBandMembersFailed(): void { + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Clears the loaded out-of-band members + */ + public clearOutOfBandMembers(): void { + let count = 0; + Object.keys(this.members).forEach((userId) => { + const member = this.members[userId]; + if (member.isOutOfBand()) { + ++count; + delete this.members[userId]; + } + }); + logger.log(`LL: RoomState removed ${count} members...`); + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Sets the loaded out-of-band members. + * @param {MatrixEvent[]} stateEvents array of membership state events + */ + public setOutOfBandMembers(stateEvents: MatrixEvent[]): void { + logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + logger.log(`LL: RoomState put in finished state ...`); + this.oobMemberFlags.status = OobStatus.Finished; + stateEvents.forEach((e) => this.setOutOfBandMember(e)); + } + + /** + * Sets a single out of band member, used by both setOutOfBandMembers and clone + * @param {MatrixEvent} stateEvent membership state event + */ + private setOutOfBandMember(stateEvent: MatrixEvent): void { + if (stateEvent.getType() !== EventType.RoomMember) { + return; + } + const userId = stateEvent.getStateKey(); + const existingMember = this.getMember(userId); + // never replace members received as part of the sync + if (existingMember && !existingMember.isOutOfBand()) { + return; + } + + const member = this.getOrCreateMember(userId, stateEvent); + member.setMembershipEvent(stateEvent, this); + // needed to know which members need to be stored seperately + // as they are not part of the sync accumulator + // this is cleared by setMembershipEvent so when it's updated through /sync + member.markOutOfBand(); + + this.updateDisplayNameCache(member.userId, member.name); + + this.setStateEvent(stateEvent); + this.updateMember(member); + this.emit("RoomState.members", stateEvent, this, member); + } + + /** + * Set the current typing event for this room. + * @param {MatrixEvent} event The typing event + */ + public setTypingEvent(event: MatrixEvent): void { + Object.values(this.members).forEach(function(member) { + member.setTypingEvent(event); + }); + } + + /** + * Get the m.room.member event which has the given third party invite token. + * + * @param {string} token The token + * @return {?MatrixEvent} The m.room.member event or null + */ + public getInviteForThreePidToken(token: string): MatrixEvent | null { + return this.tokenToInvite[token] || null; + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this room state was last updated. This timestamp is + * updated when this object has received new state events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get user IDs with the specified or similar display names. + * @param {string} displayName The display name to get user IDs from. + * @return {string[]} An array of user IDs or an empty array. + */ + public getUserIdsWithDisplayName(displayName: string): string[] { + return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; + } + + /** + * Returns true if userId is in room, event is not redacted and either sender of + * mxEvent or has power level sufficient to redact events other than their own. + * @param {MatrixEvent} mxEvent The event to test permission for + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given used ID can redact given event + */ + public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean { + const member = this.getMember(userId); + if (!member || member.membership === 'leave') return false; + + if (mxEvent.status || mxEvent.isRedacted()) return false; + + // The user may have been the sender, but they can't redact their own message + // if redactions are blocked. + const canRedact = this.maySendEvent(EventType.RoomRedaction, userId); + if (mxEvent.getSender() === userId) return canRedact; + + return this.hasSufficientPowerLevelFor('redact', member.powerLevel); + } + + /** + * Returns true if the given power level is sufficient for action + * @param {string} action The type of power level to check + * @param {number} powerLevel The power level of the member + * @return {boolean} true if the given power level is sufficient + */ + private hasSufficientPowerLevelFor(action: string, powerLevel: number): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + + let powerLevels = {}; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + } + + let requiredLevel = 50; + if (utils.isNumber(powerLevels[action])) { + requiredLevel = powerLevels[action]; + } + + return powerLevel >= requiredLevel; + } + + /** + * Short-form for maySendEvent('m.room.message', userId) + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * message events into the given room. + */ + public maySendMessage(userId: string): boolean { + return this.maySendEventOfType(EventType.RoomMessage, userId, false); + } + + /** + * Returns true if the given user ID has permission to send a normal + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + public maySendEvent(eventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(eventType, userId, false); + } + + /** + * Returns true if the given MatrixClient has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {MatrixClient} cli The client to test permission for + * @return {boolean} true if the given client should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { + if (cli.isGuest()) { + return false; + } + return this.maySendStateEvent(stateEventType, cli.credentials.userId); + } + + /** + * Returns true if the given user ID has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(stateEventType, userId, true); + } + + /** + * Returns true if the given user ID has permission to send a normal or state + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @param {boolean} state If true, tests if the user may send a state + event of this type. Otherwise tests whether + they may send a regular event. + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let powerLevels; + let eventsLevels = {}; + + let stateDefault = 0; + let eventsDefault = 0; + let powerLevel = 0; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + eventsLevels = powerLevels.events || {}; + + if (Number.isFinite(powerLevels.state_default)) { + stateDefault = powerLevels.state_default; + } else { + stateDefault = 50; + } + + const userPowerLevel = powerLevels.users && powerLevels.users[userId]; + if (Number.isFinite(userPowerLevel)) { + powerLevel = userPowerLevel; + } else if (Number.isFinite(powerLevels.users_default)) { + powerLevel = powerLevels.users_default; + } + + if (Number.isFinite(powerLevels.events_default)) { + eventsDefault = powerLevels.events_default; + } + } + + let requiredLevel = state ? stateDefault : eventsDefault; + if (Number.isFinite(eventsLevels[eventType])) { + requiredLevel = eventsLevels[eventType]; + } + return powerLevel >= requiredLevel; + } + + /** + * Returns true if the given user ID has permission to trigger notification + * of type `notifLevelKey` + * @param {string} notifLevelKey The level of notification to test (eg. 'room') + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID has permission to trigger a + * notification of this type. + */ + public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean { + const member = this.getMember(userId); + if (!member) { + return false; + } + + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let notifLevel = 50; + if ( + powerLevelsEvent && + powerLevelsEvent.getContent() && + powerLevelsEvent.getContent().notifications && + utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) + ) { + notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; + } + + return member.powerLevel >= notifLevel; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); + const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; + return joinRuleContent["join_rule"] || "invite"; + } + + private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void { + if (!memberEvent.getContent().third_party_invite) { + return; + } + const token = (memberEvent.getContent().third_party_invite.signed || {}).token; + if (!token) { + return; + } + const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token); + if (!threePidInvite) { + return; + } + this.tokenToInvite[token] = memberEvent; + } + + private updateDisplayNameCache(userId: string, displayName: string): void { + const oldName = this.userIdsToDisplayNames[userId]; + delete this.userIdsToDisplayNames[userId]; + if (oldName) { + // Remove the old name from the cache. + // We clobber the user_id > name lookup but the name -> [user_id] lookup + // means we need to remove that user ID from that array rather than nuking + // the lot. + const strippedOldName = utils.removeHiddenChars(oldName); + + const existingUserIds = this.displayNameToUserIds[strippedOldName]; + if (existingUserIds) { + // remove this user ID from this array + const filteredUserIDs = existingUserIds.filter((id) => id !== userId); + this.displayNameToUserIds[strippedOldName] = filteredUserIDs; + } + } + + this.userIdsToDisplayNames[userId] = displayName; + + const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); + // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js + if (strippedDisplayname) { + if (!this.displayNameToUserIds[strippedDisplayname]) { + this.displayNameToUserIds[strippedDisplayname] = []; + } + this.displayNameToUserIds[strippedDisplayname].push(userId); + } + } +} + +/** + * Fires whenever the event dictionary in room state is updated. + * @event module:client~MatrixClient#"RoomState.events" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.events dictionary + * was updated. + * @param {MatrixEvent} prevEvent The event being replaced by the new state, if + * known. Note that this can differ from `getPrevContent()` on the new state event + * as this is the store's view of the last state, not the previous state provided + * by the server. + * @example + * matrixClient.on("RoomState.events", function(event, state, prevEvent){ + * var newStateEvent = event; + * }); + */ + +/** + * Fires whenever a member in the members dictionary is updated in any way. + * @event module:client~MatrixClient#"RoomState.members" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated. + * @param {RoomMember} member The room member that was updated. + * @example + * matrixClient.on("RoomState.members", function(event, state, member){ + * var newMembershipState = member.membership; + * }); + */ + +/** + * Fires whenever a member is added to the members dictionary. The RoomMember + * will not be fully populated yet (e.g. no membership state) but will already + * be available in the members dictionary. + * @event module:client~MatrixClient#"RoomState.newMember" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated with a new entry. + * @param {RoomMember} member The room member that was added. + * @example + * matrixClient.on("RoomState.newMember", function(event, state, member){ + * // add event listeners on 'member' + * }); + */ diff --git a/src/models/room-summary.ts b/src/models/room-summary.ts index f8327f798..d01b470ae 100644 --- a/src/models/room-summary.ts +++ b/src/models/room-summary.ts @@ -18,12 +18,18 @@ limitations under the License. * @module models/room-summary */ +export interface IRoomSummary { + "m.heroes": string[]; + "m.joined_member_count": number; + "m.invited_member_count": number; +} + interface IInfo { title: string; - desc: string; - numMembers: number; - aliases: string[]; - timestamp: number; + desc?: string; + numMembers?: number; + aliases?: string[]; + timestamp?: number; } /** diff --git a/src/models/room.js b/src/models/room.js deleted file mode 100644 index 2e4229c7f..000000000 --- a/src/models/room.js +++ /dev/null @@ -1,2254 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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. -*/ - -/** - * @module models/room - */ - -import { EventEmitter } from "events"; -import { EventTimelineSet } from "./event-timeline-set"; -import { EventTimeline } from "./event-timeline"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; -import { EventStatus, MatrixEvent } from "./event"; -import { RoomMember } from "./room-member"; -import { RoomSummary } from "./room-summary"; -import { logger } from '../logger'; -import { ReEmitter } from '../ReEmitter'; -import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; -import { normalize } from "../utils"; - -// 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 MatrixEvent(fakeReceipt); -} - -/** - * Construct a new Room. - * - *

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. - * - *

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. - * - *

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 "chronological", messages will appear - * in the timeline when the call to sendEvent was made. If - * "detached", pending messages will appear in a separate list, - * accessbile 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 unhomoglyphed name for this room. - * @prop {Array} 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. - */ -export function Room(roomId, client, myUserId, opts) { - opts = opts || {}; - opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; - - this._client = client; - - // 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.setMaxListeners(100); - - this.reEmitter = new ReEmitter(this); - - if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { - throw new Error( - "opts.pendingEventOrdering MUST be either 'chronological' or " + - "'detached'. Got: '" + opts.pendingEventOrdering + "'", - ); - } - - this.myUserId = myUserId; - this.roomId = roomId; - this.name = roomId; - this.tags = { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - }; - this.accountData = { - // $eventType: $event - }; - this.summary = null; - this.storageToken = opts.storageToken; - this._opts = opts; - this._txnToEvent = {}; // 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. - this._receipts = { - // receipt_type: { - // user_id: { - // eventId: , - // data: - // } - // } - }; - this._receiptCacheByEventId = { - // $event_id: [{ - // type: $type, - // userId: $user_id, - // data: - // }] - }; - // only receipts that came from the server, not synthesized ones - this._realReceipts = {}; - - this._notificationCounts = {}; - - // 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(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); - - this._fixUpLegacyTimelineFields(); - - // any filtered timeline sets we're maintaining for this room - this._filteredTimelineSets = { - // filter_id: timelineSet - }; - - 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 MatrixEvent(serializedEvent); - if (event.getType() === "m.room.encrypted") { - await event.attemptDecryption(this._client.crypto); - } - event.setStatus(EventStatus.NOT_SENT); - this.addPendingEvent(event, event.getTxnId()); - }); - } - } - - // read by megolm; boolean value - null indicates "use global value" - this._blacklistUnverifiedDevices = null; - this._selfMembership = null; - this._summaryHeroes = null; - // awaited by getEncryptionTargetMembers while room members are loading - - if (!this._opts.lazyLoadMembers) { - this._membersPromise = Promise.resolve(); - } else { - this._membersPromise = null; - } - - // flags to stop logspam about missing m.room.create events - this.getTypeWarning = false; - this.getVersionWarning = false; -} - -/** - * @param {string} roomId ID of the current room - * @returns {string} Storage key to retrieve pending events - */ -function pendingEventsKey(roomId) { - return `mx_pending_events_${roomId}`; -} - -utils.inherits(Room, EventEmitter); - -/** - * 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 - */ -Room.prototype.decryptCriticalEvents = function() { - 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 - */ -Room.prototype.decryptAllEvents = function() { - 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 - */ -Room.prototype.getVersion = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getVersionWarning) { - 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 - */ -Room.prototype.shouldUpgradeToVersion = function() { - // 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: version as the new version the - * room should be upgraded to (may be the same as the current version); - * needsUpgrade to indicate if the room actually can be - * upgraded (ie: does the current version not match?); and urgent - * to indicate if the new version patches a vulnerability in a previous - * version. - * @returns {Promise<{version: string, needsUpgrade: bool, urgent: bool}>} - * Resolves to the version the room should be upgraded to. - */ -Room.prototype.getRecommendedVersion = async function() { - 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] = "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.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.warn("No room version capability - assuming upgrade required."); - return result; - } else { - result = this._checkVersionAgainstCapability(versionCap); - } - } - - return result; -}; - -Room.prototype._checkVersionAgainstCapability = function(versionCap) { - const currentVersion = this.getVersion(); - logger.log(`[${this.roomId}] Current version: ${currentVersion}`); - 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.warn(`URGENT upgrade required on ${this.roomId}`); - } else { - 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 {bool} True if the given user is permitted to upgrade the room - */ -Room.prototype.userMayUpgradeRoom = function(userId) { - return this.currentState.maySendStateEvent("m.room.tombstone", 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 opts.pendingEventOrdering was not 'detached' - */ -Room.prototype.getPendingEvents = function() { - 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. - */ -Room.prototype.removePendingEvent = function(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} - */ -Room.prototype.hasPendingEvent = function(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} - */ -Room.prototype.getPendingEvent = function(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 - */ -Room.prototype.getLiveTimeline = function() { - 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 - */ -Room.prototype.getLastActiveTimestamp = function() { - 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; - } -}; - -/** - * @param {string} myUserId the user id for the logged in member - * @return {string} the membership type (join | leave | invite) for the logged in user - */ -Room.prototype.getMyMembership = function() { - 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 - */ -Room.prototype.getDMInviter = function() { - 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) - */ -Room.prototype.guessDMUserId = function() { - 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; -}; - -Room.prototype.getAvatarFallbackMember = function() { - 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( - 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 - */ -Room.prototype.updateMyMembership = function(membership) { - const prevMembership = this._selfMembership; - this._selfMembership = membership; - if (prevMembership !== membership) { - if (membership === "leave") { - this._cleanupAfterLeaving(); - } - this.emit("Room.myMembership", this, membership, prevMembership); - } -}; - -Room.prototype._loadMembersFromServer = async function() { - 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; -}; - -Room.prototype._loadMembers = async function() { - // 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.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 - */ -Room.prototype.loadMembersIfNeeded = function() { - 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.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.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.error(err); - }); - - this._membersPromise = inMemoryUpdate; - - return this._membersPromise; -}; - -/** - * Removes the lazily loaded members from storage if needed - */ -Room.prototype.clearLoadedMembersIfNeeded = async function() { - 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. - */ -Room.prototype._cleanupAfterLeaving = function() { - this.clearLoadedMembersIfNeeded().catch((err) => { - logger.error(`error after clearing loaded members from ` + - `room ${this.roomId} after leaving`); - logger.log(err); - }); -}; - -/** - * Reset the live timeline of all timelineSets, and start new ones. - * - *

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. - */ -Room.prototype.resetLiveTimeline = function(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 - */ -Room.prototype._fixUpLegacyTimelineFields = function() { - // 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.BACKWARDS); - this.currentState = this.getLiveTimeline() - .getState(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 {bool} the result - */ -Room.prototype.hasUnverifiedDevices = async function() { - 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 - */ -Room.prototype.getTimelineSets = function() { - return this._timelineSets; -}; - -/** - * Helper to return the main unfiltered timeline set for this room - * @return {EventTimelineSet} room's unfiltered timeline set - */ -Room.prototype.getUnfilteredTimelineSet = function() { - 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 - */ -Room.prototype.getTimelineForEvent = function(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 - */ -Room.prototype.addTimeline = function() { - 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 - */ -Room.prototype.findEventById = function(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. - */ -Room.prototype.getUnreadNotificationCount = function(type) { - type = type || '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 - */ -Room.prototype.setUnreadNotificationCount = function(type, count) { - this._notificationCounts[type] = count; -}; - -Room.prototype.setSummary = function(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. - */ -Room.prototype.setBlacklistUnverifiedDevices = function(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. - */ -Room.prototype.getBlacklistUnverifiedDevices = function() { - 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. - */ -Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod, - allowDefault) { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - if (allowDefault === undefined) { - allowDefault = true; - } - if (!roomAvatarEvent && !allowDefault) { - return null; - } - - const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; - if (mainUrl) { - return 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 - */ -Room.prototype.getMxcAvatarUrl = function() { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - return roomAvatarEvent ? roomAvatarEvent.getContent().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 - */ -Room.prototype.getAliases = function() { - const aliasStrings = []; - - const aliasEvents = this.currentState.getStateEvents("m.room.aliases"); - 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 - */ -Room.prototype.getCanonicalAlias = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - 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 - */ -Room.prototype.getAltAliases = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - if (canonicalAlias) { - return canonicalAlias.getContent().alt_aliases || []; - } - return []; -}; - -/** - * Add events to a timeline - * - *

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 last 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" - * - */ -Room.prototype.addEventsToTimeline = function(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 null. - */ - Room.prototype.getMember = function(userId) { - return this.currentState.getMember(userId); - }; - -/** - * Get all currently loaded members from the current - * room state. - * @returns {RoomMember[]} Room members - */ -Room.prototype.getMembers = function() { - return this.currentState.getMembers(); -}; - -/** - * Get a list of members whose membership state is "join". - * @return {RoomMember[]} A list of currently joined members. - */ - Room.prototype.getJoinedMembers = function() { - 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 {integer} The number of members in this room whose membership is 'join' - */ -Room.prototype.getJoinedMemberCount = function() { - return this.currentState.getJoinedMemberCount(); -}; - -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -Room.prototype.getInvitedMemberCount = function() { - return this.currentState.getInvitedMemberCount(); -}; - -/** - * Returns the number of invited + joined members in this room - * @return {integer} The number of members in this room whose membership is 'invite' or 'join' - */ -Room.prototype.getInvitedAndJoinedMemberCount = function() { - 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. - */ - Room.prototype.getMembersWithMembership = function(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} A list of members who - * we should encrypt messages for in this room. - */ - Room.prototype.getEncryptionTargetMembers = async function() { - 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 - */ - Room.prototype.shouldEncryptForInvitedMembers = function() { - const ev = this.currentState.getStateEvents("m.room.history_visibility", ""); - return (ev && ev.getContent() && 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 - */ - Room.prototype.getDefaultRoomName = function(userId) { - return calculateRoomName(this, 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. 'join' - * @return {boolean} True if this user_id has the given membership state. - */ - Room.prototype.hasMembershipState = function(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 - */ -Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { - if (this._filteredTimelineSets[filter.filterId]) { - return this._filteredTimelineSets[filter.filterId]; - } - const opts = Object.assign({ filter: filter }, this._opts); - const timelineSet = new 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.BACKWARDS)) { - timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - timelineSet.getLiveTimeline().setPaginationToken( - timeline.getPaginationToken(EventTimeline.BACKWARDS), - 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 - */ -Room.prototype.removeFilteredTimelineSet = function(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 - */ -Room.prototype._addLiveEvent = function(event, duplicateStrategy, fromCache) { - 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() !== "m.room.redaction") { - 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. - * - *

The event is added to either the pendingEventList, or the live timeline, - * depending on the setting of opts.pendingEventOrdering. - * - *

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. - */ -Room.prototype.addPendingEvent = function(event, txnId) { - if (event.status !== EventStatus.SENDING && event.status !== 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.setEventMetadata( - event, - this.getLiveTimeline().getState(EventTimeline.FORWARDS), - false, - ); - - this._txnToEvent[txnId] = event; - - if (this._opts.pendingEventOrdering == "detached") { - if (this._pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { - logger.warn("Setting event as NOT_SENT due to messages in the same state"); - event.setStatus(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 i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } else { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } - } - - this.emit("Room.localEchoUpdated", event, this, null, null); -}; - -/** - * Persists all pending events to local storage - * - * If the current room is encrypted only encrypted events will be persisted - * all messages that are not yet encrypted will be discarded - * - * This is because the flow of EVENT_STATUS transition is - * queued => sending => encrypting => sending => sent - * - * Steps 3 and 4 are skipped for unencrypted room. - * It is better to discard an unencrypted message rather than persisting - * it locally for everyone to read - */ -Room.prototype._savePendingEvents = function() { - if (this._pendingEventList) { - const pendingEvents = this._pendingEventList.map(event => { - return { - ...event.event, - txn_id: event.getTxnId(), - }; - }).filter(event => { - // Filter out the unencrypted messages if the room is encrypted - const isEventEncrypted = event.type === "m.room.encrypted"; - const isRoomEncrypted = this._client.isRoomEncrypted(this.roomId); - return isEventEncrypted || !isRoomEncrypted; - }); - - const { store } = this._client.sessionStore; - if (this._pendingEventList.length > 0) { - store.setItem( - pendingEventsKey(this.roomId), - JSON.stringify(pendingEvents), - ); - } else { - store.removeItem(pendingEventsKey(this.roomId)); - } - } -}; - -/** - * Used to aggregate the local echo for a relation, and also - * for re-applying a relation after it's redaction has been cancelled, - * as the local echo for the redaction of the relation would have - * un-aggregated the relation. Note that this is different from regular messages, - * which are just kept detached for their local echo. - * - * Also note that live events are aggregated in the live EventTimelineSet. - * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. - */ -Room.prototype._aggregateNonLiveRelation = function(event) { - // TODO: We should consider whether this means it would be a better - // design to lift the relations handling up to the room instead. - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.aggregateRelations(event); - } - } else { - timelineSet.aggregateRelations(event); - } - } -}; - -/** - * Deal with the echo of a message we sent. - * - *

We move the event to the live timeline if it isn't there already, and - * update it. - * - * @param {module:models/event.MatrixEvent} remoteEvent The event received from - * /sync - * @param {module:models/event.MatrixEvent} localEvent The local echo, which - * should be either in the _pendingEventList or the timeline. - * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * @private - */ -Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { - const oldEventId = localEvent.getId(); - const newEventId = remoteEvent.getId(); - const oldStatus = localEvent.status; - - logger.debug( - `Got remote echo for event ${oldEventId} -> ${newEventId} ` + - `old status ${oldStatus}`, - ); - - // no longer pending - delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; - - // if it's in the pending list, remove it - if (this._pendingEventList) { - this.removePendingEvent(oldEventId); - } - - // replace the event source (this will preserve the plaintext payload if - // any, which is good, because we don't want to try decoding it again). - localEvent.handleRemoteEcho(remoteEvent.event); - - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - - // if it's already in the timeline, update the timeline map. If it's not, add it. - timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - } - - this.emit("Room.localEchoUpdated", localEvent, this, - oldEventId, oldStatus); -}; - -/* a map from current event status to a list of allowed next statuses - */ -const ALLOWED_TRANSITIONS = {}; - -ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ - EventStatus.SENDING, - EventStatus.NOT_SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ - EventStatus.ENCRYPTING, - EventStatus.QUEUED, - EventStatus.NOT_SENT, - EventStatus.SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.QUEUED] = - [EventStatus.SENDING, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.SENT] = - []; - -ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = - [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = - []; - -/** - * Update the status / event id on a pending event, to reflect its transmission - * progress. - * - *

This is an internal method. - * - * @param {MatrixEvent} event local echo event - * @param {EventStatus} newStatus status to assign - * @param {string} newEventId new event id to assign. Ignored unless - * newStatus == EventStatus.SENT. - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - */ -Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { - logger.log( - `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + - `event ID ${event.getId()} -> ${newEventId}`, - ); - - // if the message was sent, we expect an event id - if (newStatus == EventStatus.SENT && !newEventId) { - throw new Error("updatePendingEvent called with status=SENT, " + - "but no new event id"); - } - - // SENT races against /sync, so we have to special-case it. - if (newStatus == EventStatus.SENT) { - const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); - if (timeline) { - // we've already received the event via the event stream. - // nothing more to do here. - return; - } - } - - const oldStatus = event.status; - const oldEventId = event.getId(); - - if (!oldStatus) { - throw new Error("updatePendingEventStatus called on an event which is " + - "not a local echo."); - } - - const allowed = ALLOWED_TRANSITIONS[oldStatus]; - if (!allowed || allowed.indexOf(newStatus) < 0) { - throw new Error("Invalid EventStatus transition " + oldStatus + "->" + - newStatus); - } - - event.setStatus(newStatus); - - if (newStatus == EventStatus.SENT) { - // update the event id - event.replaceLocalEventId(newEventId); - - // if the event was already in the timeline (which will be the case if - // opts.pendingEventOrdering==chronological), we need to update the - // timeline map. - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].replaceEventId(oldEventId, newEventId); - } - } else if (newStatus == EventStatus.CANCELLED) { - // remove it from the pending event list, or the timeline. - if (this._pendingEventList) { - const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); - if (idx !== -1) { - const [removedEvent] = this._pendingEventList.splice(idx, 1); - if (removedEvent.isRedaction()) { - this._revertRedactionLocalEcho(removedEvent); - } - } - } - this.removeEvent(oldEventId); - } - this._savePendingEvents(); - - this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); -}; - -Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { - const redactId = redactionEvent.event.redacts; - if (!redactId) { - return; - } - const redactedEvent = this.getUnfilteredTimelineSet() - .findEventById(redactId); - if (redactedEvent) { - redactedEvent.unmarkLocallyRedacted(); - // re-render after undoing redaction - this.emit("Room.redactionCancelled", redactionEvent, this); - // reapply relation now redaction failed - if (redactedEvent.isRelation()) { - this._aggregateNonLiveRelation(redactedEvent); - } - } -}; - -/** - * Add some events to this room. This can include state events, message - * events and typing notifications. These events are treated as "live" so - * they will go to the end of the timeline. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {string} duplicateStrategy Optional. Applies to events in the - * timeline only. If this is 'replace' then if a duplicate is encountered, the - * event passed to this function will replace the existing event in the - * timeline. If this is not specified, or is 'ignore', then the event passed to - * this function will be ignored entirely, preserving the existing event in the - * timeline. Events are identical based on their event ID only. - * - * @param {boolean} fromCache whether the sync response came from cache - * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. - */ -Room.prototype.addLiveEvents = function(events, duplicateStrategy, fromCache) { - let i; - if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { - throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); - } - - // sanity check that the live timeline is still live - for (i = 0; i < this._timelineSets.length; i++) { - const liveTimeline = this._timelineSets[i].getLiveTimeline(); - if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - it has a pagination token " + - "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", - ); - } - if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - " + - "it has a neighbouring timeline", - ); - } - } - - for (i = 0; i < events.length; i++) { - // TODO: We should have a filter to say "only add state event - // types X Y Z to the timeline". - this._addLiveEvent(events[i], duplicateStrategy, fromCache); - } -}; - -/** - * Adds/handles ephemeral events such as typing notifications and read receipts. - * @param {MatrixEvent[]} events A list of events to process - */ -Room.prototype.addEphemeralEvents = function(events) { - for (const event of events) { - if (event.getType() === 'm.typing') { - this.currentState.setTypingEvent(event); - } else if (event.getType() === 'm.receipt') { - this.addReceipt(event); - } // else ignore - life is too short for us to care about these events - } -}; - -/** - * Removes events from this room. - * @param {String[]} eventIds A list of eventIds to remove. - */ -Room.prototype.removeEvents = function(eventIds) { - for (let i = 0; i < eventIds.length; ++i) { - this.removeEvent(eventIds[i]); - } -}; - -/** - * Removes a single event from this room. - * - * @param {String} eventId The id of the event to remove - * - * @return {bool} true if the event was removed from any of the room's timeline sets - */ -Room.prototype.removeEvent = function(eventId) { - let removedAny = false; - for (let i = 0; i < this._timelineSets.length; i++) { - const removed = this._timelineSets[i].removeEvent(eventId); - if (removed) { - if (removed.isRedaction()) { - this._revertRedactionLocalEcho(removed); - } - removedAny = true; - } - } - return removedAny; -}; - -/** - * Recalculate various aspects of the room, including the room name and - * room summary. Call this any time the room's current state is modified. - * May fire "Room.name" if the room name is updated. - * @fires module:client~MatrixClient#event:"Room.name" - */ -Room.prototype.recalculate = function() { - // set fake stripped state events if this is an invite room so logic remains - // consistent elsewhere. - const self = this; - const membershipEvent = this.currentState.getStateEvents( - "m.room.member", this.myUserId, - ); - if (membershipEvent && membershipEvent.getContent().membership === "invite") { - const strippedStateEvents = membershipEvent.event.invite_room_state || []; - strippedStateEvents.forEach(function(strippedEvent) { - const existingEvent = self.currentState.getStateEvents( - strippedEvent.type, strippedEvent.state_key, - ); - if (!existingEvent) { - // set the fake stripped event instead - self.currentState.setStateEvents([new MatrixEvent({ - type: strippedEvent.type, - state_key: strippedEvent.state_key, - content: strippedEvent.content, - event_id: "$fake" + Date.now(), - room_id: self.roomId, - user_id: self.myUserId, // technically a lie - })]); - } - }); - } - - const oldName = this.name; - this.name = calculateRoomName(this, this.myUserId); - this.normalizedName = normalize(this.name); - this.summary = new RoomSummary(this.roomId, { - title: this.name, - }); - - if (oldName !== this.name) { - this.emit("Room.name", this); - } -}; - -/** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ -Room.prototype.getUsersReadUpTo = function(event) { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return receipt.type === "m.read"; - }).map(function(receipt) { - return receipt.userId; - }); -}; - -/** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ -Room.prototype.getEventReadUpTo = function(userId, ignoreSynthesized) { - let receipts = this._receipts; - if (ignoreSynthesized) { - receipts = this._realReceipts; - } - - if ( - receipts["m.read"] === undefined || - receipts["m.read"][userId] === undefined - ) { - return null; - } - - return receipts["m.read"][userId].eventId; -}; - -/** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ -Room.prototype.hasUserReadEvent = function(userId, eventId) { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; -}; - -/** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ -Room.prototype.getReceiptsForEvent = function(event) { - return this._receiptCacheByEventId[event.getId()] || []; -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} fake True if this event is implicit - */ -Room.prototype.addReceipt = function(event, fake) { - // event content looks like: - // content: { - // $event_id: { - // $receipt_type: { - // $user_id: { - // ts: $timestamp - // } - // } - // } - // } - if (fake === undefined) { - fake = false; - } - if (!fake) { - this._addReceiptsToStructure(event, this._realReceipts); - // we don't bother caching real receipts by event ID - // as there's nothing that would read it. - } - this._addReceiptsToStructure(event, this._receipts); - this._receiptCacheByEventId = this._buildReceiptCache(this._receipts); - - // send events after we've regenerated the cache, otherwise things that - // listened for the event would read from a stale cache - this.emit("Room.receipt", event, this); -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Object} receipts The object to add receipts to - */ -Room.prototype._addReceiptsToStructure = function(event, receipts) { - const self = this; - Object.keys(event.getContent()).forEach(function(eventId) { - Object.keys(event.getContent()[eventId]).forEach(function(receiptType) { - Object.keys(event.getContent()[eventId][receiptType]).forEach( - function(userId) { - const receipt = event.getContent()[eventId][receiptType][userId]; - - if (!receipts[receiptType]) { - receipts[receiptType] = {}; - } - - const existingReceipt = receipts[receiptType][userId]; - - if (!existingReceipt) { - receipts[receiptType][userId] = {}; - } else { - // we only want to add this receipt if we think it is later - // than the one we already have. (This is managed - // server-side, but because we synthesize RRs locally we - // have to do it here too.) - const ordering = self.getUnfilteredTimelineSet().compareEventOrdering( - existingReceipt.eventId, eventId); - if (ordering !== null && ordering >= 0) { - return; - } - } - - receipts[receiptType][userId] = { - eventId: eventId, - data: receipt, - }; - }); - }); - }); -}; - -/** - * Build and return a map of receipts by event ID - * @param {Object} receipts A map of receipts - * @return {Object} Map of receipts by event ID - */ -Room.prototype._buildReceiptCache = function(receipts) { - const receiptCacheByEventId = {}; - Object.keys(receipts).forEach(function(receiptType) { - Object.keys(receipts[receiptType]).forEach(function(userId) { - const receipt = receipts[receiptType][userId]; - if (!receiptCacheByEventId[receipt.eventId]) { - receiptCacheByEventId[receipt.eventId] = []; - } - receiptCacheByEventId[receipt.eventId].push({ - userId: userId, - type: receiptType, - data: receipt.data, - }); - }); - }); - return receiptCacheByEventId; -}; - -/** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param {string} userId The user ID if the receipt sender - * @param {MatrixEvent} e The event that is to be acknowledged - * @param {string} receiptType The type of receipt - */ -Room.prototype._addLocalEchoReceipt = function(userId, e, receiptType) { - this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); -}; - -/** - * Update the room-tag event for the room. The previous one is overwritten. - * @param {MatrixEvent} event the m.tag event - */ -Room.prototype.addTags = function(event) { - // event content looks like: - // content: { - // tags: { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - // } - // } - - // XXX: do we need to deep copy here? - this.tags = event.getContent().tags || {}; - - // XXX: we could do a deep-comparison to see if the tags have really - // changed - but do we want to bother? - this.emit("Room.tags", event, this); -}; - -/** - * Update the account_data events for this room, overwriting events of the same type. - * @param {Array} events an array of account_data events to add - */ -Room.prototype.addAccountData = function(events) { - for (let i = 0; i < events.length; i++) { - const event = events[i]; - if (event.getType() === "m.tag") { - this.addTags(event); - } - const lastEvent = this.accountData[event.getType()]; - this.accountData[event.getType()] = event; - this.emit("Room.accountData", event, this, lastEvent); - } -}; - -/** - * Access account_data event of given event type for this room - * @param {string} type the type of account_data event to be accessed - * @return {?MatrixEvent} the account_data event in question - */ -Room.prototype.getAccountData = function(type) { - return this.accountData[type]; -}; - -/** - * Returns whether the syncing user has permission to send a message in the room - * @return {boolean} true if the user should be permitted to send - * message events into the room. - */ -Room.prototype.maySendMessage = function() { - return this.getMyMembership() === 'join' && - this.currentState.maySendEvent('m.room.message', this.myUserId); -}; - -/** - * Returns whether the given user has permissions to issue an invite for this room. - * @param {string} userId the ID of the Matrix user to check permissions for - * @returns {boolean} true if the user should be permitted to issue invites for this room. - */ -Room.prototype.canInvite = function(userId) { - let canInvite = this.getMyMembership() === "join"; - const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); - const me = this.getMember(userId); - if (powerLevels && me && powerLevels.invite > me.powerLevel) { - canInvite = false; - } - return canInvite; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -Room.prototype.getJoinRule = function() { - return this.currentState.getJoinRule(); -}; - -/** - * Returns the type of the room from the `m.room.create` event content or undefined if none is set - * @returns {?string} the type of the room. Currently only RoomType.Space is known. - */ -Room.prototype.getType = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getTypeWarning) { - logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); - this.getTypeWarning = true; - } - return undefined; - } - return createEvent.getContent()[RoomCreateTypeField]; -}; - -/** - * Returns whether the room is a space-room as defined by MSC1772. - * @returns {boolean} true if the room's type is RoomType.Space - */ -Room.prototype.isSpaceRoom = function() { - return this.getType() === RoomType.Space; -}; - -/** - * This is an internal method. Calculates the name of the room from the current - * room state. - * @param {Room} room The matrix room. - * @param {string} userId The client's user ID. Used to filter room members - * correctly. - * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there - * was no m.room.name event. - * @return {string} The calculated room name. - */ -function calculateRoomName(room, userId, ignoreRoomNameEvent) { - if (!ignoreRoomNameEvent) { - // check for an alias, if any. for now, assume first alias is the - // official one. - const mRoomName = room.currentState.getStateEvents("m.room.name", ""); - if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { - return mRoomName.getContent().name; - } - } - - let alias = room.getCanonicalAlias(); - - if (!alias) { - const aliases = room.getAltAliases(); - - if (aliases.length) { - alias = aliases[0]; - } - } - if (alias) { - return alias; - } - - const joinedMemberCount = room.currentState.getJoinedMemberCount(); - const invitedMemberCount = room.currentState.getInvitedMemberCount(); - // -1 because these numbers include the syncing user - const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; - - // get members that are NOT ourselves and are actually in the room. - let otherNames = null; - if (room._summaryHeroes) { - // if we have a summary, the member state events - // should be in the room state - otherNames = room._summaryHeroes.map((userId) => { - const member = room.getMember(userId); - return member ? member.name : userId; - }); - } else { - let otherMembers = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - (m.membership === "invite" || m.membership === "join"); - }); - // make sure members have stable order - otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); - // only 5 first members, immitate _summaryHeroes - otherMembers = otherMembers.slice(0, 5); - otherNames = otherMembers.map((m) => m.name); - } - - if (inviteJoinCount) { - return memberNamesToRoomName(otherNames, inviteJoinCount); - } - - const myMembership = room.getMyMembership(); - // if I have created a room and invited people throuh - // 3rd party invites - if (myMembership == 'join') { - const thirdPartyInvites = - room.currentState.getStateEvents("m.room.third_party_invite"); - - if (thirdPartyInvites && thirdPartyInvites.length) { - const thirdPartyNames = thirdPartyInvites.map((i) => { - return i.getContent().display_name; - }); - - return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; - } - } - // let's try to figure out who was here before - let leftNames = otherNames; - // if we didn't have heroes, try finding them in the room state - if (!leftNames.length) { - leftNames = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - m.membership !== "invite" && - m.membership !== "join"; - }).map((m) => m.name); - } - if (leftNames.length) { - return `Empty room (was ${memberNamesToRoomName(leftNames)})`; - } else { - return "Empty room"; - } -} - -function memberNamesToRoomName(names, count = (names.length + 1)) { - const countWithoutMe = count - 1; - if (!names.length) { - return "Empty room"; - } else if (names.length === 1 && countWithoutMe <= 1) { - return names[0]; - } else if (names.length === 2 && countWithoutMe <= 2) { - return `${names[0]} and ${names[1]}`; - } else { - const plural = countWithoutMe > 1; - if (plural) { - return `${names[0]} and ${countWithoutMe} others`; - } else { - return `${names[0]} and 1 other`; - } - } -} - -/** - * Fires when an event we had previously received is redacted. - * - * (Note this is *not* fired when the redaction happens before we receive the - * event). - * - * @event module:client~MatrixClient#"Room.redaction" - * @param {MatrixEvent} event The matrix redaction event - * @param {Room} room The room containing the redacted event - */ - -/** - * Fires when an event that was previously redacted isn't anymore. - * This happens when the redaction couldn't be sent and - * was subsequently cancelled by the user. Redactions have a local echo - * which is undone in this scenario. - * - * @event module:client~MatrixClient#"Room.redactionCancelled" - * @param {MatrixEvent} event The matrix redaction event that was cancelled. - * @param {Room} room The room containing the unredacted event - */ - -/** - * Fires whenever the name of a room is updated. - * @event module:client~MatrixClient#"Room.name" - * @param {Room} room The room whose Room.name was updated. - * @example - * matrixClient.on("Room.name", function(room){ - * var newName = room.name; - * }); - */ - -/** - * Fires whenever a receipt is received for a room - * @event module:client~MatrixClient#"Room.receipt" - * @param {event} event The receipt event - * @param {Room} room The room whose receipts was updated. - * @example - * matrixClient.on("Room.receipt", function(event, room){ - * var receiptContent = event.getContent(); - * }); - */ - -/** - * Fires whenever a room's tags are updated. - * @event module:client~MatrixClient#"Room.tags" - * @param {event} event The tags event - * @param {Room} room The room whose Room.tags was updated. - * @example - * matrixClient.on("Room.tags", function(event, room){ - * var newTags = event.getContent().tags; - * if (newTags["favourite"]) showStar(room); - * }); - */ - -/** - * Fires whenever a room's account_data is updated. - * @event module:client~MatrixClient#"Room.accountData" - * @param {event} event The account_data event - * @param {Room} room The room whose account_data was updated. - * @param {MatrixEvent} prevEvent The event being replaced by - * the new account data, if known. - * @example - * matrixClient.on("Room.accountData", function(event, room, oldEvent){ - * if (event.getType() === "m.room.colorscheme") { - * applyColorScheme(event.getContents()); - * } - * }); - */ - -/** - * Fires when the status of a transmitted event is updated. - * - *

When an event is first transmitted, a temporary copy of the event is - * inserted into the timeline, with a temporary event id, and a status of - * 'SENDING'. - * - *

Once the echo comes back from the server, the content of the event - * (MatrixEvent.event) is replaced by the complete event from the homeserver, - * thus updating its event id, as well as server-generated fields such as the - * timestamp. Its status is set to null. - * - *

Once the /send request completes, if the remote echo has not already - * arrived, the event is updated with a new event id and the status is set to - * 'SENT'. The server-generated fields are of course not updated yet. - * - *

If the /send fails, In this case, the event's status is set to - * 'NOT_SENT'. If it is later resent, the process starts again, setting the - * status to 'SENDING'. Alternatively, the message may be cancelled, which - * removes the event from the room, and sets the status to 'CANCELLED'. - * - *

This event is raised to reflect each of the transitions above. - * - * @event module:client~MatrixClient#"Room.localEchoUpdated" - * - * @param {MatrixEvent} event The matrix event which has been updated - * - * @param {Room} room The room containing the redacted event - * - * @param {string} oldEventId The previous event id (the temporary event id, - * except when updating a successfully-sent event when its echo arrives) - * - * @param {EventStatus} oldStatus The previous event status. - */ - -/** - * Fires when the logged in user's membership in the room is updated. - * - * @event module:models/room~Room#"Room.myMembership" - * @param {Room} room The room in which the membership has been updated - * @param {string} membership The new membership value - * @param {string} prevMembership The previous membership value - */ diff --git a/src/models/room.ts b/src/models/room.ts new file mode 100644 index 000000000..787b409d6 --- /dev/null +++ b/src/models/room.ts @@ -0,0 +1,2272 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/room + */ + +import { EventEmitter } from "events"; + +import { EventTimelineSet } from "./event-timeline-set"; +import { EventTimeline } from "./event-timeline"; +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { normalize } from "../utils"; +import { EventStatus, MatrixEvent } from "./event"; +import { RoomMember } from "./room-member"; +import { IRoomSummary, RoomSummary } from "./room-summary"; +import { logger } from '../logger'; +import { ReEmitter } from '../ReEmitter'; +import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; +import { IRoomVersionsCapability, MatrixClient, RoomVersionStability } from "../client"; +import { ResizeMethod } from "../@types/partials"; +import { Filter } from "../filter"; +import { RoomState } from "./room-state"; + +// 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: string, event: MatrixEvent, receiptType: string): MatrixEvent { + // 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 MatrixEvent(fakeReceipt); +} + +interface IOpts { + storageToken?: string; + pendingEventOrdering?: "chronological" | "detached"; + timelineSupport?: boolean; + unstableClientRelationAggregation?: boolean; + lazyLoadMembers?: boolean; +} + +export interface IRecommendedVersion { + version: string; + needsUpgrade: boolean; + urgent: boolean; +} + +interface IReceipt { + ts: number; +} + +interface IWrappedReceipt { + eventId: string; + data: IReceipt; +} + +interface ICachedReceipt { + type: string; + userId: string; + data: IReceipt; +} + +type ReceiptCache = Record; + +interface IReceiptContent { + [eventId: string]: { + [type: string]: { + [userId: string]: IReceipt; + }; + }; +} + +type Receipts = Record>; + +export enum NotificationCountType { + Highlight = "highlight", + Total = "total", +} + +export class Room extends EventEmitter { + private readonly reEmitter: ReEmitter; + private txnToEvent: Record = {}; // 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. + private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } + private receiptCacheByEventId: ReceiptCache = {}; // { event_id: IReceipt2[] } + // only receipts that came from the server, not synthesized ones + private realReceipts: Receipts = {}; + private notificationCounts: Partial> = {}; + private readonly timelineSets: EventTimelineSet[]; + // any filtered timeline sets we're maintaining for this room + private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet + private readonly pendingEventList?: MatrixEvent[]; + // read by megolm via getter; boolean value - null indicates "use global value" + private blacklistUnverifiedDevices: boolean = null; + private selfMembership: string = null; + private summaryHeroes: string[] = null; + // flags to stop logspam about missing m.room.create events + private getTypeWarning = false; + private getVersionWarning = false; + private membersPromise?: Promise; + + // XXX: These should be read-only + public name: string; + public normalizedName: string; + public tags: Record> = {}; // $tagName: { $metadata: $value } + public accountData: Record = {}; // $eventType: $event + public summary: RoomSummary = null; + public readonly storageToken?: string; + // legacy fields + public timeline: MatrixEvent[]; + public oldState: RoomState; + public currentState: RoomState; + + /** + * Construct a new Room. + * + *

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. + * + *

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. + * + *

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 "chronological", messages will appear + * in the timeline when the call to sendEvent was made. If + * "detached", 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} 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( + public readonly roomId: string, + private readonly client: MatrixClient, + public readonly myUserId: string, + private readonly opts: IOpts = {}, + ) { + 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.setMaxListeners(100); + this.reEmitter = new ReEmitter(this); + + opts.pendingEventOrdering = opts.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; + this.normalizedName = normalize(this.name); + + // 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(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 MatrixEvent(serializedEvent); + if (event.getType() === EventType.RoomMessageEncrypted) { + await event.attemptDecryption(this.client.crypto); + } + event.setStatus(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 + */ + public decryptCriticalEvents(): Promise { + 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 + */ + public decryptAllEvents(): Promise { + 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 + */ + public getVersion(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getVersionWarning) { + 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 + */ + public shouldUpgradeToVersion(): string | null { + // 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: version as the new version the + * room should be upgraded to (may be the same as the current version); + * needsUpgrade to indicate if the room actually can be + * upgraded (ie: does the current version not match?); and urgent + * 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. + */ + public async getRecommendedVersion(): Promise { + 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] = 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.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.warn("No room version capability - assuming upgrade required."); + return result; + } else { + result = this.checkVersionAgainstCapability(versionCap); + } + } + + return result; + } + + private checkVersionAgainstCapability(versionCap: IRoomVersionsCapability): IRecommendedVersion { + const currentVersion = this.getVersion(); + logger.log(`[${this.roomId}] Current version: ${currentVersion}`); + 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.warn(`URGENT upgrade required on ${this.roomId}`); + } else { + 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 + */ + public userMayUpgradeRoom(userId: string): boolean { + return this.currentState.maySendStateEvent(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 opts.pendingEventOrdering was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + 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. + */ + public removePendingEvent(eventId: string): boolean { + 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} + */ + public hasPendingEvent(eventId: string): boolean { + 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} + */ + public getPendingEvent(eventId: string): MatrixEvent | null { + 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 + */ + public getLiveTimeline(): EventTimeline { + 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 + */ + public getLastActiveTimestamp(): number { + 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 + */ + public getMyMembership(): string { + 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 + */ + public getDMInviter(): string { + 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) + */ + public guessDMUserId(): string { + 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; + } + + public getAvatarFallbackMember(): RoomMember { + 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( + 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 + */ + public updateMyMembership(membership: string): void { + const prevMembership = this.selfMembership; + this.selfMembership = membership; + if (prevMembership !== membership) { + if (membership === "leave") { + this.cleanupAfterLeaving(); + } + this.emit("Room.myMembership", this, membership, prevMembership); + } + } + + private async loadMembersFromServer(): Promise { + 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; + } + + private async loadMembers(): Promise<{ memberEvents: MatrixEvent[], fromServer: boolean }> { + // 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.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 + */ + public loadMembersIfNeeded(): Promise { + 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.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.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.error(err); + }); + + this.membersPromise = inMemoryUpdate; + + return this.membersPromise; + } + + /** + * Removes the lazily loaded members from storage if needed + */ + public async clearLoadedMembersIfNeeded(): Promise { + 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. + */ + private cleanupAfterLeaving(): void { + this.clearLoadedMembersIfNeeded().catch((err) => { + logger.error(`error after clearing loaded members from ` + + `room ${this.roomId} after leaving`); + logger.log(err); + }); + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + *

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. + */ + public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken: string): void { + 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 + */ + private fixUpLegacyTimelineFields(): void { + // 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.BACKWARDS); + this.currentState = this.getLiveTimeline() + .getState(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 + */ + public async hasUnverifiedDevices(): Promise { + 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 + */ + public getTimelineSets(): EventTimelineSet[] { + return this.timelineSets; + } + + /** + * Helper to return the main unfiltered timeline set for this room + * @return {EventTimelineSet} room's unfiltered timeline set + */ + public getUnfilteredTimelineSet(): EventTimelineSet { + 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 + */ + public getTimelineForEvent(eventId: string): EventTimeline { + 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 + */ + public addTimeline(): EventTimeline { + 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 + */ + public findEventById(eventId: string): MatrixEvent | undefined { + 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. + */ + public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { + 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 + */ + public setUnreadNotificationCount(type: NotificationCountType, count: number): void { + this.notificationCounts[type] = count; + } + + public setSummary(summary: IRoomSummary): void { + 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. + */ + public setBlacklistUnverifiedDevices(value: boolean): void { + 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. + */ + public getBlacklistUnverifiedDevices(): boolean { + 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. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: ResizeMethod, + allowDefault = true, + ): string | null { + const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); + if (!roomAvatarEvent && !allowDefault) { + return null; + } + + const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; + if (mainUrl) { + return 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 + */ + public getMxcAvatarUrl(): string | null { + return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.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 + */ + public getAliases(): string[] { + const aliasStrings = []; + + const aliasEvents = this.currentState.getStateEvents(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 + */ + public getCanonicalAlias(): string | null { + const canonicalAlias = this.currentState.getStateEvents(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 + */ + public getAltAliases(): string[] { + const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alt_aliases || []; + } + return []; + } + + /** + * Add events to a timeline + * + *

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 last 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" + * + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken?: string, + ): void { + 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 null. + */ + public getMember(userId: string): RoomMember | null { + return this.currentState.getMember(userId); + } + + /** + * Get all currently loaded members from the current + * room state. + * @returns {RoomMember[]} Room members + */ + public getMembers(): RoomMember[] { + return this.currentState.getMembers(); + } + + /** + * Get a list of members whose membership state is "join". + * @return {RoomMember[]} A list of currently joined members. + */ + public getJoinedMembers(): RoomMember[] { + 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' + */ + public getJoinedMemberCount(): number { + 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' + */ + public getInvitedMemberCount(): number { + 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' + */ + public getInvitedAndJoinedMemberCount(): number { + 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. + */ + public getMembersWithMembership(membership: string): RoomMember[] { + 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} A list of members who + * we should encrypt messages for in this room. + */ + public async getEncryptionTargetMembers(): Promise { + 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 + */ + public shouldEncryptForInvitedMembers(): boolean { + const ev = this.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); + return 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 + */ + public getDefaultRoomName(userId: string): string { + 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. 'join' + * @return {boolean} True if this user_id has the given membership state. + */ + public hasMembershipState(userId: string, membership: string): boolean { + 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 + */ + public getOrCreateFilteredTimelineSet(filter: Filter): EventTimelineSet { + if (this.filteredTimelineSets[filter.filterId]) { + return this.filteredTimelineSets[filter.filterId]; + } + const opts = Object.assign({ filter: filter }, this.opts); + const timelineSet = new 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.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + timelineSet.getLiveTimeline().setPaginationToken( + timeline.getPaginationToken(EventTimeline.BACKWARDS), + 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 + */ + public removeFilteredTimelineSet(filter: Filter): void { + 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 + */ + private addLiveEvent(event: MatrixEvent, duplicateStrategy: "ignore" | "replace", fromCache: boolean): void { + 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() !== 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. + * + *

The event is added to either the pendingEventList, or the live timeline, + * depending on the setting of opts.pendingEventOrdering. + * + *

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. + */ + public addPendingEvent(event: MatrixEvent, txnId: string): void { + if (event.status !== EventStatus.SENDING && event.status !== 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.setEventMetadata( + event, + this.getLiveTimeline().getState(EventTimeline.FORWARDS), + false, + ); + + this.txnToEvent[txnId] = event; + + if (this.opts.pendingEventOrdering == "detached") { + if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { + logger.warn("Setting event as NOT_SENT due to messages in the same state"); + event.setStatus(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 i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } + } + + this.emit("Room.localEchoUpdated", event, this, null, null); + } + + /** + * Persists all pending events to local storage + * + * If the current room is encrypted only encrypted events will be persisted + * all messages that are not yet encrypted will be discarded + * + * This is because the flow of EVENT_STATUS transition is + * queued => sending => encrypting => sending => sent + * + * Steps 3 and 4 are skipped for unencrypted room. + * It is better to discard an unencrypted message rather than persisting + * it locally for everyone to read + */ + private savePendingEvents(): void { + if (this.pendingEventList) { + const pendingEvents = this.pendingEventList.map(event => { + return { + ...event.event, + txn_id: event.getTxnId(), + }; + }).filter(event => { + // Filter out the unencrypted messages if the room is encrypted + const isEventEncrypted = event.type === EventType.RoomMessageEncrypted; + const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); + return isEventEncrypted || !isRoomEncrypted; + }); + + const { store } = this.client.sessionStore; + if (this.pendingEventList.length > 0) { + store.setItem( + pendingEventsKey(this.roomId), + JSON.stringify(pendingEvents), + ); + } else { + store.removeItem(pendingEventsKey(this.roomId)); + } + } + } + + /** + * Used to aggregate the local echo for a relation, and also + * for re-applying a relation after it's redaction has been cancelled, + * as the local echo for the redaction of the relation would have + * un-aggregated the relation. Note that this is different from regular messages, + * which are just kept detached for their local echo. + * + * Also note that live events are aggregated in the live EventTimelineSet. + * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. + */ + private aggregateNonLiveRelation(event: MatrixEvent): void { + // TODO: We should consider whether this means it would be a better + // design to lift the relations handling up to the room instead. + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.aggregateRelations(event); + } + } else { + timelineSet.aggregateRelations(event); + } + } + } + + /** + * Deal with the echo of a message we sent. + * + *

We move the event to the live timeline if it isn't there already, and + * update it. + * + * @param {module:models/event.MatrixEvent} remoteEvent The event received from + * /sync + * @param {module:models/event.MatrixEvent} localEvent The local echo, which + * should be either in the pendingEventList or the timeline. + * + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * @private + */ + private handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { + const oldEventId = localEvent.getId(); + const newEventId = remoteEvent.getId(); + const oldStatus = localEvent.status; + + logger.debug( + `Got remote echo for event ${oldEventId} -> ${newEventId} ` + + `old status ${oldStatus}`, + ); + + // no longer pending + delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; + + // if it's in the pending list, remove it + if (this.pendingEventList) { + this.removePendingEvent(oldEventId); + } + + // replace the event source (this will preserve the plaintext payload if + // any, which is good, because we don't want to try decoding it again). + localEvent.handleRemoteEcho(remoteEvent.event); + + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + } + + this.emit("Room.localEchoUpdated", localEvent, this, + oldEventId, oldStatus); + } + + /** + * Update the status / event id on a pending event, to reflect its transmission + * progress. + * + *

This is an internal method. + * + * @param {MatrixEvent} event local echo event + * @param {EventStatus} newStatus status to assign + * @param {string} newEventId new event id to assign. Ignored unless + * newStatus == EventStatus.SENT. + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + */ + public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void { + logger.log( + `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + + `event ID ${event.getId()} -> ${newEventId}`, + ); + + // if the message was sent, we expect an event id + if (newStatus == EventStatus.SENT && !newEventId) { + throw new Error("updatePendingEvent called with status=SENT, " + + "but no new event id"); + } + + // SENT races against /sync, so we have to special-case it. + if (newStatus == EventStatus.SENT) { + const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); + if (timeline) { + // we've already received the event via the event stream. + // nothing more to do here. + return; + } + } + + const oldStatus = event.status; + const oldEventId = event.getId(); + + if (!oldStatus) { + throw new Error("updatePendingEventStatus called on an event which is " + + "not a local echo."); + } + + const allowed = ALLOWED_TRANSITIONS[oldStatus]; + if (!allowed || allowed.indexOf(newStatus) < 0) { + throw new Error("Invalid EventStatus transition " + oldStatus + "->" + + newStatus); + } + + event.setStatus(newStatus); + + if (newStatus == EventStatus.SENT) { + // update the event id + event.replaceLocalEventId(newEventId); + + // if the event was already in the timeline (which will be the case if + // opts.pendingEventOrdering==chronological), we need to update the + // timeline map. + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].replaceEventId(oldEventId, newEventId); + } + } else if (newStatus == EventStatus.CANCELLED) { + // remove it from the pending event list, or the timeline. + if (this.pendingEventList) { + const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId); + if (idx !== -1) { + const [removedEvent] = this.pendingEventList.splice(idx, 1); + if (removedEvent.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); + } + } + } + this.removeEvent(oldEventId); + } + this.savePendingEvents(); + + this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); + } + + private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void { + const redactId = redactionEvent.event.redacts; + if (!redactId) { + return; + } + const redactedEvent = this.getUnfilteredTimelineSet() + .findEventById(redactId); + if (redactedEvent) { + redactedEvent.unmarkLocallyRedacted(); + // re-render after undoing redaction + this.emit("Room.redactionCancelled", redactionEvent, this); + // reapply relation now redaction failed + if (redactedEvent.isRelation()) { + this.aggregateNonLiveRelation(redactedEvent); + } + } + } + + /** + * Add some events to this room. This can include state events, message + * events and typing notifications. These events are treated as "live" so + * they will go to the end of the timeline. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {string} duplicateStrategy Optional. Applies to events in the + * timeline only. If this is 'replace' then if a duplicate is encountered, the + * event passed to this function will replace the existing event in the + * timeline. If this is not specified, or is 'ignore', then the event passed to + * this function will be ignored entirely, preserving the existing event in the + * timeline. Events are identical based on their event ID only. + * + * @param {boolean} fromCache whether the sync response came from cache + * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy: "replace" | "ignore", fromCache: boolean): void { + let i; + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { + throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); + } + + // sanity check that the live timeline is still live + for (i = 0; i < this.timelineSets.length; i++) { + const liveTimeline = this.timelineSets[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - it has a pagination token " + + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", + ); + } + if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - " + + "it has a neighbouring timeline", + ); + } + } + + for (i = 0; i < events.length; i++) { + // TODO: We should have a filter to say "only add state event + // types X Y Z to the timeline". + this.addLiveEvent(events[i], duplicateStrategy, fromCache); + } + } + + /** + * Adds/handles ephemeral events such as typing notifications and read receipts. + * @param {MatrixEvent[]} events A list of events to process + */ + public addEphemeralEvents(events: MatrixEvent[]): void { + for (const event of events) { + if (event.getType() === 'm.typing') { + this.currentState.setTypingEvent(event); + } else if (event.getType() === 'm.receipt') { + this.addReceipt(event); + } // else ignore - life is too short for us to care about these events + } + } + + /** + * Removes events from this room. + * @param {String[]} eventIds A list of eventIds to remove. + */ + public removeEvents(eventIds: string[]): void { + for (let i = 0; i < eventIds.length; ++i) { + this.removeEvent(eventIds[i]); + } + } + + /** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {boolean} true if the event was removed from any of the room's timeline sets + */ + public removeEvent(eventId: string): boolean { + let removedAny = false; + for (let i = 0; i < this.timelineSets.length; i++) { + const removed = this.timelineSets[i].removeEvent(eventId); + if (removed) { + if (removed.isRedaction()) { + this.revertRedactionLocalEcho(removed); + } + removedAny = true; + } + } + return removedAny; + } + + /** + * Recalculate various aspects of the room, including the room name and + * room summary. Call this any time the room's current state is modified. + * May fire "Room.name" if the room name is updated. + * @fires module:client~MatrixClient#event:"Room.name" + */ + public recalculate(): void { + // set fake stripped state events if this is an invite room so logic remains + // consistent elsewhere. + const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); + if (membershipEvent && membershipEvent.getContent().membership === "invite") { + const strippedStateEvents = membershipEvent.event.invite_room_state || []; + strippedStateEvents.forEach((strippedEvent) => { + const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); + if (!existingEvent) { + // set the fake stripped event instead + this.currentState.setStateEvents([new MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId, // technically a lie + })]); + } + }); + } + + const oldName = this.name; + this.name = this.calculateRoomName(this.myUserId); + this.normalizedName = normalize(this.name); + this.summary = new RoomSummary(this.roomId, { + title: this.name, + }); + + if (oldName !== this.name) { + this.emit("Room.name", this); + } + } + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return receipt.type === "m.read"; + }).map(function(receipt) { + return receipt.userId; + }); + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param {String} userId The user ID to get read receipt event ID for + * @param {Boolean} ignoreSynthesized If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @return {String} ID of the latest event that the given user has read, or null. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized: boolean): string | null { + let receipts = this.receipts; + if (ignoreSynthesized) { + receipts = this.realReceipts; + } + + if ( + receipts["m.read"] === undefined || + receipts["m.read"][userId] === undefined + ) { + return null; + } + + return receipts["m.read"][userId].eventId; + } + + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } + + /** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ + public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { + return this.receiptCacheByEventId[event.getId()] || []; + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Boolean} fake True if this event is implicit + */ + public addReceipt(event: MatrixEvent, fake = false): void { + if (!fake) { + this.addReceiptsToStructure(event, this.realReceipts); + // we don't bother caching real receipts by event ID + // as there's nothing that would read it. + } + this.addReceiptsToStructure(event, this.receipts); + this.receiptCacheByEventId = this.buildReceiptCache(this.receipts); + + // send events after we've regenerated the cache, otherwise things that + // listened for the event would read from a stale cache + this.emit("Room.receipt", event, this); + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Object} receipts The object to add receipts to + */ + private addReceiptsToStructure(event: MatrixEvent, receipts: Receipts): void { + const content = event.getContent(); + Object.keys(content).forEach((eventId) => { + Object.keys(content[eventId]).forEach((receiptType) => { + Object.keys(content[eventId][receiptType]).forEach((userId) => { + const receipt = content[eventId][receiptType][userId]; + + if (!receipts[receiptType]) { + receipts[receiptType] = {}; + } + + const existingReceipt = receipts[receiptType][userId]; + + if (!existingReceipt) { + receipts[receiptType][userId] = {} as IWrappedReceipt; + } else { + // we only want to add this receipt if we think it is later + // than the one we already have. (This is managed + // server-side, but because we synthesize RRs locally we + // have to do it here too.) + const ordering = this.getUnfilteredTimelineSet().compareEventOrdering( + existingReceipt.eventId, eventId); + if (ordering !== null && ordering >= 0) { + return; + } + } + + receipts[receiptType][userId] = { + eventId: eventId, + data: receipt, + }; + }); + }); + }); + } + + /** + * Build and return a map of receipts by event ID + * @param {Object} receipts A map of receipts + * @return {Object} Map of receipts by event ID + */ + private buildReceiptCache(receipts: Receipts): ReceiptCache { + const receiptCacheByEventId = {}; + Object.keys(receipts).forEach(function(receiptType) { + Object.keys(receipts[receiptType]).forEach(function(userId) { + const receipt = receipts[receiptType][userId]; + if (!receiptCacheByEventId[receipt.eventId]) { + receiptCacheByEventId[receipt.eventId] = []; + } + receiptCacheByEventId[receipt.eventId].push({ + userId: userId, + type: receiptType, + data: receipt.data, + }); + }); + }); + return receiptCacheByEventId; + } + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param {string} userId The user ID if the receipt sender + * @param {MatrixEvent} e The event that is to be acknowledged + * @param {string} receiptType The type of receipt + */ + public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: string): void { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Update the room-tag event for the room. The previous one is overwritten. + * @param {MatrixEvent} event the m.tag event + */ + public addTags(event: MatrixEvent): void { + // event content looks like: + // content: { + // tags: { + // $tagName: { $metadata: $value }, + // $tagName: { $metadata: $value }, + // } + // } + + // XXX: do we need to deep copy here? + this.tags = event.getContent().tags || {}; + + // XXX: we could do a deep-comparison to see if the tags have really + // changed - but do we want to bother? + this.emit("Room.tags", event, this); + } + + /** + * Update the account_data events for this room, overwriting events of the same type. + * @param {Array} events an array of account_data events to add + */ + public addAccountData(events: MatrixEvent[]): void { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.getType() === "m.tag") { + this.addTags(event); + } + const lastEvent = this.accountData[event.getType()]; + this.accountData[event.getType()] = event; + this.emit("Room.accountData", event, this, lastEvent); + } + } + + /** + * Access account_data event of given event type for this room + * @param {string} type the type of account_data event to be accessed + * @return {?MatrixEvent} the account_data event in question + */ + public getAccountData(type: EventType | string): MatrixEvent | undefined { + return this.accountData[type]; + } + + /** + * Returns whether the syncing user has permission to send a message in the room + * @return {boolean} true if the user should be permitted to send + * message events into the room. + */ + public maySendMessage(): boolean { + return this.getMyMembership() === 'join' && + this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId); + } + + /** + * Returns whether the given user has permissions to issue an invite for this room. + * @param {string} userId the ID of the Matrix user to check permissions for + * @returns {boolean} true if the user should be permitted to issue invites for this room. + */ + public canInvite(userId: string): boolean { + let canInvite = this.getMyMembership() === "join"; + const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); + const me = this.getMember(userId); + if (powerLevels && me && powerLevels.invite > me.powerLevel) { + canInvite = false; + } + return canInvite; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + return this.currentState.getJoinRule(); + } + + /** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns {?string} the type of the room. Currently only RoomType.Space is known. + */ + public getType(): RoomType | string | undefined { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getTypeWarning) { + logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); + this.getTypeWarning = true; + } + return undefined; + } + return createEvent.getContent()[RoomCreateTypeField]; + } + + /** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns {boolean} true if the room's type is RoomType.Space + */ + public isSpaceRoom(): boolean { + return this.getType() === RoomType.Space; + } + + /** + * This is an internal method. Calculates the name of the room from the current + * room state. + * @param {string} userId The client's user ID. Used to filter room members + * correctly. + * @param {boolean} ignoreRoomNameEvent Return the implicit room name that we'd see if there + * was no m.room.name event. + * @return {string} The calculated room name. + */ + private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string { + if (!ignoreRoomNameEvent) { + // check for an alias, if any. for now, assume first alias is the + // official one. + const mRoomName = this.currentState.getStateEvents(EventType.RoomName, ""); + if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { + return mRoomName.getContent().name; + } + } + + let alias = this.getCanonicalAlias(); + + if (!alias) { + const aliases = this.getAltAliases(); + + if (aliases.length) { + alias = aliases[0]; + } + } + if (alias) { + return alias; + } + + const joinedMemberCount = this.currentState.getJoinedMemberCount(); + const invitedMemberCount = this.currentState.getInvitedMemberCount(); + // -1 because these numbers include the syncing user + const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get members that are NOT ourselves and are actually in the room. + let otherNames = null; + if (this.summaryHeroes) { + // if we have a summary, the member state events + // should be in the room state + otherNames = this.summaryHeroes.map((userId) => { + const member = this.getMember(userId); + return member ? member.name : userId; + }); + } else { + let otherMembers = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + (m.membership === "invite" || m.membership === "join"); + }); + // make sure members have stable order + otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); + // only 5 first members, immitate summaryHeroes + otherMembers = otherMembers.slice(0, 5); + otherNames = otherMembers.map((m) => m.name); + } + + if (inviteJoinCount) { + return memberNamesToRoomName(otherNames, inviteJoinCount); + } + + const myMembership = this.getMyMembership(); + // if I have created a room and invited people throuh + // 3rd party invites + if (myMembership == 'join') { + const thirdPartyInvites = + this.currentState.getStateEvents(EventType.RoomThirdPartyInvite); + + if (thirdPartyInvites && thirdPartyInvites.length) { + const thirdPartyNames = thirdPartyInvites.map((i) => { + return i.getContent().display_name; + }); + + return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; + } + } + // let's try to figure out who was here before + let leftNames = otherNames; + // if we didn't have heroes, try finding them in the room state + if (!leftNames.length) { + leftNames = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + m.membership !== "invite" && + m.membership !== "join"; + }).map((m) => m.name); + } + if (leftNames.length) { + return `Empty room (was ${memberNamesToRoomName(leftNames)})`; + } else { + return "Empty room"; + } + } +} + +/** + * @param {string} roomId ID of the current room + * @returns {string} Storage key to retrieve pending events + */ +function pendingEventsKey(roomId: string): string { + return `mx_pending_events_${roomId}`; +} + +/* a map from current event status to a list of allowed next statuses + */ +const ALLOWED_TRANSITIONS = {}; + +ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ + EventStatus.SENDING, + EventStatus.NOT_SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ + EventStatus.ENCRYPTING, + EventStatus.QUEUED, + EventStatus.NOT_SENT, + EventStatus.SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.QUEUED] = + [EventStatus.SENDING, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.SENT] = + []; + +ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = + [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = + []; + +// TODO i18n +function memberNamesToRoomName(names: string[], count = (names.length + 1)) { + const countWithoutMe = count - 1; + if (!names.length) { + return "Empty room"; + } else if (names.length === 1 && countWithoutMe <= 1) { + return names[0]; + } else if (names.length === 2 && countWithoutMe <= 2) { + return `${names[0]} and ${names[1]}`; + } else { + const plural = countWithoutMe > 1; + if (plural) { + return `${names[0]} and ${countWithoutMe} others`; + } else { + return `${names[0]} and 1 other`; + } + } +} + +/** + * Fires when an event we had previously received is redacted. + * + * (Note this is *not* fired when the redaction happens before we receive the + * event). + * + * @event module:client~MatrixClient#"Room.redaction" + * @param {MatrixEvent} event The matrix redaction event + * @param {Room} room The room containing the redacted event + */ + +/** + * Fires when an event that was previously redacted isn't anymore. + * This happens when the redaction couldn't be sent and + * was subsequently cancelled by the user. Redactions have a local echo + * which is undone in this scenario. + * + * @event module:client~MatrixClient#"Room.redactionCancelled" + * @param {MatrixEvent} event The matrix redaction event that was cancelled. + * @param {Room} room The room containing the unredacted event + */ + +/** + * Fires whenever the name of a room is updated. + * @event module:client~MatrixClient#"Room.name" + * @param {Room} room The room whose Room.name was updated. + * @example + * matrixClient.on("Room.name", function(room){ + * var newName = room.name; + * }); + */ + +/** + * Fires whenever a receipt is received for a room + * @event module:client~MatrixClient#"Room.receipt" + * @param {event} event The receipt event + * @param {Room} room The room whose receipts was updated. + * @example + * matrixClient.on("Room.receipt", function(event, room){ + * var receiptContent = event.getContent(); + * }); + */ + +/** + * Fires whenever a room's tags are updated. + * @event module:client~MatrixClient#"Room.tags" + * @param {event} event The tags event + * @param {Room} room The room whose Room.tags was updated. + * @example + * matrixClient.on("Room.tags", function(event, room){ + * var newTags = event.getContent().tags; + * if (newTags["favourite"]) showStar(room); + * }); + */ + +/** + * Fires whenever a room's account_data is updated. + * @event module:client~MatrixClient#"Room.accountData" + * @param {event} event The account_data event + * @param {Room} room The room whose account_data was updated. + * @param {MatrixEvent} prevEvent The event being replaced by + * the new account data, if known. + * @example + * matrixClient.on("Room.accountData", function(event, room, oldEvent){ + * if (event.getType() === "m.room.colorscheme") { + * applyColorScheme(event.getContents()); + * } + * }); + */ + +/** + * Fires when the status of a transmitted event is updated. + * + *

When an event is first transmitted, a temporary copy of the event is + * inserted into the timeline, with a temporary event id, and a status of + * 'SENDING'. + * + *

Once the echo comes back from the server, the content of the event + * (MatrixEvent.event) is replaced by the complete event from the homeserver, + * thus updating its event id, as well as server-generated fields such as the + * timestamp. Its status is set to null. + * + *

Once the /send request completes, if the remote echo has not already + * arrived, the event is updated with a new event id and the status is set to + * 'SENT'. The server-generated fields are of course not updated yet. + * + *

If the /send fails, In this case, the event's status is set to + * 'NOT_SENT'. If it is later resent, the process starts again, setting the + * status to 'SENDING'. Alternatively, the message may be cancelled, which + * removes the event from the room, and sets the status to 'CANCELLED'. + * + *

This event is raised to reflect each of the transitions above. + * + * @event module:client~MatrixClient#"Room.localEchoUpdated" + * + * @param {MatrixEvent} event The matrix event which has been updated + * + * @param {Room} room The room containing the redacted event + * + * @param {string} oldEventId The previous event id (the temporary event id, + * except when updating a successfully-sent event when its echo arrives) + * + * @param {EventStatus} oldStatus The previous event status. + */ + +/** + * Fires when the logged in user's membership in the room is updated. + * + * @event module:models/room~Room#"Room.myMembership" + * @param {Room} room The room in which the membership has been updated + * @param {string} membership The new membership value + * @param {string} prevMembership The previous membership value + */