diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts')
-rw-r--r-- | includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts | 3487 |
1 files changed, 3487 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts new file mode 100644 index 0000000..133b210 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts @@ -0,0 +1,3487 @@ +/* +Copyright 2015 - 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { M_POLL_START, Optional } from "matrix-events-sdk"; + +import { + EventTimelineSet, + DuplicateStrategy, + IAddLiveEventOptions, + EventTimelineSetHandlerMap, +} from "./event-timeline-set"; +import { Direction, EventTimeline } from "./event-timeline"; +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { normalize, noUnsafeEventProps } from "../utils"; +import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event"; +import { EventStatus } from "./event-status"; +import { RoomMember } from "./room-member"; +import { IRoomSummary, RoomSummary } from "./room-summary"; +import { logger } from "../logger"; +import { TypedReEmitter } from "../ReEmitter"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_ELEMENT_FUNCTIONAL_USERS, + EVENT_VISIBILITY_CHANGE_TYPE, + RelationType, +} from "../@types/event"; +import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; +import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; +import { Filter, IFilterDefinition } from "../filter"; +import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state"; +import { BeaconEvent, BeaconEventHandlerMap } from "./beacon"; +import { + Thread, + ThreadEvent, + EventHandlerMap as ThreadHandlerMap, + FILTER_RELATED_BY_REL_TYPES, + THREAD_RELATION_TYPE, + FILTER_RELATED_BY_SENDERS, + ThreadFilterType, +} from "./thread"; +import { + CachedReceiptStructure, + MAIN_ROOM_TIMELINE, + Receipt, + ReceiptContent, + ReceiptType, +} from "../@types/read_receipts"; +import { IStateEventWithRoomId } from "../@types/search"; +import { RelationsContainer } from "./relations-container"; +import { ReadReceipt, synthesizeReceipt } from "./read-receipt"; +import { Poll, PollEvent } from "./poll"; + +// 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. +export const KNOWN_SAFE_ROOM_VERSION = "9"; +const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; + +interface IOpts { + /** + * Controls where pending messages appear in a room's timeline. + * If "<b>chronological</b>", messages will appear in the timeline when the call to `sendEvent` was made. + * If "<b>detached</b>", pending messages will appear in a separate list, + * accessible via {@link Room#getPendingEvents}. + * Default: "chronological". + */ + pendingEventOrdering?: PendingEventOrdering; + /** + * Set to true to enable improved timeline support. + */ + timelineSupport?: boolean; + lazyLoadMembers?: boolean; +} + +export interface IRecommendedVersion { + version: string; + needsUpgrade: boolean; + urgent: boolean; +} + +// When inserting a visibility event affecting event `eventId`, we +// need to scan through existing visibility events for `eventId`. +// In theory, this could take an unlimited amount of time if: +// +// - the visibility event was sent by a moderator; and +// - `eventId` already has many visibility changes (usually, it should +// be 2 or less); and +// - for some reason, the visibility changes are received out of order +// (usually, this shouldn't happen at all). +// +// For this reason, we limit the number of events to scan through, +// expecting that a broken visibility change for a single event in +// an extremely uncommon case (possibly a DoS) is a small +// price to pay to keep matrix-js-sdk responsive. +const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30; + +export type NotificationCount = Partial<Record<NotificationCountType, number>>; + +export enum NotificationCountType { + Highlight = "highlight", + Total = "total", +} + +export interface ICreateFilterOpts { + // Populate the filtered timeline with already loaded events in the room + // timeline. Useful to disable for some filters that can't be achieved by the + // client in an efficient manner + prepopulateTimeline?: boolean; + useSyncEvents?: boolean; + pendingEvents?: boolean; +} + +export enum RoomEvent { + MyMembership = "Room.myMembership", + Tags = "Room.tags", + AccountData = "Room.accountData", + Receipt = "Room.receipt", + Name = "Room.name", + Redaction = "Room.redaction", + RedactionCancelled = "Room.redactionCancelled", + LocalEchoUpdated = "Room.localEchoUpdated", + Timeline = "Room.timeline", + TimelineReset = "Room.timelineReset", + TimelineRefresh = "Room.TimelineRefresh", + OldStateUpdated = "Room.OldStateUpdated", + CurrentStateUpdated = "Room.CurrentStateUpdated", + HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", + UnreadNotifications = "Room.UnreadNotifications", +} + +export type RoomEmittedEvents = + | RoomEvent + | RoomStateEvent.Events + | RoomStateEvent.Members + | RoomStateEvent.NewMember + | RoomStateEvent.Update + | RoomStateEvent.Marker + | ThreadEvent.New + | ThreadEvent.Update + | ThreadEvent.NewReply + | ThreadEvent.Delete + | MatrixEventEvent.BeforeRedaction + | BeaconEvent.New + | BeaconEvent.Update + | BeaconEvent.Destroy + | BeaconEvent.LivenessChange + | PollEvent.New; + +export type RoomEventHandlerMap = { + /** + * Fires when the logged in user's membership in the room is updated. + * + * @param room - The room in which the membership has been updated + * @param membership - The new membership value + * @param prevMembership - The previous membership value + */ + [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void; + /** + * Fires whenever a room's tags are updated. + * @param event - The tags event + * @param 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); + * }); + * ``` + */ + [RoomEvent.Tags]: (event: MatrixEvent, room: Room) => void; + /** + * Fires whenever a room's account_data is updated. + * @param event - The account_data event + * @param room - The room whose account_data was updated. + * @param 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()); + * } + * }); + * ``` + */ + [RoomEvent.AccountData]: (event: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => void; + /** + * Fires whenever a receipt is received for a room + * @param event - The receipt event + * @param room - The room whose receipts was updated. + * @example + * ``` + * matrixClient.on("Room.receipt", function(event, room){ + * var receiptContent = event.getContent(); + * }); + * ``` + */ + [RoomEvent.Receipt]: (event: MatrixEvent, room: Room) => void; + /** + * Fires whenever the name of a room is updated. + * @param room - The room whose Room.name was updated. + * @example + * ``` + * matrixClient.on("Room.name", function(room){ + * var newName = room.name; + * }); + * ``` + */ + [RoomEvent.Name]: (room: Room) => void; + /** + * Fires when an event we had previously received is redacted. + * + * (Note this is *not* fired when the redaction happens before we receive the + * event). + * + * @param event - The matrix redaction event + * @param room - The room containing the redacted event + */ + [RoomEvent.Redaction]: (event: MatrixEvent, room: Room) => void; + /** + * 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. + * + * @param event - The matrix redaction event that was cancelled. + * @param room - The room containing the unredacted event + */ + [RoomEvent.RedactionCancelled]: (event: MatrixEvent, room: Room) => void; + /** + * Fires when the status of a transmitted event is updated. + * + * <p>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'. + * + * <p>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. + * + * <p>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. + * + * <p>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'. + * + * <p>This event is raised to reflect each of the transitions above. + * + * @param event - The matrix event which has been updated + * + * @param room - The room containing the redacted event + * + * @param oldEventId - The previous event id (the temporary event id, + * except when updating a successfully-sent event when its echo arrives) + * + * @param oldStatus - The previous event status. + */ + [RoomEvent.LocalEchoUpdated]: ( + event: MatrixEvent, + room: Room, + oldEventId?: string, + oldStatus?: EventStatus | null, + ) => void; + [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.HistoryImportedWithinTimeline]: (markerEvent: MatrixEvent, room: Room) => void; + [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void; + [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; + [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; + /** + * Fires when a new poll instance is added to the room state + * @param poll - the new poll + */ + [PollEvent.New]: (poll: Poll) => void; +} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> & + EventTimelineSetHandlerMap & + Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> & + Pick< + RoomStateEventHandlerMap, + | RoomStateEvent.Events + | RoomStateEvent.Members + | RoomStateEvent.NewMember + | RoomStateEvent.Update + | RoomStateEvent.Marker + | BeaconEvent.New + > & + Pick<BeaconEventHandlerMap, BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange>; + +export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> { + public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>; + private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent } + private notificationCounts: NotificationCount = {}; + private readonly threadNotifications = new Map<string, NotificationCount>(); + public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>(); + // Useful to know at what point the current user has started using threads in this room + private oldestThreadedReceiptTs = Infinity; + /** + * A record of the latest unthread receipts per user + * This is useful in determining whether a user has read a thread or not + */ + private unthreadedReceipts = new Map<string, Receipt>(); + private readonly timelineSets: EventTimelineSet[]; + public readonly polls: Map<string, Poll> = new Map<string, Poll>(); + public readonly threadsTimelineSets: EventTimelineSet[] = []; + // any filtered timeline sets we're maintaining for this room + private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet + private timelineNeedsRefresh = false; + private readonly pendingEventList?: MatrixEvent[]; + // read by megolm via getter; boolean value - null indicates "use global value" + private blacklistUnverifiedDevices?: boolean; + private selfMembership?: string; + private summaryHeroes: string[] | null = null; + // flags to stop logspam about missing m.room.create events + private getTypeWarning = false; + private getVersionWarning = false; + private membersPromise?: Promise<boolean>; + + // XXX: These should be read-only + /** + * The human-readable display name for this room. + */ + public name: string; + /** + * The un-homoglyphed name for this room. + */ + public normalizedName: string; + /** + * 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 } }` + */ + public tags: Record<string, Record<string, any>> = {}; // $tagName: { $metadata: $value } + /** + * accountData Dict of per-room account_data events; the keys are the + * event type and the values are the events. + */ + public accountData: Map<string, MatrixEvent> = new Map(); // $eventType: $event + /** + * The room summary. + */ + public summary: RoomSummary | null = null; + // legacy fields + /** + * The live event timeline for this room, with the oldest event at index 0. + * Present for backwards compatibility - prefer getLiveTimeline().getEvents() + */ + public timeline!: MatrixEvent[]; + /** + * 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). + */ + public oldState!: 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). + */ + public currentState!: RoomState; + public readonly relations = new RelationsContainer(this.client, this); + + /** + * A collection of events known by the client + * This is not a comprehensive list of the threads that exist in this room + */ + private threads = new Map<string, Thread>(); + public lastThread?: Thread; + + /** + * A mapping of eventId to all visibility changes to apply + * to the event, by chronological order, as per + * https://github.com/matrix-org/matrix-doc/pull/3531 + * + * # Invariants + * + * - within each list, all events are classed by + * chronological order; + * - all events are events such that + * `asVisibilityEvent()` returns a non-null `IVisibilityChange`; + * - within each list with key `eventId`, all events + * are in relation to `eventId`. + * + * @experimental + */ + private visibilityEvents = new Map<string, MatrixEvent[]>(); + + /** + * Construct a new Room. + * + * <p>For a room, we store an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline. It also tracks + * forward and backward pagination tokens, as well as containing links to the + * next timeline in the sequence. + * + * <p>There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + * <p>In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @param roomId - Required. The ID of this room. + * @param client - Required. The client, used to lazy load members. + * @param myUserId - Required. The ID of the syncing user. + * @param opts - Configuration options + */ + public constructor( + public readonly roomId: string, + public 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 TypedReEmitter(this); + + opts.pendingEventOrdering = opts.pendingEventOrdering || PendingEventOrdering.Chronological; + + this.name = roomId; + this.normalizedName = roomId; + + // all our per-room timeline sets. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this.timelineSets = [new EventTimelineSet(this, opts)]; + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]); + + this.fixUpLegacyTimelineFields(); + + if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { + this.pendingEventList = []; + this.client.store.getPendingEvents(this.roomId).then((events) => { + const mapper = this.client.getEventMapper({ + toDevice: false, + decrypt: false, + }); + events.forEach(async (serializedEvent: Partial<IEvent>) => { + const event = mapper(serializedEvent); + await client.decryptEventIfNeeded(event); + 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 = undefined; + } + } + + private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null; + public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> { + if (this.threadTimelineSetsPromise) { + return this.threadTimelineSetsPromise; + } + + if (this.client?.supportsThreads()) { + try { + this.threadTimelineSetsPromise = Promise.all([ + this.createThreadTimelineSet(), + this.createThreadTimelineSet(ThreadFilterType.My), + ]); + const timelineSets = await this.threadTimelineSetsPromise; + this.threadsTimelineSets.push(...timelineSets); + return timelineSets; + } catch (e) { + this.threadTimelineSetsPromise = null; + return null; + } + } + return 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 Signals when all events have been decrypted + */ + public async decryptCriticalEvents(): Promise<void> { + if (!this.client.isCryptoEnabled()) return; + + 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) + .reverse() + .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true })); + + await Promise.allSettled(decryptionPromises); + } + + /** + * Bulk decrypt events in a room + * + * @returns Signals when all events have been decrypted + */ + public async decryptAllEvents(): Promise<void> { + if (!this.client.isCryptoEnabled()) return; + + const decryptionPromises = this.getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .slice(0) // copy before reversing + .reverse() + .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true })); + + await Promise.allSettled(decryptionPromises); + } + + /** + * Gets the creator of the room + * @returns The creator of the room, or null if it could not be determined + */ + public getCreator(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + return createEvent?.getContent()["creator"] ?? null; + } + + /** + * Gets the version of the room + * @returns The version of the room, or null if it could not be determined + */ + public getVersion(): string { + 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"; + } + return createEvent.getContent()["room_version"] ?? "1"; + } + + /** + * Determines whether this room needs to be upgraded to a new version + * @returns 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 + * Resolves to the version the room should be upgraded to. + */ + public async getRecommendedVersion(): Promise<IRecommendedVersion> { + 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: IRecommendedVersion = { + 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 userId - The ID of the user to test against + * @returns 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 + * + * @returns A list of the sent events + * waiting for remote echo. + * + * @throws If `opts.pendingEventOrdering` was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (!this.pendingEventList) { + throw new Error( + "Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering, + ); + } + + return this.pendingEventList; + } + + /** + * Removes a pending event for this room + * + * @returns True if an element was removed. + */ + public removePendingEvent(eventId: string): boolean { + if (!this.pendingEventList) { + 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 eventId - The event ID to check for. + */ + public hasPendingEvent(eventId: string): boolean { + return this.pendingEventList?.some((event) => event.getId() === eventId) ?? false; + } + + /** + * Get a specific event from the pending event list, if configured, null otherwise. + * + * @param eventId - The event ID to check for. + */ + public getPendingEvent(eventId: string): MatrixEvent | null { + return this.pendingEventList?.find((event) => event.getId() === eventId) ?? null; + } + + /** + * Get the live unfiltered timeline for this room. + * + * @returns live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().getLiveTimeline(); + } + + /** + * Get the timestamp of the last message in the room + * + * @returns 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; + } + } + + /** + * @returns the membership type (join | leave | invite) for the logged in user + */ + public getMyMembership(): string { + return this.selfMembership ?? "leave"; + } + + /** + * If this room is a DM we're invited to, + * try to find out who invited us + * @returns user id of the inviter + */ + public getDMInviter(): string | undefined { + 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) { + return this.summaryHeroes?.[0]; + } + } + } + + /** + * Assuming this room is a DM room, tries to guess with which user. + * @returns 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 + if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) { + 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 | undefined { + 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 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(RoomEvent.MyMembership, this, membership, prevMembership); + } + } + + private async loadMembersFromServer(): Promise<IStateEventWithRoomId[]> { + const lastSyncToken = this.client.store.getSyncToken(); + const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken ?? undefined); + 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 the room is encrypted, we always fetch members from the server at + // least once, in case the latest state wasn't persisted properly. Note + // that this function is only called once (unless loading the members + // fails), since loadMembersIfNeeded always returns this.membersPromise + // if set, which will be the result of the first (successful) call. + if (rawMembersEvents === null || (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) { + fromServer = true; + rawMembersEvents = await this.loadMembersFromServer(); + logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`); + } + const memberEvents = rawMembersEvents.filter(noUnsafeEventProps).map(this.client.getEventMapper()); + return { memberEvents, fromServer }; + } + + /** + * Check if loading of out-of-band-members has completed + * + * @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled). + * False if the load is not started or is in progress. + */ + public membersLoaded(): boolean { + if (!this.opts.lazyLoadMembers) { + return true; + } + + return this.currentState.outOfBandMembersReady(); + } + + /** + * Preloads the member list in case lazy loading + * of memberships is in use. Can be called multiple times, + * it will only preload once. + * @returns when preloading is done and + * accessing the members on the room will take + * all members in the room into account + */ + public loadMembersIfNeeded(): Promise<boolean> { + 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); + return result.fromServer; + }) + .catch((err) => { + // allow retries on fail + this.membersPromise = undefined; + 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 as IStateEventWithRoomId); + 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<void> { + if (this.opts.lazyLoadMembers && this.membersPromise) { + await this.loadMembersIfNeeded(); + await this.client.store.clearOutOfBandMembers(this.roomId); + this.currentState.clearOutOfBandMembers(); + this.membersPromise = undefined; + } + } + + /** + * 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); + }); + } + + /** + * Empty out the current live timeline and re-request it. This is used when + * historical messages are imported into the room via MSC2716 `/batch_send` + * because the client may already have that section of the timeline loaded. + * We need to force the client to throw away their current timeline so that + * when they back paginate over the area again with the historical messages + * in between, it grabs the newly imported messages. We can listen for + * `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready + * to be discovered in the room and the timeline needs a refresh. The SDK + * emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a + * valid marker and can check the needs refresh status via + * `room.getTimelineNeedsRefresh()`. + */ + public async refreshLiveTimeline(): Promise<void> { + const liveTimelineBefore = this.getLiveTimeline(); + const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS); + const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS); + const eventsBefore = liveTimelineBefore.getEvents(); + const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; + logger.log( + `[refreshLiveTimeline for ${this.roomId}] at ` + + `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + + `forwardPaginationToken=${forwardPaginationToken} ` + + `backwardPaginationToken=${backwardPaginationToken}`, + ); + + // Get the main TimelineSet + const timelineSet = this.getUnfilteredTimelineSet(); + + let newTimeline: Optional<EventTimeline>; + // If there isn't any event in the timeline, let's go fetch the latest + // event and construct a timeline from it. + // + // This should only really happen if the user ran into an error + // with refreshing the timeline before which left them in a blank + // timeline from `resetLiveTimeline`. + if (!mostRecentEventInTimeline) { + newTimeline = await this.client.getLatestTimeline(timelineSet); + } else { + // Empty out all of `this.timelineSets`. But we also need to keep the + // same `timelineSet` references around so the React code updates + // properly and doesn't ignore the room events we emit because it checks + // that the `timelineSet` references are the same. We need the + // `timelineSet` empty so that the `client.getEventTimeline(...)` call + // later, will call `/context` and create a new timeline instead of + // returning the same one. + this.resetLiveTimeline(null, null); + + // Make the UI timeline show the new blank live timeline we just + // reset so that if the network fails below it's showing the + // accurate state of what we're working with instead of the + // disconnected one in the TimelineWindow which is just hanging + // around by reference. + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + + // Use `client.getEventTimeline(...)` to construct a new timeline from a + // `/context` response state and events for the most recent event before + // we reset everything. The `timelineSet` we pass in needs to be empty + // in order for this function to call `/context` and generate a new + // timeline. + newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()!); + } + + // If a racing `/sync` beat us to creating a new timeline, use that + // instead because it's the latest in the room and any new messages in + // the scrollback will include the history. + const liveTimeline = timelineSet.getLiveTimeline(); + if ( + !liveTimeline || + (liveTimeline.getPaginationToken(Direction.Forward) === null && + liveTimeline.getPaginationToken(Direction.Backward) === null && + liveTimeline.getEvents().length === 0) + ) { + logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`); + // Set the pagination token back to the live sync token (`null`) instead + // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) + // so that it matches the next response from `/sync` and we can properly + // continue the timeline. + newTimeline!.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); + + // Set our new fresh timeline as the live timeline to continue syncing + // forwards and back paginating from. + timelineSet.setLiveTimeline(newTimeline!); + // Fixup `this.oldstate` so that `scrollback` has the pagination tokens + // available + this.fixUpLegacyTimelineFields(); + } else { + logger.log( + `[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` + + `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + + `this timeline will include the history.`, + ); + } + + // The timeline has now been refreshed ✅ + this.setTimelineNeedsRefresh(false); + + // Emit an event which clients can react to and re-load the timeline + // from the SDK + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + * <p>This is used when /sync returns a 'limited' timeline. + * + * @param backPaginationToken - token for back-paginating the new timeline + * @param 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 | null, forwardPaginationToken?: string | null): void { + for (const timelineSet of this.timelineSets) { + timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined); + } + for (const thread of this.threads.values()) { + thread.resetLiveTimeline(backPaginationToken, forwardPaginationToken); + } + + this.fixUpLegacyTimelineFields(); + } + + /** + * Fix up this.timeline, this.oldState and this.currentState + * + * @internal + */ + private fixUpLegacyTimelineFields(): void { + const previousOldState = this.oldState; + const previousCurrentState = this.currentState; + + // 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)!; + + // Let people know to register new listeners for the new state + // references. The reference won't necessarily change every time so only + // emit when we see a change. + if (previousOldState !== this.oldState) { + this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState); + } + + if (previousCurrentState !== this.currentState) { + this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState); + + // Re-emit various events on the current room state + // TODO: If currentState really only exists for backwards + // compatibility, shouldn't we be doing this some other way? + this.reEmitter.stopReEmitting(previousCurrentState, [ + RoomStateEvent.Events, + RoomStateEvent.Members, + RoomStateEvent.NewMember, + RoomStateEvent.Update, + RoomStateEvent.Marker, + BeaconEvent.New, + BeaconEvent.Update, + BeaconEvent.Destroy, + BeaconEvent.LivenessChange, + ]); + this.reEmitter.reEmit(this.currentState, [ + RoomStateEvent.Events, + RoomStateEvent.Members, + RoomStateEvent.NewMember, + RoomStateEvent.Update, + RoomStateEvent.Marker, + BeaconEvent.New, + BeaconEvent.Update, + BeaconEvent.Destroy, + BeaconEvent.LivenessChange, + ]); + } + } + + /** + * 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. + * + * @returns the result + */ + public async hasUnverifiedDevices(): Promise<boolean> { + 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. + * @returns array of timeline sets for this room + */ + public getTimelineSets(): EventTimelineSet[] { + return this.timelineSets; + } + + /** + * Helper to return the main unfiltered timeline set for this room + * @returns 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 eventId - event ID to look for + * @returns timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId: string): EventTimeline | null { + const event = this.findEventById(eventId); + const thread = this.findThreadForEvent(event); + if (thread) { + return thread.timelineSet.getTimelineForEvent(eventId); + } else { + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); + } + } + + /** + * Add a new timeline to this room's unfiltered timeline set + * + * @returns newly-created timeline + */ + public addTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().addTimeline(); + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @param value - The value to set + */ + public setTimelineNeedsRefresh(value: boolean): void { + this.timelineNeedsRefresh = value; + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @returns . + */ + public getTimelineNeedsRefresh(): boolean { + return this.timelineNeedsRefresh; + } + + /** + * Get an event which is stored in our unfiltered timeline set, or in a thread + * + * @param eventId - event ID to look for + * @returns the given event, or undefined if unknown + */ + public findEventById(eventId: string): MatrixEvent | undefined { + let event = this.getUnfilteredTimelineSet().findEventById(eventId); + + if (!event) { + const threads = this.getThreads(); + for (let i = 0; i < threads.length; i++) { + const thread = threads[i]; + event = thread.findEventById(eventId); + if (event) { + return event; + } + } + } + + return event; + } + + /** + * Get one of the notification counts for this room + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getUnreadNotificationCount(type = NotificationCountType.Total): number { + let count = this.getRoomUnreadNotificationCount(type); + for (const threadNotification of this.threadNotifications.values()) { + count += threadNotification[type] ?? 0; + } + return count; + } + + /** + * Get the notification for the event context (room or thread timeline) + */ + public getUnreadCountForEventContext(type = NotificationCountType.Total, event: MatrixEvent): number { + const isThreadEvent = !!event.threadRootId && !event.isThreadRoot; + + return ( + (isThreadEvent + ? this.getThreadUnreadNotificationCount(event.threadRootId, type) + : this.getRoomUnreadNotificationCount(type)) ?? 0 + ); + } + + /** + * Get one of the notification counts for this room + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getRoomUnreadNotificationCount(type = NotificationCountType.Total): number { + return this.notificationCounts[type] ?? 0; + } + + /** + * Get one of the notification counts for a thread + * @param threadId - the root event ID + * @param type - The type of notification count to get. default: 'total' + * @returns The notification count, or undefined if there is no count + * for this type. + */ + public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number { + return this.threadNotifications.get(threadId)?.[type] ?? 0; + } + + /** + * Checks if the current room has unread thread notifications + * @returns + */ + public hasThreadUnreadNotification(): boolean { + for (const notification of this.threadNotifications.values()) { + if ((notification.highlight ?? 0) > 0 || (notification.total ?? 0) > 0) { + return true; + } + } + return false; + } + + /** + * Swet one of the notification count for a thread + * @param threadId - the root event ID + * @param type - The type of notification count to get. default: 'total' + * @returns + */ + public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void { + const notification: NotificationCount = { + highlight: this.threadNotifications.get(threadId)?.highlight, + total: this.threadNotifications.get(threadId)?.total, + ...{ + [type]: count, + }, + }; + + this.threadNotifications.set(threadId, notification); + + this.emit(RoomEvent.UnreadNotifications, notification, threadId); + } + + /** + * @returns the notification count type for all the threads in the room + */ + public get threadsAggregateNotificationType(): NotificationCountType | null { + let type: NotificationCountType | null = null; + for (const threadNotification of this.threadNotifications.values()) { + if ((threadNotification.highlight ?? 0) > 0) { + return NotificationCountType.Highlight; + } else if ((threadNotification.total ?? 0) > 0 && !type) { + type = NotificationCountType.Total; + } + } + return type; + } + + /** + * Resets the thread notifications for this room + */ + public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void { + if (notificationsToKeep) { + for (const [threadId] of this.threadNotifications) { + if (!notificationsToKeep.includes(threadId)) { + this.threadNotifications.delete(threadId); + } + } + } else { + this.threadNotifications.clear(); + } + this.emit(RoomEvent.UnreadNotifications); + } + + /** + * Set one of the notification counts for this room + * @param type - The type of notification count to set. + * @param count - The new count + */ + public setUnreadNotificationCount(type: NotificationCountType, count: number): void { + this.notificationCounts[type] = count; + this.emit(RoomEvent.UnreadNotifications, this.notificationCounts); + } + + public setUnread(type: NotificationCountType, count: number): void { + return this.setUnreadNotificationCount(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 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. + * @returns true if blacklisting unverified devices, null + * if the global value should be used for this room. + */ + public getBlacklistUnverifiedDevices(): boolean | null { + if (this.blacklistUnverifiedDevices === undefined) return null; + return this.blacklistUnverifiedDevices; + } + + /** + * Get the avatar URL for a room if one was set. + * @param baseUrl - The homeserver base URL. See + * {@link MatrixClient#getHomeserverUrl}. + * @param width - The desired width of the thumbnail. + * @param height - The desired height of the thumbnail. + * @param resizeMethod - The thumbnail resize method to use, either + * "crop" or "scale". + * @param allowDefault - True to allow an identicon for this room if an + * avatar URL wasn't explicitly set. Default: true. (Deprecated) + * @returns 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. + * @returns the mxc avatar url or falsy + */ + public getMxcAvatarUrl(): string | null { + return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null; + } + + /** + * Get this room's canonical alias + * The alias returned by this function may not necessarily + * still point to this room. + * @returns 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 + * @returns 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 + * + * <p>Will fire "Room.timeline" for each event added. + * + * @param events - A list of events to add. + * + * @param toStartOfTimeline - True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the <b>last</b> element of 'events'. + * + * @param timeline - timeline to + * add events to. + * + * @param paginationToken - token for the next batch of events + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken?: string, + ): void { + timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken); + } + + /** + * Get the instance of the thread associated with the current event + * @param eventId - the ID of the current event + * @returns a thread instance if known + */ + public getThread(eventId: string): Thread | null { + return this.threads.get(eventId) ?? null; + } + + /** + * Get all the known threads in the room + */ + public getThreads(): Thread[] { + return Array.from(this.threads.values()); + } + + /** + * Get a member from the current room state. + * @param userId - The user ID of the member. + * @returns 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 Room members + */ + public getMembers(): RoomMember[] { + return this.currentState.getMembers(); + } + + /** + * Get a list of members whose membership state is "join". + * @returns 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. + * @returns 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 + * @returns 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 + * @returns 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 membership - The membership state. + * @returns 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 + * @returns A list of members who + * we should encrypt messages for in this room. + */ + public async getEncryptionTargetMembers(): Promise<RoomMember[]> { + 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 + * @returns 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 userId - The userId from whose perspective we want + * to calculate the default name + * @returns 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 userId - The user ID to check. + * @param membership - The membership e.g. `'join'` + * @returns 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 - The filter to be applied to this timelineSet + * @param opts - Configuration options + * @returns The timelineSet + */ + public getOrCreateFilteredTimelineSet( + filter: Filter, + { prepopulateTimeline = true, useSyncEvents = true, pendingEvents = true }: ICreateFilterOpts = {}, + ): EventTimelineSet { + if (this.filteredTimelineSets[filter.filterId!]) { + return this.filteredTimelineSets[filter.filterId!]; + } + const opts = Object.assign({ filter, pendingEvents }, this.opts); + const timelineSet = new EventTimelineSet(this, opts); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); + if (useSyncEvents) { + this.filteredTimelineSets[filter.filterId!] = timelineSet; + this.timelineSets.push(timelineSet); + } + + const unfilteredLiveTimeline = this.getLiveTimeline(); + // Not all filter are possible to replicate client-side only + // When that's the case we do not want to prepopulate from the live timeline + // as we would get incorrect results compared to what the server would send back + if (prepopulateTimeline) { + // 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 + + 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); + } else if (useSyncEvents) { + const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward); + timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, Direction.Backward); + } + + // 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; + } + + private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> { + const myUserId = this.client.getUserId()!; + const filter = new Filter(myUserId); + + const definition: IFilterDefinition = { + room: { + timeline: { + [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name], + }, + }, + }; + + if (filterType === ThreadFilterType.My) { + definition!.room!.timeline![FILTER_RELATED_BY_SENDERS.name] = [myUserId]; + } + + filter.setDefinition(definition); + const filterId = await this.client.getOrCreateFilter(`THREAD_PANEL_${this.roomId}_${filterType}`, filter); + + filter.filterId = filterId; + + return filter; + } + + private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> { + let timelineSet: EventTimelineSet; + if (Thread.hasServerSideListSupport) { + timelineSet = new EventTimelineSet( + this, + { + ...this.opts, + pendingEvents: false, + }, + undefined, + undefined, + filterType ?? ThreadFilterType.All, + ); + this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]); + } else if (Thread.hasServerSideSupport) { + const filter = await this.getThreadListFilter(filterType); + + timelineSet = this.getOrCreateFilteredTimelineSet(filter, { + prepopulateTimeline: false, + useSyncEvents: false, + pendingEvents: false, + }); + } else { + timelineSet = new EventTimelineSet(this, { + pendingEvents: false, + }); + + Array.from(this.threads).forEach(([, thread]) => { + if (thread.length === 0) return; + const currentUserParticipated = thread.timeline.some((event) => { + return event.getSender() === this.client.getUserId(); + }); + if (filterType !== ThreadFilterType.My || currentUserParticipated) { + timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, { + toStartOfTimeline: false, + }); + } + }); + } + + return timelineSet; + } + + private threadsReady = false; + + /** + * Takes the given thread root events and creates threads for them. + */ + public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void { + for (const rootEvent of events) { + EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline); + if (!this.getThread(rootEvent.getId()!)) { + this.createThread(rootEvent.getId()!, rootEvent, [], toStartOfTimeline); + } + } + } + + /** + * Fetch the bare minimum of room threads required for the thread list to work reliably. + * With server support that means fetching one page. + * Without server support that means fetching as much at once as the server allows us to. + */ + public async fetchRoomThreads(): Promise<void> { + if (this.threadsReady || !this.client.supportsThreads()) { + return; + } + + if (Thread.hasServerSideListSupport) { + await Promise.all([ + this.fetchRoomThreadList(ThreadFilterType.All), + this.fetchRoomThreadList(ThreadFilterType.My), + ]); + } else { + const allThreadsFilter = await this.getThreadListFilter(); + + const { chunk: events } = await this.client.createMessagesRequest( + this.roomId, + "", + Number.MAX_SAFE_INTEGER, + Direction.Backward, + allThreadsFilter, + ); + + if (!events.length) return; + + // Sorted by last_reply origin_server_ts + const threadRoots = events.map(this.client.getEventMapper()).sort((eventA, eventB) => { + /** + * `origin_server_ts` in a decentralised world is far from ideal + * but for lack of any better, we will have to use this + * Long term the sorting should be handled by homeservers and this + * is only meant as a short term patch + */ + const threadAMetadata = eventA.getServerAggregatedRelation<IThreadBundledRelationship>( + THREAD_RELATION_TYPE.name, + )!; + const threadBMetadata = eventB.getServerAggregatedRelation<IThreadBundledRelationship>( + THREAD_RELATION_TYPE.name, + )!; + return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts; + }); + + let latestMyThreadsRootEvent: MatrixEvent | undefined; + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of threadRoots) { + const opts = { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, + roomState, + }; + this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts); + + const threadRelationship = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>( + THREAD_RELATION_TYPE.name, + ); + if (threadRelationship?.current_user_participated) { + this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, opts); + latestMyThreadsRootEvent = rootEvent; + } + } + + this.processThreadRoots(threadRoots, true); + + this.client.decryptEventIfNeeded(threadRoots[threadRoots.length - 1]); + if (latestMyThreadsRootEvent) { + this.client.decryptEventIfNeeded(latestMyThreadsRootEvent); + } + } + + this.on(ThreadEvent.NewReply, this.onThreadNewReply); + this.on(ThreadEvent.Delete, this.onThreadDelete); + this.threadsReady = true; + } + + public async processPollEvents(events: MatrixEvent[]): Promise<void> { + const processPollStartEvent = (event: MatrixEvent): void => { + if (!M_POLL_START.matches(event.getType())) return; + try { + const poll = new Poll(event, this.client, this); + this.polls.set(event.getId()!, poll); + this.emit(PollEvent.New, poll); + } catch {} + // poll creation can fail for malformed poll start events + }; + + const processPollRelationEvent = (event: MatrixEvent): void => { + const relationEventId = event.relationEventId; + if (relationEventId && this.polls.has(relationEventId)) { + const poll = this.polls.get(relationEventId); + poll?.onNewRelation(event); + } + }; + + const processPollEvent = (event: MatrixEvent): void => { + processPollStartEvent(event); + processPollRelationEvent(event); + }; + + for (const event of events) { + try { + await this.client.decryptEventIfNeeded(event); + processPollEvent(event); + } catch {} + } + } + + /** + * Fetch a single page of threadlist messages for the specific thread filter + * @internal + */ + private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> { + const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0]; + + const { chunk: events, end } = await this.client.createThreadListMessagesRequest( + this.roomId, + null, + undefined, + Direction.Backward, + timelineSet.threadListType, + timelineSet.getFilter(), + ); + + timelineSet.getLiveTimeline().setPaginationToken(end ?? null, Direction.Backward); + + if (!events.length) return; + + const matrixEvents = events.map(this.client.getEventMapper()); + this.processThreadRoots(matrixEvents, true); + const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + for (const rootEvent of matrixEvents) { + timelineSet.addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState, + }); + } + } + + private onThreadNewReply(thread: Thread): void { + this.updateThreadRootEvents(thread, false, true); + } + + private onThreadDelete(thread: Thread): void { + this.threads.delete(thread.id); + + const timeline = this.getTimelineForEvent(thread.id); + const roomEvent = timeline?.getEvents()?.find((it) => it.getId() === thread.id); + if (roomEvent) { + thread.clearEventMetadata(roomEvent); + } else { + logger.debug("onThreadDelete: Could not find root event in room timeline"); + } + for (const timelineSet of this.threadsTimelineSets) { + timelineSet.removeEvent(thread.id); + } + } + + /** + * Forget the timelineSet for this room with the given filter + * + * @param 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); + } + } + + public eventShouldLiveIn( + event: MatrixEvent, + events?: MatrixEvent[], + roots?: Set<string>, + ): { + shouldLiveInRoom: boolean; + shouldLiveInThread: boolean; + threadId?: string; + } { + if (!this.client?.supportsThreads()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + + // A thread root is always shown in both timelines + if (event.isThreadRoot || roots?.has(event.getId()!)) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.getId(), + }; + } + + // A thread relation is always only shown in a thread + if (event.isRelation(THREAD_RELATION_TYPE.name)) { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: event.threadRootId, + }; + } + + const parentEventId = event.getAssociatedId(); + let parentEvent: MatrixEvent | undefined; + if (parentEventId) { + parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId); + } + + // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead + if (parentEvent && (event.isRelation() || event.isRedaction())) { + return this.eventShouldLiveIn(parentEvent, events, roots); + } + + // Edge case where we know the event is a relation but don't have the parentEvent + if (roots?.has(event.relationEventId!)) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + + public findThreadForEvent(event?: MatrixEvent): Thread | null { + if (!event) return null; + + const { threadId } = this.eventShouldLiveIn(event); + return threadId ? this.getThread(threadId) : null; + } + + private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void { + let thread = this.getThread(threadId); + + if (!thread) { + const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId); + thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); + } + + thread.addEvents(events, toStartOfTimeline); + } + + /** + * Adds events to a thread's timeline. Will fire "Thread.update" + */ + public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { + events.forEach(this.applyRedaction); + + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + for (const event of events) { + const { threadId, shouldLiveInThread } = this.eventShouldLiveIn(event); + if (shouldLiveInThread && !eventsByThread[threadId!]) { + eventsByThread[threadId!] = []; + } + eventsByThread[threadId!]?.push(event); + } + + Object.entries(eventsByThread).map(([threadId, threadEvents]) => + this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline), + ); + } + + private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean, recreateEvent: boolean): void => { + if (thread.length) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent); + if (thread.hasCurrentUserParticipated) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent); + } + } + }; + + private updateThreadRootEvent = ( + timelineSet: Optional<EventTimelineSet>, + thread: Thread, + toStartOfTimeline: boolean, + recreateEvent: boolean, + ): void => { + if (timelineSet && thread.rootEvent) { + if (recreateEvent) { + timelineSet.removeEvent(thread.id); + } + if (Thread.hasServerSideSupport) { + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState: this.currentState, + }); + } else { + timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { toStartOfTimeline }); + } + } + }; + + public createThread( + threadId: string, + rootEvent: MatrixEvent | undefined, + events: MatrixEvent[] = [], + toStartOfTimeline: boolean, + ): Thread { + if (this.threads.has(threadId)) { + return this.threads.get(threadId)!; + } + + if (rootEvent) { + const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId()!); + if (relatedEvents?.length) { + // Include all relations of the root event, given it'll be visible in both timelines, + // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced` + events = events.concat(relatedEvents.filter((e) => !e.isRelation(RelationType.Replace))); + } + } + + const thread = new Thread(threadId, rootEvent, { + room: this, + client: this.client, + pendingEventOrdering: this.opts.pendingEventOrdering, + receipts: this.cachedThreadReadReceipts.get(threadId) ?? [], + }); + + // All read receipts should now come down from sync, we do not need to keep + // a reference to the cached receipts anymore. + this.cachedThreadReadReceipts.delete(threadId); + + // If we managed to create a thread and figure out its `id` then we can use it + // This has to happen before thread.addEvents, because that adds events to the eventtimeline, and the + // eventtimeline sometimes looks up thread information via the room. + this.threads.set(thread.id, thread); + + // This is necessary to be able to jump to events in threads: + // If we jump to an event in a thread where neither the event, nor the root, + // nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread, + // and pass the event through this. + thread.addEvents(events, false); + + this.reEmitter.reEmit(thread, [ + ThreadEvent.Delete, + ThreadEvent.Update, + ThreadEvent.NewReply, + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); + const isNewer = + this.lastThread?.rootEvent && + rootEvent?.localTimestamp && + this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp; + + if (!this.lastThread || isNewer) { + this.lastThread = thread; + } + + if (this.threadsReady) { + this.updateThreadRootEvents(thread, toStartOfTimeline, false); + } + this.emit(ThreadEvent.New, thread, toStartOfTimeline); + + return thread; + } + + private applyRedaction = (event: MatrixEvent): void => { + if (event.isRedaction()) { + const redactId = event.event.redacts; + + // if we know about this event, redact its contents now. + const redactedEvent = redactId ? this.findEventById(redactId) : undefined; + if (redactedEvent) { + redactedEvent.makeRedacted(event); + + // If this is in the current state, replace it with the redacted version + if (redactedEvent.isState()) { + const currentStateEvent = this.currentState.getStateEvents( + redactedEvent.getType(), + redactedEvent.getStateKey()!, + ); + if (currentStateEvent?.getId() === redactedEvent.getId()) { + this.currentState.setStateEvents([redactedEvent]); + } + } + + this.emit(RoomEvent.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. + + // Remove any visibility change on this event. + this.visibilityEvents.delete(redactId!); + + // If this event is a visibility change event, remove it from the + // list of visibility changes and update any event affected by it. + if (redactedEvent.isVisibilityEvent()) { + this.redactVisibilityChangeEvent(event); + } + } + + // 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. + } + }; + + private processLiveEvent(event: MatrixEvent): void { + this.applyRedaction(event); + + // Implement MSC3531: hiding messages. + if (event.isVisibilityEvent()) { + // This event changes the visibility of another event, record + // the visibility change, inform clients if necessary. + this.applyNewVisibilityEvent(event); + } + // If any pending visibility change is waiting for this (older) event, + this.applyPendingVisibilityEvents(event); + + // Sliding Sync modifications: + // The proxy cannot guarantee every sent event will have a transaction_id field, so we need + // to check the event ID against the list of pending events if there is no transaction ID + // field. Only do this for events sent by us though as it's potentially expensive to loop + // the pending events map. + const txnId = event.getUnsigned().transaction_id; + if (!txnId && event.getSender() === this.myUserId) { + // check the txn map for a matching event ID + for (const [tid, localEvent] of this.txnToEvent) { + if (localEvent.getId() === event.getId()) { + logger.debug("processLiveEvent: found sent event without txn ID: ", tid, event.getId()); + // update the unsigned field so we can re-use the same codepaths + const unsigned = event.getUnsigned(); + unsigned.transaction_id = tid; + event.setUnsigned(unsigned); + break; + } + } + } + } + + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param event - Event to be added + * @param addLiveEventOptions - addLiveEvent options + * @internal + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void { + const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions; + + // add to our timeline sets + for (const timelineSet of this.timelineSets) { + timelineSet.addLiveEvent(event, { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }); + } + + // 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, ReceiptType.Read), true); + + // Any live events from a user could be taken as implicit + // presence information: evidence that they are currently active. + // ...except in a world where we use 'user.currentlyActive' to reduce + // presence spam, this isn't very useful - we'll get a transition when + // they are no longer currently active anyway. So don't bother to + // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. + } + } + + /** + * Add a pending outgoing event to this room. + * + * <p>The event is added to either the pendingEventList, or the live timeline, + * depending on the setting of opts.pendingEventOrdering. + * + * <p>This is an internal method, intended for use by MatrixClient. + * + * @param event - The event to add. + * + * @param txnId - Transaction id for this outgoing event + * + * @throws if the event doesn't have status SENDING, or we aren't given a + * unique transaction id. + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} + */ + 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.get(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.set(txnId, event); + if (this.pendingEventList) { + 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.find((e) => e.getId() === redactId); + if (!redactedEvent && redactId) { + redactedEvent = this.findEventById(redactId); + } + if (redactedEvent) { + redactedEvent.markLocallyRedacted(event); + this.emit(RoomEvent.Redaction, event, this); + } + } + } else { + for (const timelineSet of this.timelineSets) { + if (timelineSet.getFilter()) { + if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); + } + } else { + timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); + } + } + } + + this.emit(RoomEvent.LocalEchoUpdated, event, this); + } + + /** + * 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; + }); + + this.client.store.setPendingEvents(this.roomId, pendingEvents); + } + } + + /** + * 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 event - the relation event that needs to be aggregated. + */ + private aggregateNonLiveRelation(event: MatrixEvent): void { + this.relations.aggregateChildEvent(event); + } + + public getEventForTxnId(txnId: string): MatrixEvent | undefined { + return this.txnToEvent.get(txnId); + } + + /** + * Deal with the echo of a message we sent. + * + * <p>We move the event to the live timeline if it isn't there already, and + * update it. + * + * @param remoteEvent - The event received from + * /sync + * @param localEvent - The local echo, which + * should be either in the pendingEventList or the timeline. + * + * @internal + * + * @remarks + * Fires {@link RoomEvent.LocalEchoUpdated} + */ + public 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 + this.txnToEvent.delete(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); + + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent); + const thread = threadId ? this.getThread(threadId) : null; + thread?.setEventMetadata(localEvent); + thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + + if (shouldLiveInRoom) { + for (const timelineSet of this.timelineSets) { + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + } + } + + this.emit(RoomEvent.LocalEchoUpdated, localEvent, this, oldEventId, oldStatus); + } + + /** + * Update the status / event id on a pending event, to reflect its transmission + * progress. + * + * <p>This is an internal method. + * + * @param event - local echo event + * @param newStatus - status to assign + * @param newEventId - new event id to assign. Ignored unless newStatus == EventStatus.SENT. + * + * @remarks + * Fires {@link RoomEvent.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.getTimelineForEvent(newEventId!); + if (timeline) { + // we've already received the event via the event stream. + // nothing more to do here, assuming the transaction ID was correctly matched. + // Let's check that. + const remoteEvent = this.findEventById(newEventId!); + const remoteTxnId = remoteEvent?.getUnsigned().transaction_id; + if (!remoteTxnId && remoteEvent) { + // This code path is mostly relevant for the Sliding Sync proxy. + // The remote event did not contain a transaction ID, so we did not handle + // the remote echo yet. Handle it now. + const unsigned = remoteEvent.getUnsigned(); + unsigned.transaction_id = event.getTxnId(); + remoteEvent.setUnsigned(unsigned); + // the remote event is _already_ in the timeline, so we need to remove it so + // we can convert the local event into the final event. + this.removeEvent(remoteEvent.getId()!); + this.handleRemoteEcho(remoteEvent, event); + } + 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?.includes(newStatus)) { + throw new Error(`Invalid EventStatus transition ${oldStatus}->${newStatus}`); + } + + event.setStatus(newStatus); + + if (newStatus == EventStatus.SENT) { + // update the event id + event.replaceLocalEventId(newEventId!); + + const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event); + const thread = threadId ? this.getThread(threadId) : undefined; + thread?.setEventMetadata(event); + thread?.timelineSet.replaceEventId(oldEventId, newEventId!); + + if (shouldLiveInRoom) { + // 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 (const timelineSet of this.timelineSets) { + timelineSet.replaceEventId(oldEventId, newEventId!); + } + } + } else if (newStatus == EventStatus.CANCELLED) { + // remove it from the pending event list, or the timeline. + if (this.pendingEventList) { + const removedEvent = this.getPendingEvent(oldEventId); + this.removePendingEvent(oldEventId); + if (removedEvent?.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); + } + } + this.removeEvent(oldEventId); + } + this.savePendingEvents(); + + this.emit(RoomEvent.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(RoomEvent.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 events - A list of events to add. + * @param addLiveEventOptions - addLiveEvent options + * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'. + */ + public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void; + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void; + public addLiveEvents( + events: MatrixEvent[], + duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, + fromCache = false, + ): void { + let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy; + let timelineWasEmpty: boolean | undefined = false; + if (typeof duplicateStrategyOrOpts === "object") { + ({ + duplicateStrategy, + fromCache = false, + /* roomState, (not used here) */ + timelineWasEmpty, + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + "Overload deprecated: " + + "`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` " + + "is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`", + ); + } + + 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 (let 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`); + } + } + + const threadRoots = this.findThreadRoots(events); + const eventsByThread: { [threadId: string]: MatrixEvent[] } = {}; + + const options: IAddLiveEventOptions = { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }; + + for (const event of events) { + // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". + this.processLiveEvent(event); + + if (event.getUnsigned().transaction_id) { + const existingEvent = this.txnToEvent.get(event.getUnsigned().transaction_id!); + if (existingEvent) { + // remote echo of an event we sent earlier + this.handleRemoteEcho(event, existingEvent); + continue; // we can skip adding the event to the timeline sets, it is already there + } + } + + const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + event, + events, + threadRoots, + ); + + if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { + eventsByThread[threadId ?? ""] = []; + } + eventsByThread[threadId ?? ""]?.push(event); + + if (shouldLiveInRoom) { + this.addLiveEvent(event, options); + } + } + + Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => { + this.addThreadedEvents(threadId, threadEvents, false); + }); + } + + public partitionThreadedEvents( + events: MatrixEvent[], + ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] { + // Indices to the events array, for readability + const ROOM = 0; + const THREAD = 1; + if (this.client.supportsThreads()) { + const threadRoots = this.findThreadRoots(events); + return events.reduce( + (memo, event: MatrixEvent) => { + const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + event, + events, + threadRoots, + ); + + if (shouldLiveInRoom) { + memo[ROOM].push(event); + } + + if (shouldLiveInThread) { + event.setThreadId(threadId ?? ""); + memo[THREAD].push(event); + } + + return memo; + }, + [[] as MatrixEvent[], [] as MatrixEvent[]], + ); + } else { + // When `experimentalThreadSupport` is disabled treat all events as timelineEvents + return [events as MatrixEvent[], [] as MatrixEvent[]]; + } + } + + /** + * Given some events, find the IDs of all the thread roots that are referred to by them. + */ + private findThreadRoots(events: MatrixEvent[]): Set<string> { + const threadRoots = new Set<string>(); + for (const event of events) { + if (event.isRelation(THREAD_RELATION_TYPE.name)) { + threadRoots.add(event.relationEventId ?? ""); + } + } + return threadRoots; + } + + /** + * Add a receipt event to the room. + * @param event - The m.receipt event. + * @param synthetic - True if this event is implicit. + */ + public addReceipt(event: MatrixEvent, synthetic = false): void { + const content = event.getContent<ReceiptContent>(); + Object.keys(content).forEach((eventId: string) => { + Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => { + Object.keys(content[eventId][receiptType]).forEach((userId: string) => { + const receipt = content[eventId][receiptType][userId] as Receipt; + const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE; + const receiptDestination: Thread | this | undefined = receiptForMainTimeline + ? this + : this.threads.get(receipt.thread_id ?? ""); + + if (receiptDestination) { + receiptDestination.addReceiptToStructure( + eventId, + receiptType as ReceiptType, + userId, + receipt, + synthetic, + ); + + // If the read receipt sent for the logged in user matches + // the last event of the live timeline, then we know for a fact + // that the user has read that message. + // We can mark the room as read and not wait for the local echo + // from synapse + // This needs to be done after the initial sync as we do not want this + // logic to run whilst the room is being initialised + if (this.client.isInitialSyncComplete() && userId === this.client.getUserId()) { + const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1]; + if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) { + receiptDestination.setUnread(NotificationCountType.Total, 0); + receiptDestination.setUnread(NotificationCountType.Highlight, 0); + } + } + } else { + // The thread does not exist locally, keep the read receipt + // in a cache locally, and re-apply the `addReceipt` logic + // when the thread is created + this.cachedThreadReadReceipts.set(receipt.thread_id!, [ + ...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []), + { eventId, receiptType, userId, receipt, synthetic }, + ]); + } + + const me = this.client.getUserId(); + // Track the time of the current user's oldest threaded receipt in the room. + if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) { + this.oldestThreadedReceiptTs = receipt.ts; + } + + // Track each user's unthreaded read receipt. + if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) { + this.unthreadedReceipts.set(userId, receipt); + } + }); + }); + }); + + // send events after we've regenerated the structure & cache, otherwise things that + // listened for the event would read stale data. + this.emit(RoomEvent.Receipt, event, this); + } + + /** + * Adds/handles ephemeral events such as typing notifications and read receipts. + * @param events - A list of events to process + */ + public addEphemeralEvents(events: MatrixEvent[]): void { + for (const event of events) { + if (event.getType() === EventType.Typing) { + this.currentState.setTypingEvent(event); + } else if (event.getType() === EventType.Receipt) { + this.addReceipt(event); + } // else ignore - life is too short for us to care about these events + } + } + + /** + * Removes events from this room. + * @param eventIds - A list of eventIds to remove. + */ + public removeEvents(eventIds: string[]): void { + for (const eventId of eventIds) { + this.removeEvent(eventId); + } + } + + /** + * Removes a single event from this room. + * + * @param eventId - The id of the event to remove + * + * @returns true if the event was removed from any of the room's timeline sets + */ + public removeEvent(eventId: string): boolean { + let removedAny = false; + for (const timelineSet of this.timelineSets) { + const removed = timelineSet.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. + * + * @remarks + * Fires {@link RoomEvent.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) { + const membership = membershipEvent.getContent().membership; + this.updateMyMembership(membership!); + + if (membership === "invite") { + const strippedStateEvents = membershipEvent.getUnsigned().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(RoomEvent.Name, this); + } + } + + /** + * Update the room-tag event for the room. The previous one is overwritten. + * @param 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(RoomEvent.Tags, event, this); + } + + /** + * Update the account_data events for this room, overwriting events of the same type. + * @param events - an array of account_data events to add + */ + public addAccountData(events: MatrixEvent[]): void { + for (const event of events) { + if (event.getType() === "m.tag") { + this.addTags(event); + } + const eventType = event.getType(); + const lastEvent = this.accountData.get(eventType); + this.accountData.set(eventType, event); + this.emit(RoomEvent.AccountData, event, this, lastEvent); + } + } + + /** + * Access account_data event of given event type for this room + * @param type - the type of account_data event to be accessed + * @returns the account_data event in question + */ + public getAccountData(type: EventType | string): MatrixEvent | undefined { + return this.accountData.get(type); + } + + /** + * Returns whether the syncing user has permission to send a message in the room + * @returns true if the user should be permitted to send + * message events into the room. + */ + public maySendMessage(): boolean { + return ( + this.getMyMembership() === "join" && + (this.client.isRoomEncrypted(this.roomId) + ? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId) + : this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId)) + ); + } + + /** + * Returns whether the given user has permissions to issue an invite for this room. + * @param userId - the ID of the Matrix user to check permissions for + * @returns 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 the join_rule applied to this room + */ + public getJoinRule(): JoinRule { + return this.currentState.getJoinRule(); + } + + /** + * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. + * @returns the history_visibility applied to this room + */ + public getHistoryVisibility(): HistoryVisibility { + return this.currentState.getHistoryVisibility(); + } + + /** + * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`. + * @returns the history_visibility applied to this room + */ + public getGuestAccess(): GuestAccess { + return this.currentState.getGuestAccess(); + } + + /** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns the type of the room. + */ + 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 true if the room's type is RoomType.Space + */ + public isSpaceRoom(): boolean { + return this.getType() === RoomType.Space; + } + + /** + * Returns whether the room is a call-room as defined by MSC3417. + * @returns true if the room's type is RoomType.UnstableCall + */ + public isCallRoom(): boolean { + return this.getType() === RoomType.UnstableCall; + } + + /** + * Returns whether the room is a video room. + * @returns true if the room's type is RoomType.ElementVideo + */ + public isElementVideoRoom(): boolean { + return this.getType() === RoomType.ElementVideo; + } + + /** + * Find the predecessor of this room. + * + * @param msc3946ProcessDynamicPredecessor - if true, look for an + * m.room.predecessor state event and use it if found (MSC3946). + * @returns null if this room has no predecessor. Otherwise, returns + * the roomId, last eventId and viaServers of the predecessor room. + * + * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events + * as well as m.room.create events to find predecessors. + * + * Note: if an m.predecessor event is used, eventId may be undefined + * since last_known_event_id is optional. + * + * Note: viaServers may be undefined, and will definitely be undefined if + * this predecessor comes from a RoomCreate event (rather than a + * RoomPredecessor, which has the optional via_servers property). + */ + public findPredecessor( + msc3946ProcessDynamicPredecessor = false, + ): { roomId: string; eventId?: string; viaServers?: string[] } | null { + const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + if (!currentState) { + return null; + } + return currentState.findPredecessor(msc3946ProcessDynamicPredecessor); + } + + private roomNameGenerator(state: RoomNameState): string { + if (this.client.roomNameGenerator) { + const name = this.client.roomNameGenerator(this.roomId, state); + if (name !== null) { + return name; + } + } + + switch (state.type) { + case RoomNameType.Actual: + return state.name; + case RoomNameType.Generated: + switch (state.subtype) { + case "Inviting": + return `Inviting ${memberNamesToRoomName(state.names, state.count)}`; + default: + return memberNamesToRoomName(state.names, state.count); + } + case RoomNameType.EmptyRoom: + if (state.oldName) { + return `Empty room (was ${state.oldName})`; + } else { + return "Empty room"; + } + } + } + + /** + * This is an internal method. Calculates the name of the room from the current + * room state. + * @param userId - The client's user ID. Used to filter room members + * correctly. + * @param ignoreRoomNameEvent - Return the implicit room name that we'd see if there + * was no m.room.name event. + * @returns 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?.getContent().name) { + return this.roomNameGenerator({ + type: RoomNameType.Actual, + name: mRoomName.getContent().name, + }); + } + } + + const alias = this.getCanonicalAlias(); + if (alias) { + return this.roomNameGenerator({ + type: RoomNameType.Actual, + name: alias, + }); + } + + const joinedMemberCount = this.currentState.getJoinedMemberCount(); + const invitedMemberCount = this.currentState.getInvitedMemberCount(); + // -1 because these numbers include the syncing user + let inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get service members (e.g. helper bots) for exclusion + let excludedUserIds: string[] = []; + const mFunctionalMembers = this.currentState.getStateEvents(UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, ""); + if (Array.isArray(mFunctionalMembers?.getContent().service_members)) { + excludedUserIds = mFunctionalMembers!.getContent().service_members; + } + + // get members that are NOT ourselves and are actually in the room. + let otherNames: string[] = []; + if (this.summaryHeroes) { + // if we have a summary, the member state events should be in the room state + this.summaryHeroes.forEach((userId) => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return; + } + const member = this.getMember(userId); + otherNames.push(member ? member.name : userId); + }); + } else { + let otherMembers = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && (m.membership === "invite" || m.membership === "join"); + }); + otherMembers = otherMembers.filter(({ userId }) => { + // filter service members + if (excludedUserIds.includes(userId)) { + inviteJoinCount--; + return false; + } + return true; + }); + // make sure members have stable order + otherMembers.sort((a, b) => utils.compare(a.userId, b.userId)); + // only 5 first members, immitate summaryHeroes + otherMembers = otherMembers.slice(0, 5); + otherNames = otherMembers.map((m) => m.name); + } + + if (inviteJoinCount) { + return this.roomNameGenerator({ + type: RoomNameType.Generated, + names: otherNames, + count: inviteJoinCount, + }); + } + + const myMembership = this.getMyMembership(); + // if I have created a room and invited people through + // 3rd party invites + if (myMembership == "join") { + const thirdPartyInvites = this.currentState.getStateEvents(EventType.RoomThirdPartyInvite); + + if (thirdPartyInvites?.length) { + const thirdPartyNames = thirdPartyInvites.map((i) => { + return i.getContent().display_name; + }); + + return this.roomNameGenerator({ + type: RoomNameType.Generated, + subtype: "Inviting", + names: thirdPartyNames, + count: thirdPartyNames.length + 1, + }); + } + } + + // 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); + } + + let oldName: string | undefined; + if (leftNames.length) { + oldName = this.roomNameGenerator({ + type: RoomNameType.Generated, + names: leftNames, + count: leftNames.length + 1, + }); + } + + return this.roomNameGenerator({ + type: RoomNameType.EmptyRoom, + oldName, + }); + } + + /** + * When we receive a new visibility change event: + * + * - store this visibility change alongside the timeline, in case we + * later need to apply it to an event that we haven't received yet; + * - if we have already received the event whose visibility has changed, + * patch it to reflect the visibility change and inform listeners. + */ + private applyNewVisibilityEvent(event: MatrixEvent): void { + const visibilityChange = event.asVisibilityChange(); + if (!visibilityChange) { + // The event is ill-formed. + return; + } + + // Ignore visibility change events that are not emitted by moderators. + const userId = event.getSender(); + if (!userId) { + return; + } + const isPowerSufficient = + (EVENT_VISIBILITY_CHANGE_TYPE.name && + this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId)) || + (EVENT_VISIBILITY_CHANGE_TYPE.altName && + this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId)); + if (!isPowerSufficient) { + // Powerlevel is insufficient. + return; + } + + // Record this change in visibility. + // If the event is not in our timeline and we only receive it later, + // we may need to apply the visibility change at a later date. + + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(visibilityChange.eventId); + if (visibilityEventsOnOriginalEvent) { + // It would be tempting to simply erase the latest visibility change + // but we need to record all of the changes in case the latest change + // is ever redacted. + // + // In practice, linear scans through `visibilityEvents` should be fast. + // However, to protect against a potential DoS attack, we limit the + // number of iterations in this loop. + let index = visibilityEventsOnOriginalEvent.length - 1; + const min = Math.max( + 0, + visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH, + ); + for (; index >= min; --index) { + const target = visibilityEventsOnOriginalEvent[index]; + if (target.getTs() < event.getTs()) { + break; + } + } + if (index === -1) { + visibilityEventsOnOriginalEvent.unshift(event); + } else { + visibilityEventsOnOriginalEvent.splice(index + 1, 0, event); + } + } else { + this.visibilityEvents.set(visibilityChange.eventId, [event]); + } + + // Finally, let's check if the event is already in our timeline. + // If so, we need to patch it and inform listeners. + + const originalEvent = this.findEventById(visibilityChange.eventId); + if (!originalEvent) { + return; + } + originalEvent.applyVisibilityEvent(visibilityChange); + } + + private redactVisibilityChangeEvent(event: MatrixEvent): void { + // Sanity checks. + if (!event.isVisibilityEvent) { + throw new Error("expected a visibility change event"); + } + const relation = event.getRelation(); + const originalEventId = relation?.event_id; + const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(originalEventId!); + if (!visibilityEventsOnOriginalEvent) { + // No visibility changes on the original event. + // In particular, this change event was not recorded, + // most likely because it was ill-formed. + return; + } + const index = visibilityEventsOnOriginalEvent.findIndex((change) => change.getId() === event.getId()); + if (index === -1) { + // This change event was not recorded, most likely because + // it was ill-formed. + return; + } + // Remove visibility change. + visibilityEventsOnOriginalEvent.splice(index, 1); + + // If we removed the latest visibility change event, propagate changes. + if (index === visibilityEventsOnOriginalEvent.length) { + const originalEvent = this.findEventById(originalEventId!); + if (!originalEvent) { + return; + } + if (index === 0) { + // We have just removed the only visibility change event. + this.visibilityEvents.delete(originalEventId!); + originalEvent.applyVisibilityEvent(); + } else { + const newEvent = visibilityEventsOnOriginalEvent[visibilityEventsOnOriginalEvent.length - 1]; + const newVisibility = newEvent.asVisibilityChange(); + if (!newVisibility) { + // Event is ill-formed. + // This breaks our invariant. + throw new Error("at this stage, visibility changes should be well-formed"); + } + originalEvent.applyVisibilityEvent(newVisibility); + } + } + } + + /** + * When we receive an event whose visibility has been altered by + * a (more recent) visibility change event, patch the event in + * place so that clients now not to display it. + * + * @param event - Any matrix event. If this event has at least one a + * pending visibility change event, apply the latest visibility + * change event. + */ + private applyPendingVisibilityEvents(event: MatrixEvent): void { + const visibilityEvents = this.visibilityEvents.get(event.getId()!); + if (!visibilityEvents || visibilityEvents.length == 0) { + // No pending visibility change in store. + return; + } + const visibilityEvent = visibilityEvents[visibilityEvents.length - 1]; + const visibilityChange = visibilityEvent.asVisibilityChange(); + if (!visibilityChange) { + return; + } + if (visibilityChange.visible) { + // Events are visible by default, no need to apply a visibility change. + // Note that we need to keep the visibility changes in `visibilityEvents`, + // in case we later fetch an older visibility change event that is superseded + // by `visibilityChange`. + } + if (visibilityEvent.getTs() < event.getTs()) { + // Something is wrong, the visibility change cannot happen before the + // event. Presumably an ill-formed event. + return; + } + event.applyVisibilityEvent(visibilityChange); + } + + /** + * Find when a client has gained thread capabilities by inspecting the oldest + * threaded receipt + * @returns the timestamp of the oldest threaded receipt + */ + public getOldestThreadedReceiptTs(): number { + return this.oldestThreadedReceiptTs; + } + + /** + * Returns the most recent unthreaded receipt for a given user + * @param userId - the MxID of the User + * @returns an unthreaded Receipt. Can be undefined if receipts have been disabled + * or a user chooses to use private read receipts (or we have simply not received + * a receipt from this user yet). + */ + public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined { + return this.unthreadedReceipts.get(userId); + } + + /** + * This issue should also be addressed on synapse's side and is tracked as part + * of https://github.com/matrix-org/synapse/issues/14837 + * + * + * We consider a room fully read if the current user has sent + * the last event in the live timeline of that context and if the read receipt + * we have on record matches. + * This also detects all unread threads and applies the same logic to those + * contexts + */ + public fixupNotifications(userId: string): void { + super.fixupNotifications(userId); + + const unreadThreads = this.getThreads().filter( + (thread) => this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0, + ); + + for (const thread of unreadThreads) { + thread.fixupNotifications(userId); + } + } +} + +// a map from current event status to a list of allowed next statuses +const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = { + [EventStatus.ENCRYPTING]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED], + [EventStatus.SENDING]: [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT], + [EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED], + [EventStatus.SENT]: [], + [EventStatus.NOT_SENT]: [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED], + [EventStatus.CANCELLED]: [], +}; + +export enum RoomNameType { + EmptyRoom, + Generated, + Actual, +} + +export interface EmptyRoomNameState { + type: RoomNameType.EmptyRoom; + oldName?: string; +} + +export interface GeneratedRoomNameState { + type: RoomNameType.Generated; + subtype?: "Inviting"; + names: string[]; + count: number; +} + +export interface ActualRoomNameState { + type: RoomNameType.Actual; + name: string; +} + +export type RoomNameState = EmptyRoomNameState | GeneratedRoomNameState | ActualRoomNameState; + +// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn +function memberNamesToRoomName(names: string[], count: number): string { + 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`; + } + } +} |