diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts')
-rw-r--r-- | includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts | 1027 |
1 files changed, 1027 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts new file mode 100644 index 0000000..93e29e0 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts @@ -0,0 +1,1027 @@ +/* +Copyright 2022 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 type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend"; +import { NotificationCountType, Room, RoomEvent } from "./models/room"; +import { logger } from "./logger"; +import * as utils from "./utils"; +import { EventTimeline } from "./models/event-timeline"; +import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client"; +import { + ISyncStateData, + SyncState, + _createAndReEmitRoom, + SyncApiOptions, + defaultClientOpts, + defaultSyncApiOpts, +} from "./sync"; +import { MatrixEvent } from "./models/event"; +import { Crypto } from "./crypto"; +import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator"; +import { MatrixError } from "./http-api"; +import { + Extension, + ExtensionState, + MSC3575RoomData, + MSC3575SlidingSyncResponse, + SlidingSync, + SlidingSyncEvent, + SlidingSyncState, +} from "./sliding-sync"; +import { EventType } from "./@types/event"; +import { IPushRules } from "./@types/PushRules"; +import { RoomStateEvent } from "./models/room-state"; +import { RoomMemberEvent } from "./models/room-member"; + +// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed +// to RECONNECTING. This is needed to inform the client of server issues when the +// keepAlive is successful but the server /sync fails. +const FAILED_SYNC_ERROR_THRESHOLD = 3; + +type ExtensionE2EERequest = { + enabled: boolean; +}; + +type ExtensionE2EEResponse = Pick< + ISyncResponse, + | "device_lists" + | "device_one_time_keys_count" + | "device_unused_fallback_key_types" + | "org.matrix.msc2732.device_unused_fallback_key_types" +>; + +class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResponse> { + public constructor(private readonly crypto: Crypto) {} + + public name(): string { + return "e2ee"; + } + + public when(): ExtensionState { + return ExtensionState.PreProcess; + } + + public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined { + if (!isInitial) { + return undefined; + } + return { + enabled: true, // this is sticky so only send it on the initial request + }; + } + + public async onResponse(data: ExtensionE2EEResponse): Promise<void> { + // Handle device list updates + if (data["device_lists"]) { + await this.crypto.handleDeviceListChanges( + { + oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :( + }, + data["device_lists"], + ); + } + + // Handle one_time_keys_count + if (data["device_one_time_keys_count"]) { + const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0; + this.crypto.updateOneTimeKeyCount(currentCount); + } + if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) { + // The presence of device_unused_fallback_key_types indicates that the + // server supports fallback keys. If there's no unused + // signed_curve25519 fallback key we need a new one. + const unusedFallbackKeys = + data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]; + this.crypto.setNeedsNewFallback( + Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"), + ); + } + this.crypto.onSyncCompleted({}); + } +} + +type ExtensionToDeviceRequest = { + since?: string; + limit?: number; + enabled?: boolean; +}; + +type ExtensionToDeviceResponse = { + events: Required<ISyncResponse>["to_device"]["events"]; + next_batch: string | null; +}; + +class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> { + private nextBatch: string | null = null; + + public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {} + + public name(): string { + return "to_device"; + } + + public when(): ExtensionState { + return ExtensionState.PreProcess; + } + + public onRequest(isInitial: boolean): ExtensionToDeviceRequest { + const extReq: ExtensionToDeviceRequest = { + since: this.nextBatch !== null ? this.nextBatch : undefined, + }; + if (isInitial) { + extReq["limit"] = 100; + extReq["enabled"] = true; + } + return extReq; + } + + public async onResponse(data: ExtensionToDeviceResponse): Promise<void> { + const cancelledKeyVerificationTxns: string[] = []; + let events = data["events"] || []; + if (events.length > 0 && this.cryptoCallbacks) { + events = await this.cryptoCallbacks.preprocessToDeviceMessages(events); + } + events + .map(this.client.getEventMapper()) + .map((toDeviceEvent) => { + // map is a cheap inline forEach + // We want to flag m.key.verification.start events as cancelled + // if there's an accompanying m.key.verification.cancel event, so + // we pull out the transaction IDs from the cancellation events + // so we can flag the verification events as cancelled in the loop + // below. + if (toDeviceEvent.getType() === "m.key.verification.cancel") { + const txnId: string | undefined = toDeviceEvent.getContent()["transaction_id"]; + if (txnId) { + cancelledKeyVerificationTxns.push(txnId); + } + } + + // as mentioned above, .map is a cheap inline forEach, so return + // the unmodified event. + return toDeviceEvent; + }) + .forEach((toDeviceEvent) => { + const content = toDeviceEvent.getContent(); + if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { + // the mapper already logged a warning. + logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); + return; + } + + if ( + toDeviceEvent.getType() === "m.key.verification.start" || + toDeviceEvent.getType() === "m.key.verification.request" + ) { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); + } + } + + this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); + }); + + this.nextBatch = data.next_batch; + } +} + +type ExtensionAccountDataRequest = { + enabled: boolean; +}; + +type ExtensionAccountDataResponse = { + global: IMinimalEvent[]; + rooms: Record<string, IMinimalEvent[]>; +}; + +class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, ExtensionAccountDataResponse> { + public constructor(private readonly client: MatrixClient) {} + + public name(): string { + return "account_data"; + } + + public when(): ExtensionState { + return ExtensionState.PostProcess; + } + + public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined { + if (!isInitial) { + return undefined; + } + return { + enabled: true, + }; + } + + public onResponse(data: ExtensionAccountDataResponse): void { + if (data.global && data.global.length > 0) { + this.processGlobalAccountData(data.global); + } + + for (const roomId in data.rooms) { + const accountDataEvents = mapEvents(this.client, roomId, data.rooms[roomId]); + const room = this.client.getRoom(roomId); + if (!room) { + logger.warn("got account data for room but room doesn't exist on client:", roomId); + continue; + } + room.addAccountData(accountDataEvents); + accountDataEvents.forEach((e) => { + this.client.emit(ClientEvent.Event, e); + }); + } + } + + private processGlobalAccountData(globalAccountData: IMinimalEvent[]): void { + const events = mapEvents(this.client, undefined, globalAccountData); + const prevEventsMap = events.reduce<Record<string, MatrixEvent | undefined>>((m, c) => { + m[c.getType()] = this.client.store.getAccountData(c.getType()); + return m; + }, {}); + this.client.store.storeAccountDataEvents(events); + events.forEach((accountDataEvent) => { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === EventType.PushRules) { + const rules = accountDataEvent.getContent<IPushRules>(); + this.client.setPushRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getType()]; + this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent); + return accountDataEvent; + }); + } +} + +type ExtensionTypingRequest = { + enabled: boolean; +}; + +type ExtensionTypingResponse = { + rooms: Record<string, IMinimalEvent>; +}; + +class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypingResponse> { + public constructor(private readonly client: MatrixClient) {} + + public name(): string { + return "typing"; + } + + public when(): ExtensionState { + return ExtensionState.PostProcess; + } + + public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined { + if (!isInitial) { + return undefined; // don't send a JSON object for subsequent requests, we don't need to. + } + return { + enabled: true, + }; + } + + public onResponse(data: ExtensionTypingResponse): void { + if (!data?.rooms) { + return; + } + + for (const roomId in data.rooms) { + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); + } + } +} + +type ExtensionReceiptsRequest = { + enabled: boolean; +}; + +type ExtensionReceiptsResponse = { + rooms: Record<string, IMinimalEvent>; +}; + +class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, ExtensionReceiptsResponse> { + public constructor(private readonly client: MatrixClient) {} + + public name(): string { + return "receipts"; + } + + public when(): ExtensionState { + return ExtensionState.PostProcess; + } + + public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined { + if (isInitial) { + return { + enabled: true, + }; + } + return undefined; // don't send a JSON object for subsequent requests, we don't need to. + } + + public onResponse(data: ExtensionReceiptsResponse): void { + if (!data?.rooms) { + return; + } + + for (const roomId in data.rooms) { + processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]); + } + } +} + +/** + * A copy of SyncApi such that it can be used as a drop-in replacement for sync v2. For the actual + * sliding sync API, see sliding-sync.ts or the class SlidingSync. + */ +export class SlidingSyncSdk { + private readonly opts: IStoredClientOpts; + private readonly syncOpts: SyncApiOptions; + private syncState: SyncState | null = null; + private syncStateData?: ISyncStateData; + private lastPos: string | null = null; + private failCount = 0; + private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response + + public constructor( + private readonly slidingSync: SlidingSync, + private readonly client: MatrixClient, + opts?: IStoredClientOpts, + syncOpts?: SyncApiOptions, + ) { + this.opts = defaultClientOpts(opts); + this.syncOpts = defaultSyncApiOpts(syncOpts); + + if (client.getNotifTimelineSet()) { + client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]); + } + + this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this)); + this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this)); + const extensions: Extension<any, any>[] = [ + new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks), + new ExtensionAccountData(this.client), + new ExtensionTyping(this.client), + new ExtensionReceipts(this.client), + ]; + if (this.syncOpts.crypto) { + extensions.push(new ExtensionE2EE(this.syncOpts.crypto)); + } + extensions.forEach((ext) => { + this.slidingSync.registerExtension(ext); + }); + } + + private onRoomData(roomId: string, roomData: MSC3575RoomData): void { + let room = this.client.store.getRoom(roomId); + if (!room) { + if (!roomData.initial) { + logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData); + return; + } + room = _createAndReEmitRoom(this.client, roomId, this.opts); + } + this.processRoomData(this.client, room, roomData); + } + + private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void { + if (err) { + logger.debug("onLifecycle", state, err); + } + switch (state) { + case SlidingSyncState.Complete: + this.purgeNotifications(); + if (!resp) { + break; + } + // Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared + if (!this.lastPos) { + this.updateSyncState(SyncState.Prepared, { + oldSyncToken: undefined, + nextSyncToken: resp.pos, + catchingUp: false, + fromCache: false, + }); + } + // Conversely, Element won't show the room list unless there is at least 1x SyncState.Syncing + // so hence for the very first sync we will fire prepared then immediately syncing. + this.updateSyncState(SyncState.Syncing, { + oldSyncToken: this.lastPos!, + nextSyncToken: resp.pos, + catchingUp: false, + fromCache: false, + }); + this.lastPos = resp.pos; + break; + case SlidingSyncState.RequestFinished: + if (err) { + this.failCount += 1; + this.updateSyncState( + this.failCount > FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting, + { + error: new MatrixError(err), + }, + ); + if (this.shouldAbortSync(new MatrixError(err))) { + return; // shouldAbortSync actually stops syncing too so we don't need to do anything. + } + } else { + this.failCount = 0; + } + break; + } + } + + /** + * Sync rooms the user has left. + * @returns Resolved when they've been added to the store. + */ + public async syncLeftRooms(): Promise<Room[]> { + return []; // TODO + } + + /** + * Peek into a room. This will result in the room in question being synced so it + * is accessible via getRooms(). Live updates for the room will be provided. + * @param roomId - The room ID to peek into. + * @returns A promise which resolves once the room has been added to the + * store. + */ + public async peek(_roomId: string): Promise<Room> { + return null!; // TODO + } + + /** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ + public stopPeeking(): void { + // TODO + } + + /** + * Returns the current state of this sync object + * @see MatrixClient#event:"sync" + */ + public getSyncState(): SyncState | null { + return this.syncState; + } + + /** + * Returns the additional data object associated with + * the current sync state, or null if there is no + * such data. + * Sync errors, if available, are put in the 'error' key of + * this object. + */ + public getSyncStateData(): ISyncStateData | null { + return this.syncStateData ?? null; + } + + // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts + + public createRoom(roomId: string): Room { + // XXX cargoculted from sync.ts + const { timelineSupport } = this.client; + const room = new Room(roomId, this.client, this.client.getUserId()!, { + lazyLoadMembers: this.opts.lazyLoadMembers, + pendingEventOrdering: this.opts.pendingEventOrdering, + timelineSupport, + }); + this.client.reEmitter.reEmit(room, [ + RoomEvent.Name, + RoomEvent.Redaction, + RoomEvent.RedactionCancelled, + RoomEvent.Receipt, + RoomEvent.Tags, + RoomEvent.LocalEchoUpdated, + RoomEvent.AccountData, + RoomEvent.MyMembership, + RoomEvent.Timeline, + RoomEvent.TimelineReset, + ]); + this.registerStateListeners(room); + return room; + } + + private registerStateListeners(room: Room): void { + // XXX cargoculted from sync.ts + // we need to also re-emit room state and room member events, so hook it up + // to the client now. We need to add a listener for RoomState.members in + // order to hook them correctly. + this.client.reEmitter.reEmit(room.currentState, [ + RoomStateEvent.Events, + RoomStateEvent.Members, + RoomStateEvent.NewMember, + RoomStateEvent.Update, + ]); + room.currentState.on(RoomStateEvent.NewMember, (event, state, member) => { + member.user = this.client.getUser(member.userId) ?? undefined; + this.client.reEmitter.reEmit(member, [ + RoomMemberEvent.Name, + RoomMemberEvent.Typing, + RoomMemberEvent.PowerLevel, + RoomMemberEvent.Membership, + ]); + }); + } + + /* + private deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts + // could do with a better way of achieving this. + room.currentState.removeAllListeners(RoomStateEvent.Events); + room.currentState.removeAllListeners(RoomStateEvent.Members); + room.currentState.removeAllListeners(RoomStateEvent.NewMember); + } */ + + private shouldAbortSync(error: MatrixError): boolean { + if (error.errcode === "M_UNKNOWN_TOKEN") { + // The logout already happened, we just need to stop. + logger.warn("Token no longer valid - assuming logout"); + this.stop(); + this.updateSyncState(SyncState.Error, { error }); + return true; + } + return false; + } + + private async processRoomData(client: MatrixClient, room: Room, roomData: MSC3575RoomData): Promise<void> { + roomData = ensureNameEvent(client, room.roomId, roomData); + const stateEvents = mapEvents(this.client, room.roomId, roomData.required_state); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + let timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false); + const ephemeralEvents: MatrixEvent[] = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral); + + // TODO: handle threaded / beacon events + + if (roomData.initial) { + // we should not know about any of these timeline entries if this is a genuinely new room. + // If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for + // this room, then timeline_limit: 50). + const knownEvents = new Set<string>(); + room.getLiveTimeline() + .getEvents() + .forEach((e) => { + knownEvents.add(e.getId()!); + }); + // all unknown events BEFORE a known event must be scrollback e.g: + // D E <-- what we know + // A B C D E F <-- what we just received + // means: + // A B C <-- scrollback + // D E <-- dupes + // F <-- new event + // We bucket events based on if we have seen a known event yet. + const oldEvents: MatrixEvent[] = []; + const newEvents: MatrixEvent[] = []; + let seenKnownEvent = false; + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const recvEvent = timelineEvents[i]; + if (knownEvents.has(recvEvent.getId()!)) { + seenKnownEvent = true; + continue; // don't include this event, it's a dupe + } + if (seenKnownEvent) { + // old -> new + oldEvents.push(recvEvent); + } else { + // old -> new + newEvents.unshift(recvEvent); + } + } + timelineEvents = newEvents; + if (oldEvents.length > 0) { + // old events are scrollback, insert them now + room.addEventsToTimeline(oldEvents, true, room.getLiveTimeline(), roomData.prev_batch); + } + } + + const encrypted = this.client.isRoomEncrypted(room.roomId); + // we do this first so it's correct when any of the events fire + if (roomData.notification_count != null) { + room.setUnreadNotificationCount(NotificationCountType.Total, roomData.notification_count); + } + + if (roomData.highlight_count != null) { + // We track unread notifications ourselves in encrypted rooms, so don't + // bother setting it here. We trust our calculations better than the + // server's for this case, and therefore will assume that our non-zero + // count is accurate. + if (!encrypted || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) { + room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count); + } + } + + if (Number.isInteger(roomData.invited_count)) { + room.currentState.setInvitedMemberCount(roomData.invited_count!); + } + if (Number.isInteger(roomData.joined_count)) { + room.currentState.setJoinedMemberCount(roomData.joined_count!); + } + + if (roomData.invite_state) { + const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state); + this.injectRoomEvents(room, inviteStateEvents); + if (roomData.initial) { + room.recalculate(); + this.client.store.storeRoom(room); + this.client.emit(ClientEvent.Room, room); + } + inviteStateEvents.forEach((e) => { + this.client.emit(ClientEvent.Event, e); + }); + room.updateMyMembership("invite"); + return; + } + + if (roomData.initial) { + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS); + } + + /* TODO + else if (roomData.limited) { + + let limited = true; + + // we've got a limited sync, so we *probably* have a gap in the + // timeline, so should reset. But we might have been peeking or + // paginating and already have some of the events, in which + // case we just want to append any subsequent events to the end + // of the existing timeline. + // + // This is particularly important in the case that we already have + // *all* of the events in the timeline - in that case, if we reset + // the timeline, we'll end up with an entirely empty timeline, + // which we'll try to paginate but not get any new events (which + // will stop us linking the empty timeline into the chain). + // + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const eventId = timelineEvents[i].getId(); + if (room.getTimelineForEvent(eventId)) { + logger.debug("Already have event " + eventId + " in limited " + + "sync - not resetting"); + limited = false; + + // we might still be missing some of the events before i; + // we don't want to be adding them to the end of the + // timeline because that would put them out of order. + timelineEvents.splice(0, i); + + // XXX: there's a problem here if the skipped part of the + // timeline modifies the state set in stateEvents, because + // we'll end up using the state from stateEvents rather + // than the later state from timelineEvents. We probably + // need to wind stateEvents forward over the events we're + // skipping. + break; + } + } + + if (limited) { + room.resetLiveTimeline( + roomData.prev_batch, + null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken, + ); + + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + this.client.resetNotifTimelineSet(); + this.registerStateListeners(room); + } + } */ + + this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live); + + // we deliberately don't add ephemeral events to the timeline + room.addEphemeralEvents(ephemeralEvents); + + // local fields must be set before any async calls because call site assumes + // synchronous execution prior to emitting SlidingSyncState.Complete + room.updateMyMembership("join"); + + room.recalculate(); + if (roomData.initial) { + client.store.storeRoom(room); + client.emit(ClientEvent.Room, room); + } + + // check if any timeline events should bing and add them to the notifEvents array: + // we'll purge this once we've fully processed the sync response + this.addNotifications(timelineEvents); + + const processRoomEvent = async (e: MatrixEvent): Promise<void> => { + client.emit(ClientEvent.Event, e); + if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) { + await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); + } + }; + + await utils.promiseMapSeries(stateEvents, processRoomEvent); + await utils.promiseMapSeries(timelineEvents, processRoomEvent); + ephemeralEvents.forEach(function (e) { + client.emit(ClientEvent.Event, e); + }); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); + } + + /** + * Injects events into a room's model. + * @param stateEventList - A list of state events. This is the state + * at the *START* of the timeline list if it is supplied. + * @param timelineEventList - A list of timeline events. Lower index + * is earlier in time. Higher index is later. + * @param numLive - the number of events in timelineEventList which just happened, + * supplied from the server. + */ + public injectRoomEvents( + room: Room, + stateEventList: MatrixEvent[], + timelineEventList?: MatrixEvent[], + numLive?: number, + ): void { + timelineEventList = timelineEventList || []; + stateEventList = stateEventList || []; + numLive = numLive || 0; + + // If there are no events in the timeline yet, initialise it with + // the given state events + const liveTimeline = room.getLiveTimeline(); + const timelineWasEmpty = liveTimeline.getEvents().length == 0; + if (timelineWasEmpty) { + // Passing these events into initialiseState will freeze them, so we need + // to compute and cache the push actions for them now, otherwise sync dies + // with an attempt to assign to read only property. + // XXX: This is pretty horrible and is assuming all sorts of behaviour from + // these functions that it shouldn't be. We should probably either store the + // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise + // find some solution where MatrixEvents are immutable but allow for a cache + // field. + for (const ev of stateEventList) { + this.client.getPushActionsForEvent(ev); + } + liveTimeline.initialiseState(stateEventList); + } + + // If the timeline wasn't empty, we process the state events here: they're + // defined as updates to the state before the start of the timeline, so this + // starts to roll the state forward. + // XXX: That's what we *should* do, but this can happen if we were previously + // peeking in a room, in which case we obviously do *not* want to add the + // state events here onto the end of the timeline. Historically, the js-sdk + // has just set these new state events on the old and new state. This seems + // very wrong because there could be events in the timeline that diverge the + // state, in which case this is going to leave things out of sync. However, + // for now I think it;s best to behave the same as the code has done previously. + if (!timelineWasEmpty) { + // XXX: As above, don't do this... + //room.addLiveEvents(stateEventList || []); + // Do this instead... + room.oldState.setStateEvents(stateEventList); + room.currentState.setStateEvents(stateEventList); + } + + // the timeline is broken into 'live' events which just happened and normal timeline events + // which are still to be appended to the end of the live timeline but happened a while ago. + // The live events are marked as fromCache=false to ensure that downstream components know + // this is a live event, not historical (from a remote server cache). + + let liveTimelineEvents: MatrixEvent[] = []; + if (numLive > 0) { + // last numLive events are live + liveTimelineEvents = timelineEventList.slice(-1 * numLive); + // everything else is not live + timelineEventList = timelineEventList.slice(0, -1 * liveTimelineEvents.length); + } + + // execute the timeline events. This will continue to diverge the current state + // if the timeline has any state events in it. + // This also needs to be done before running push rules on the events as they need + // to be decorated with sender etc. + room.addLiveEvents(timelineEventList, { + fromCache: true, + }); + if (liveTimelineEvents.length > 0) { + room.addLiveEvents(liveTimelineEvents, { + fromCache: false, + }); + } + + room.recalculate(); + + // resolve invites now we have set the latest state + this.resolveInvites(room); + } + + private resolveInvites(room: Room): void { + if (!room || !this.opts.resolveInvitesToProfiles) { + return; + } + const client = this.client; + // For each invited room member we want to give them a displayname/avatar url + // if they have one (the m.room.member invites don't contain this). + room.getMembersWithMembership("invite").forEach(function (member) { + if (member.requestedProfileInfo) return; + member.requestedProfileInfo = true; + // try to get a cached copy first. + const user = client.getUser(member.userId); + let promise: ReturnType<MatrixClient["getProfileInfo"]>; + if (user) { + promise = Promise.resolve({ + avatar_url: user.avatarUrl, + displayname: user.displayName, + }); + } else { + promise = client.getProfileInfo(member.userId); + } + promise.then( + function (info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member!; + if (inviteEvent.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, + function (_err) { + // OH WELL. + }, + ); + }); + } + + public retryImmediately(): boolean { + return true; + } + + /** + * Main entry point. Blocks until stop() is called. + */ + public async sync(): Promise<void> { + logger.debug("Sliding sync init loop"); + + // 1) We need to get push rules so we can check if events should bing as we get + // them from /sync. + while (!this.client.isGuest()) { + try { + logger.debug("Getting push rules..."); + const result = await this.client.getPushRules(); + logger.debug("Got push rules"); + this.client.pushRules = result; + break; + } catch (err) { + logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(<MatrixError>err)) { + return; + } + } + } + + // start syncing + await this.slidingSync.start(); + } + + /** + * Stops the sync object from syncing. + */ + public stop(): void { + logger.debug("SyncApi.stop"); + this.slidingSync.stop(); + } + + /** + * Sets the sync state and emits an event to say so + * @param newState - The new state string + * @param data - Object of additional data to emit in the event + */ + private updateSyncState(newState: SyncState, data?: ISyncStateData): void { + const old = this.syncState; + this.syncState = newState; + this.syncStateData = data; + this.client.emit(ClientEvent.Sync, this.syncState, old, data); + } + + /** + * Takes a list of timelineEvents and adds and adds to notifEvents + * as appropriate. + * This must be called after the room the events belong to has been stored. + * + * @param timelineEventList - A list of timeline events. Lower index + * is earlier in time. Higher index is later. + */ + private addNotifications(timelineEventList: MatrixEvent[]): void { + // gather our notifications into this.notifEvents + if (!this.client.getNotifTimelineSet()) { + return; + } + for (const timelineEvent of timelineEventList) { + const pushActions = this.client.getPushActionsForEvent(timelineEvent); + if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) { + this.notifEvents.push(timelineEvent); + } + } + } + + /** + * Purge any events in the notifEvents array. Used after a /sync has been complete. + * This should not be called at a per-room scope (e.g in onRoomData) because otherwise the ordering + * will be messed up e.g room A gets a bing, room B gets a newer bing, but both in the same /sync + * response. If we purge at a per-room scope then we could process room B before room A leading to + * room B appearing earlier in the notifications timeline, even though it has the higher origin_server_ts. + */ + private purgeNotifications(): void { + this.notifEvents.sort(function (a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach((event) => { + this.client.getNotifTimelineSet()?.addLiveEvent(event); + }); + this.notifEvents = []; + } +} + +function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575RoomData): MSC3575RoomData { + // make sure m.room.name is in required_state if there is a name, replacing anything previously + // there if need be. This ensures clients transparently 'calculate' the right room name. Native + // sliding sync clients should just read the "name" field. + if (!roomData.name) { + return roomData; + } + for (const stateEvent of roomData.required_state) { + if (stateEvent.type === EventType.RoomName && stateEvent.state_key === "") { + stateEvent.content = { + name: roomData.name, + }; + return roomData; + } + } + roomData.required_state.push({ + event_id: "$fake-sliding-sync-name-event-" + roomId, + state_key: "", + type: EventType.RoomName, + content: { + name: roomData.name, + }, + sender: client.getUserId()!, + origin_server_ts: new Date().getTime(), + }); + return roomData; +} + +type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string }; + +// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts, +// just outside the class. +function mapEvents(client: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] { + const mapper = client.getEventMapper({ decrypt }); + return (events as TaggedEvent[]).map(function (e) { + e.room_id = roomId; + return mapper(e); + }); +} + +function processEphemeralEvents(client: MatrixClient, roomId: string, ephEvents: IMinimalEvent[]): void { + const ephemeralEvents = mapEvents(client, roomId, ephEvents); + const room = client.getRoom(roomId); + if (!room) { + logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId); + return; + } + room.addEphemeralEvents(ephemeralEvents); + ephemeralEvents.forEach((e) => { + client.emit(ClientEvent.Event, e); + }); +} |