From e32b9d47586223d1c80069f38b3d8c3bf0e1b1cc Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Tue, 11 Oct 2022 14:22:08 +0200 Subject: [PATCH 01/29] Fix bug where undefined vs null in pagination tokens wasn't correctly handled --- src/models/room.ts | 2 +- src/timeline-window.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index aac79cb8d10..52598415777 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1017,7 +1017,7 @@ export class Room extends ReadReceipt { * 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 { + public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void { for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].resetLiveTimeline( backPaginationToken, forwardPaginationToken, diff --git a/src/timeline-window.ts b/src/timeline-window.ts index ba6a16ea55f..610a5f323db 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -236,8 +236,9 @@ export class TimelineWindow { } } - return Boolean(tl.timeline.getNeighbouringTimeline(direction) || - tl.timeline.getPaginationToken(direction) !== null); + const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction); + const paginationToken = tl.timeline.getPaginationToken(direction); + return Boolean(hasNeighbouringTimeline) || Boolean(paginationToken); } /** @@ -294,7 +295,7 @@ export class TimelineWindow { // try making a pagination request const token = tl.timeline.getPaginationToken(direction); - if (token === null) { + if (!token) { debuglog("TimelineWindow: no token"); return Promise.resolve(false); } From 979ac993cb445e9ef676e0b56da94f6adf57644c Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Tue, 11 Oct 2022 14:39:14 +0200 Subject: [PATCH 02/29] Fix bug where thread list results were sorted incorrectly --- src/client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client.ts b/src/client.ts index 6d270c1495a..4087e481735 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5407,6 +5407,7 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, path, params, undefined, opts) .then(res => ({ ...res, + chunk: res.chunk?.reverse(), start: res.prev_batch, end: res.next_batch, })); From 317272ae3639f7f1233ebe6611748bee0a6b101e Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Tue, 11 Oct 2022 14:25:52 +0200 Subject: [PATCH 03/29] Allow removing the relationship of an event to a thread --- src/models/event.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 559ec4c8730..733040f1782 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -1540,10 +1540,15 @@ export class MatrixEvent extends TypedEventEmitter Date: Fri, 21 Oct 2022 14:32:05 +0200 Subject: [PATCH 04/29] Implement feature detection for new threads MSCs and specs --- src/client.ts | 26 +++++++++++++++++++++----- src/models/thread.ts | 7 +++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/client.ts b/src/client.ts index 4087e481735..4a370161503 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,7 +36,7 @@ import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, suppor import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; -import { QueryDict, sleep } from './utils'; +import { replaceParam, QueryDict, sleep } from './utils'; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -1188,9 +1188,10 @@ export class MatrixClient extends TypedEventEmitter { + if (await this.isVersionSupported("v1.4")) { + return { + threads: FeatureSupport.Stable, + list: FeatureSupport.Stable, + fwdPagination: FeatureSupport.Stable, + }; + } + try { - const [threadUnstable, threadStable, listUnstable, listStable] = await Promise.all([ + const [ + threadUnstable, threadStable, + listUnstable, listStable, + fwdPaginationUnstable, fwdPaginationStable, + ] = await Promise.all([ this.doesServerSupportUnstableFeature("org.matrix.msc3440"), this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), this.doesServerSupportUnstableFeature("org.matrix.msc3856"), this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), + this.doesServerSupportUnstableFeature("org.matrix.msc3715"), + this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable"), ]); - // TODO: Use `this.isVersionSupported("v1.3")` for whatever spec version includes MSC3440 formally. - return { threads: determineFeatureSupport(threadStable, threadUnstable), list: determineFeatureSupport(listStable, listUnstable), + fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable), }; } catch (e) { return { threads: FeatureSupport.None, list: FeatureSupport.None, + fwdPagination: FeatureSupport.None, }; } } diff --git a/src/models/thread.ts b/src/models/thread.ts index e77e3f5b830..2f741c28fc7 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -73,6 +73,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea export class Thread extends ReadReceipt { public static hasServerSideSupport = FeatureSupport.None; public static hasServerSideListSupport = FeatureSupport.None; + public static hasServerSideFwdPaginationSupport = FeatureSupport.None; /** * A reference to all the events ID at the bottom of the threads @@ -168,6 +169,12 @@ export class Thread extends ReadReceipt { Thread.hasServerSideListSupport = status; } + public static setServerSideFwdPaginationSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideFwdPaginationSupport = status; + } + private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { if (event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && From f8b506359088512256768087b9cf5eaafd6fe4d0 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 21 Oct 2022 14:32:30 +0200 Subject: [PATCH 05/29] Prefix dir parameter for threads pagination if necessary --- src/client.ts | 6 +++++- src/utils.ts | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 4a370161503..cb356730e09 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7284,7 +7284,11 @@ export class MatrixClient extends TypedEventEmitter { - const queryString = utils.encodeParams(opts as Record); + let params = opts as QueryDict; + if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { + params = replaceParam("dir", "org.matrix.msc3715.dir", params); + } + const queryString = utils.encodeParams(params); let templatedUrl = "/rooms/$roomId/relations/$eventId"; if (relationType !== null) { diff --git a/src/utils.ts b/src/utils.ts index 1e396d2de5d..81ed1abb66f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -76,6 +76,25 @@ export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParam export type QueryDict = Record; +/** + * Replace a stable parameter with the unstable naming for params + * @param stable + * @param unstable + * @param dict + */ +export function replaceParam( + stable: string, + unstable: string, + dict: QueryDict, +): QueryDict { + const result = { + ...dict, + [unstable]: dict[stable], + }; + delete result[stable]; + return result; +} + /** * Decode a query string in `application/x-www-form-urlencoded` format. * @param {string} query A query string to decode e.g. From 5972e3c0c5bf9655747a490e25e7885ebfba05a8 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 12 Oct 2022 13:49:17 +0200 Subject: [PATCH 06/29] Make threads conform to the same timeline APIs as any other timeline --- src/client.ts | 144 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 98 insertions(+), 46 deletions(-) diff --git a/src/client.ts b/src/client.ts index cb356730e09..7aeb5a1e227 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5160,6 +5160,10 @@ export class MatrixClient extends TypedEventEmitter { + if (!this.supportsExperimentalThreads()) { + throw new Error("could not get thread timeline: no client support"); + } + + const path = utils.encodeUri( + "/rooms/$roomId/context/$eventId", { + $roomId: timelineSet.room.roomId, + $eventId: eventId, + }, + ); + + const params: Record = { + limit: "0", + }; + if (this.clientOpts.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors. + const res = await this.http.authedRequest(Method.Get, path, params); + const mapper = this.getEventMapper(); + const event = mapper(res.event); + + if (!timelineSet.canContain(event)) { + return undefined; + } + + if (Thread.hasServerSideSupport) { + const thread = timelineSet.thread; + const opts: IRelationsRequestOpts = { + dir: Direction.Backward, + limit: 50, + }; + + await thread.fetchInitialEvents(); + let nextBatch: string | null | undefined = thread.liveTimeline.getPaginationToken(Direction.Backward); + + // Fetch events until we find the one we were asked for, or we run out of pages + while (!thread.findEventById(eventId)) { + if (nextBatch) { + opts.from = nextBatch; + } + + ({ nextBatch } = await thread.fetchEvents(opts)); + if (!nextBatch) break; + } + + return thread.liveTimeline; + } + } + /** * Get an EventTimeline for the latest events in the room. This will just * call `/messages` to get the latest message in the room, then use @@ -5271,28 +5295,44 @@ export class MatrixClient extends TypedEventEmitter = { + dir: 'b', + }; + if (this.clientOpts.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(Method.Get, messagesPath, params); + event = res.chunk?.[0]; } - const event = res.chunk?.[0]; if (!event) { - throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); + throw new Error("No message returned when trying to construct getLatestTimeline"); } return this.getEventTimeline(timelineSet, event.event_id); @@ -5430,7 +5470,8 @@ export class MatrixClient extends TypedEventEmitter { const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); const room = this.getRoom(eventTimeline.getRoomId()!); - const isThreadTimeline = eventTimeline.getTimelineSet().isThreadTimeline; + const isThreadListTimeline = eventTimeline.getTimelineSet().isThreadTimeline; + const isThreadTimeline = (eventTimeline.getTimelineSet().thread); // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. @@ -5501,11 +5542,15 @@ export class MatrixClient extends TypedEventEmitter { + return Boolean(res.nextBatch); + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; } else { if (!room) { throw new Error("Unknown room " + eventTimeline.getRoomId()); From 6bf88b43a655c16add587491233c79c2d35a5874 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 12 Oct 2022 13:55:39 +0200 Subject: [PATCH 07/29] Extract thread timeline loading out of thread class --- src/client.ts | 149 ++++++++++++++++++++++++++++++++++++++----- src/models/room.ts | 1 - src/models/thread.ts | 69 +------------------- 3 files changed, 135 insertions(+), 84 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7aeb5a1e227..38742e30ed3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5251,26 +5251,111 @@ export class MatrixClient extends TypedEventEmitter = res.end; + while (nextBatch) { + const resNewer = await this.fetchRelations( + timelineSet.room.roomId, + thread.id, + THREAD_RELATION_TYPE.name, + null, + { dir: Direction.Forward, from: nextBatch }, + ); + nextBatch = resNewer.next_batch ?? null; + eventsNewer.push(...resNewer.chunk); + } + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...eventsNewer.reverse().map(mapper), + event, + ...resOlder.chunk.map(mapper), + ]; + + await timelineSet.thread?.fetchEditsWhereNeeded(...events); + + // Here we handle non-thread timelines only, but still process any thread events to populate thread + // summaries. + const timeline = timelineSet.getLiveTimeline(); + timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + + timelineSet.addEventsToTimeline(events, true, timeline, null); + if (!resOlder.next_batch) { + timelineSet.addEventsToTimeline([mapper(resOlder.original_event)], true, timeline, null); + } + timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward); + timeline.setPaginationToken(null, Direction.Forward); + this.processBeaconEvents(timelineSet.room, events); - ({ nextBatch } = await thread.fetchEvents(opts)); - if (!nextBatch) break; + return timeline; } - - return thread.liveTimeline; } } @@ -5583,8 +5668,38 @@ export class MatrixClient extends TypedEventEmitter { - return Boolean(res.nextBatch); + const room = this.getRoom(eventTimeline.getRoomId()); + if (!room) { + throw new Error("Unknown room " + eventTimeline.getRoomId()); + } + + promise = this.fetchRelations( + eventTimeline.getRoomId(), + eventTimeline.getTimelineSet().thread?.id, + THREAD_RELATION_TYPE.name, + null, + { dir, limit: opts.limit, from: token }, + ).then((res) => { + const mapper = this.getEventMapper(); + const matrixEvents = res.chunk.map(mapper); + eventTimeline.getTimelineSet().thread?.fetchEditsWhereNeeded(...matrixEvents); + + const newToken = res.next_batch; + + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null); + if (!newToken && backwards) { + timelineSet.addEventsToTimeline([mapper(res.original_event)], true, eventTimeline, null); + } + this.processBeaconEvents(timelineSet.room, matrixEvents); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !newToken) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(newToken); }).finally(() => { eventTimeline.paginationRequests[dir] = null; }); diff --git a/src/models/room.ts b/src/models/room.ts index 52598415777..77fd80f417e 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1967,7 +1967,6 @@ export class Room extends ReadReceipt { } const thread = new Thread(threadId, rootEvent, { - initialEvents: events, room: this, client: this.client, }); diff --git a/src/models/thread.ts b/src/models/thread.ts index 2f741c28fc7..4978a11d024 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -18,9 +18,8 @@ import { Optional } from "matrix-events-sdk"; import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; -import { IRelationsRequestOpts } from "../@types/requests"; import { IThreadBundledRelationship, MatrixEvent } from "./event"; -import { Direction, EventTimeline } from "./event-timeline"; +import { EventTimeline } from "./event-timeline"; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { Room } from './room'; import { RoomState } from "./room-state"; @@ -46,7 +45,6 @@ export type EventHandlerMap = { } & EventTimelineSetHandlerMap; interface IThreadOpts { - initialEvents?: MatrixEvent[]; room: Room; client: MatrixClient; } @@ -90,8 +88,6 @@ export class Thread extends ReadReceipt { public readonly room: Room; public readonly client: MatrixClient; - public initialEventsFetched = !Thread.hasServerSideSupport; - constructor( public readonly id: string, public rootEvent: MatrixEvent | undefined, @@ -123,9 +119,6 @@ export class Thread extends ReadReceipt { this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); this.timelineSet.on(RoomEvent.Timeline, this.onEcho); - if (opts.initialEvents) { - this.addEvents(opts.initialEvents, false); - } // even if this thread is thought to be originating from this client, we initialise it as we may be in a // gappy sync and a thread around this event may already exist. this.initialiseThread(); @@ -273,7 +266,6 @@ export class Thread extends ReadReceipt { this.client.decryptEventIfNeeded(event, {}); } else if (!toStartOfTimeline && - this.initialEventsFetched && event.localTimestamp > this.lastReply()!.localTimestamp ) { this.fetchEditsWhereNeeded(event); @@ -326,7 +318,7 @@ export class Thread extends ReadReceipt { } // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 - private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { + public async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { if (event.isRelation()) return; // skip - relations don't get edits return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), { @@ -341,13 +333,7 @@ export class Thread extends ReadReceipt { })); } - public async fetchInitialEvents(): Promise { - if (this.initialEventsFetched) return; - await this.fetchEvents(); - this.initialEventsFetched = true; - } - - private setEventMetadata(event: MatrixEvent): void { + public setEventMetadata(event: MatrixEvent): void { EventTimeline.setEventMetadata(event, this.roomState, false); event.setThread(this); } @@ -413,55 +399,6 @@ export class Thread extends ReadReceipt { return this.timelineSet.getLiveTimeline(); } - public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, dir: Direction.Backward }): Promise<{ - originalEvent?: MatrixEvent; - events: MatrixEvent[]; - nextBatch?: string | null; - prevBatch?: string; - }> { - let { - originalEvent, - events, - prevBatch, - nextBatch, - } = await this.client.relations( - this.room.roomId, - this.id, - THREAD_RELATION_TYPE.name, - null, - opts, - ); - - // When there's no nextBatch returned with a `from` request we have reached - // the end of the thread, and therefore want to return an empty one - if (!opts.to && !nextBatch && originalEvent) { - events = [...events, originalEvent]; - } - - await this.fetchEditsWhereNeeded(...events); - - await Promise.all(events.map(event => { - this.setEventMetadata(event); - return this.client.decryptEventIfNeeded(event); - })); - - const prependEvents = (opts.dir ?? Direction.Backward) === Direction.Backward; - - this.timelineSet.addEventsToTimeline( - events, - prependEvents, - this.liveTimeline, - prependEvents ? nextBatch : prevBatch, - ); - - return { - originalEvent, - events, - prevBatch, - nextBatch, - }; - } - public getUnfilteredTimelineSet(): EventTimelineSet { return this.timelineSet; } From 7459f492e98a779ab6a7113620e3f13c601424cd Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 21 Oct 2022 14:26:03 +0200 Subject: [PATCH 08/29] Make typescript strict lint happier --- src/client.ts | 4 ++-- src/models/event-timeline-set.ts | 4 ++-- src/models/event.ts | 4 ++-- src/utils.ts | 9 +++++++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/client.ts b/src/client.ts index 38742e30ed3..cdcf19e5285 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6906,7 +6906,7 @@ export class MatrixClient extends TypedEventEmitter { - const fetchedEventType = this.getEncryptedIfNeededEventType(roomId, eventType); + const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null; const result = await this.fetchRelations( roomId, eventId, @@ -6930,7 +6930,7 @@ export class MatrixClient extends TypedEventEmitter e.getSender() === originalEvent.getSender()); } return { - originalEvent, + originalEvent: originalEvent ?? null, events, nextBatch: result.next_batch, prevBatch: result.prev_batch, diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index d0033c6f699..db2e17c29e9 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -297,8 +297,8 @@ export class EventTimelineSet extends TypedEventEmitter { * variables with. E.g. { "$bar": "baz" }. * @return {string} The result of replacing all template variables e.g. '/foo/baz'. */ -export function encodeUri(pathTemplate: string, variables: Record): string { +export function encodeUri(pathTemplate: string, variables: Record>): string { for (const key in variables) { if (!variables.hasOwnProperty(key)) { continue; } + const value = variables[key]; + if (value === undefined || value === null) { + continue; + } pathTemplate = pathTemplate.replace( - key, encodeURIComponent(variables[key]), + key, encodeURIComponent(value), ); } return pathTemplate; From 4f79986519b8c527f4c0f88ea655e607c3e0b00e Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Thu, 13 Oct 2022 03:46:20 +0200 Subject: [PATCH 09/29] fix thread roots not being updated correctly --- src/models/room.ts | 59 ++++++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index 77fd80f417e..b8367a01cdc 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -18,6 +18,8 @@ limitations under the License. * @module models/room */ +import { Optional } from "matrix-events-sdk"; + import { EventTimelineSet, DuplicateStrategy, @@ -1767,7 +1769,7 @@ export class Room extends ReadReceipt { let latestMyThreadsRootEvent: MatrixEvent; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent(rootEvent, { + this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, @@ -1776,7 +1778,7 @@ export class Room extends ReadReceipt { const threadRelationship = rootEvent .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (threadRelationship?.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, @@ -1832,14 +1834,8 @@ export class Room extends ReadReceipt { } private onThreadNewReply(thread: Thread): void { - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); - for (const timelineSet of this.threadsTimelineSets) { - timelineSet.removeEvent(thread.id); - timelineSet.addLiveEvent(thread.rootEvent, { - duplicateStrategy: DuplicateStrategy.Replace, - fromCache: false, - roomState, - }); + if (thread.length && thread.rootEvent) { + this.updateThreadRootEvents(thread, false); } } @@ -1951,6 +1947,33 @@ export class Room extends ReadReceipt { )); } + private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean) => { + this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline); + if (thread.hasCurrentUserParticipated) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline); + } + }; + + private updateThreadRootEvent = ( + timelineSet: Optional, + thread: Thread, + toStartOfTimeline: boolean, + ) => { + 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, @@ -1985,19 +2008,9 @@ export class Room extends ReadReceipt { } if (this.threadsReady) { - this.threadsTimelineSets.forEach(timelineSet => { - if (thread.rootEvent) { - if (Thread.hasServerSideSupport) { - timelineSet.addLiveEvent(thread.rootEvent); - } else { - timelineSet.addEventToTimeline( - thread.rootEvent, - timelineSet.getLiveTimeline(), - toStartOfTimeline, - ); - } - } - }); + if (thread.length && thread.rootEvent) { + this.updateThreadRootEvents(thread, toStartOfTimeline); + } } this.emit(ThreadEvent.New, thread, toStartOfTimeline); From 4a51e33ff774239eb16b6db652f9ad436ae6f0d9 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 21 Oct 2022 14:35:34 +0200 Subject: [PATCH 10/29] fix jumping to events by link --- src/timeline-window.ts | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/src/timeline-window.ts b/src/timeline-window.ts index 610a5f323db..c5560bdf4b0 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -133,18 +133,14 @@ export class TimelineWindow { // We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need, // which is important to keep room-switching feeling snappy. - if (initialEventId) { - const timeline = this.timelineSet.getTimelineForEvent(initialEventId); - if (timeline) { - // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. - initFields(timeline); - return Promise.resolve(); - } - - return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); + if (this.timelineSet.getTimelineForEvent(initialEventId)) { + initFields(this.timelineSet.getTimelineForEvent(initialEventId)); + return Promise.resolve(); + } else if (initialEventId) { + return this.client.getEventTimeline(this.timelineSet, initialEventId) + .then(initFields); } else { - const tl = this.timelineSet.getLiveTimeline(); - initFields(tl); + initFields(this.timelineSet.getLiveTimeline()); return Promise.resolve(); } } @@ -263,7 +259,7 @@ export class TimelineWindow { * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ - public paginate( + public async paginate( direction: Direction, size: number, makeRequest = true, @@ -275,7 +271,7 @@ export class TimelineWindow { if (!tl) { debuglog("TimelineWindow: no timeline yet"); - return Promise.resolve(false); + return false; } if (tl.pendingPaginate) { @@ -284,20 +280,20 @@ export class TimelineWindow { // try moving the cap if (this.extend(direction, size)) { - return Promise.resolve(true); + return true; } if (!makeRequest || requestLimit === 0) { // todo: should we return something different to indicate that there // might be more events out there, but we haven't found them yet? - return Promise.resolve(false); + return false; } // try making a pagination request const token = tl.timeline.getPaginationToken(direction); if (!token) { debuglog("TimelineWindow: no token"); - return Promise.resolve(false); + return false; } debuglog("TimelineWindow: starting request"); @@ -310,8 +306,7 @@ export class TimelineWindow { }).then((r) => { debuglog("TimelineWindow: request completed with result " + r); if (!r) { - // end of timeline - return false; + return this.paginate(direction, size, false, 0); } // recurse to advance the index into the results. From 7fee96b3833ab5f93dadac9ab28b340fa9dbfa57 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 21 Oct 2022 14:46:37 +0200 Subject: [PATCH 11/29] implement new thread timeline loading --- src/client.ts | 77 +++++++++++------ src/models/event-timeline-set.ts | 4 +- src/models/room.ts | 80 +++++++++++++----- src/models/thread.ts | 139 +++++++++++++++++-------------- 4 files changed, 187 insertions(+), 113 deletions(-) diff --git a/src/client.ts b/src/client.ts index cdcf19e5285..39c6467a795 100644 --- a/src/client.ts +++ b/src/client.ts @@ -189,7 +189,14 @@ import { TypedEventEmitter } from "./models/typed-event-emitter"; import { ReceiptType } from "./@types/read_receipts"; import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { FeatureSupport, Thread, THREAD_RELATION_TYPE, determineFeatureSupport } from "./models/thread"; +import { + FeatureSupport, + Thread, + THREAD_RELATION_TYPE, + determineFeatureSupport, + ThreadFilterType, + threadFilterTypeToFilter, +} from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { UnstableValue } from "./NamespacedValue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; @@ -5160,7 +5167,7 @@ export class MatrixClient extends TypedEventEmitter = res.end; while (nextBatch) { - const resNewer = await this.fetchRelations( + const resNewer: IRelationsResponse = await this.fetchRelations( timelineSet.room.roomId, thread.id, THREAD_RELATION_TYPE.name, @@ -5338,8 +5355,9 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); @@ -5497,7 +5517,7 @@ export class MatrixClient extends TypedEventEmitter = { limit: limit.toString(), dir: dir, - include: 'all', + include: threadFilterTypeToFilter(threadListType), }; if (fromToken) { @@ -5509,7 +5529,6 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, path, params, undefined, opts) .then(res => ({ @@ -5555,8 +5575,8 @@ export class MatrixClient extends TypedEventEmitter { const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); const room = this.getRoom(eventTimeline.getRoomId()!); - const isThreadListTimeline = eventTimeline.getTimelineSet().isThreadTimeline; - const isThreadTimeline = (eventTimeline.getTimelineSet().thread); + const threadListType = eventTimeline.getTimelineSet().threadListType; + const thread = eventTimeline.getTimelineSet().thread; // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. @@ -5627,7 +5647,7 @@ export class MatrixClient extends TypedEventEmitter { if (res.state) { @@ -5667,7 +5688,7 @@ export class MatrixClient extends TypedEventEmitter { + { dir, limit: opts.limit, from: token ?? undefined }, + ).then(async (res) => { const mapper = this.getEventMapper(); const matrixEvents = res.chunk.map(mapper); - eventTimeline.getTimelineSet().thread?.fetchEditsWhereNeeded(...matrixEvents); + for (const event of matrixEvents) { + await eventTimeline.getTimelineSet()?.thread?.processEvent(event); + } const newToken = res.next_batch; @@ -5725,10 +5748,12 @@ export class MatrixClient extends TypedEventEmitter it.isRelation(THREAD_RELATION_TYPE.name)), + false); const atEnd = res.end === undefined || res.end === res.start; diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index db2e17c29e9..f56c38376db 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -27,7 +27,7 @@ import { RoomState } from "./room-state"; import { TypedEventEmitter } from "./typed-event-emitter"; import { RelationsContainer } from "./relations-container"; import { MatrixClient } from "../client"; -import { Thread } from "./thread"; +import { Thread, ThreadFilterType } from "./thread"; const DEBUG = true; @@ -140,7 +140,7 @@ export class EventTimelineSet extends TypedEventEmitter void; } & Pick< ThreadHandlerMap, - ThreadEvent.Update | ThreadEvent.NewReply + ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete > & EventTimelineSetHandlerMap & Pick @@ -1661,8 +1662,12 @@ export class Room extends ReadReceipt { private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { let timelineSet: EventTimelineSet; if (Thread.hasServerSideListSupport) { + let threadListType: ThreadFilterType | null = null; + if (Thread.hasServerSideListSupport) { + threadListType = filterType ?? ThreadFilterType.All; + } timelineSet = - new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport)); + new EventTimelineSet(this, this.opts, undefined, undefined, threadListType); this.reEmitter.reEmit(timelineSet, [ RoomEvent.Timeline, RoomEvent.TimelineReset, @@ -1796,6 +1801,7 @@ export class Room extends ReadReceipt { } this.on(ThreadEvent.NewReply, this.onThreadNewReply); + this.on(ThreadEvent.Delete, this.onThreadDelete); this.threadsReady = true; } @@ -1814,6 +1820,7 @@ export class Room extends ReadReceipt { null, undefined, Direction.Backward, + timelineSet.threadListType, timelineSet.getFilter(), ); @@ -1834,8 +1841,21 @@ export class Room extends ReadReceipt { } private onThreadNewReply(thread: Thread): void { - if (thread.length && thread.rootEvent) { - this.updateThreadRootEvents(thread, false); + this.updateThreadRootEvents(thread, false); + } + + 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); } } @@ -1948,9 +1968,11 @@ export class Room extends ReadReceipt { } private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean) => { - this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline); - if (thread.hasCurrentUserParticipated) { - this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline); + if (thread.length) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline); + if (thread.hasCurrentUserParticipated) { + this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline); + } } }; @@ -1959,18 +1981,20 @@ export class Room extends ReadReceipt { thread: Thread, toStartOfTimeline: boolean, ) => { - if (Thread.hasServerSideSupport) { - timelineSet.addLiveEvent(thread.rootEvent, { - duplicateStrategy: DuplicateStrategy.Replace, - fromCache: false, - roomState: this.currentState, - }); - } else { - timelineSet.addEventToTimeline( - thread.rootEvent, - timelineSet.getLiveTimeline(), - { toStartOfTimeline }, - ); + if (timelineSet && thread.rootEvent) { + if (Thread.hasServerSideSupport) { + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState: this.currentState, + }); + } else { + timelineSet.addEventToTimeline( + thread.rootEvent, + timelineSet.getLiveTimeline(), + { toStartOfTimeline }, + ); + } } }; @@ -1994,23 +2018,33 @@ export class Room extends ReadReceipt { client: this.client, }); + // 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. + for (const event of events) { + thread.setEventMetadata(event); + } + // If we managed to create a thread and figure out its `id` then we can use it this.threads.set(thread.id, thread); 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 || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) { + if (!this.lastThread || isNewer) { this.lastThread = thread; } if (this.threadsReady) { - if (thread.length && thread.rootEvent) { - this.updateThreadRootEvents(thread, toStartOfTimeline); - } + this.updateThreadRootEvents(thread, toStartOfTimeline); } this.emit(ThreadEvent.New, thread, toStartOfTimeline); diff --git a/src/models/thread.ts b/src/models/thread.ts index 4978a11d024..d4fec1e1373 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -32,6 +32,7 @@ export enum ThreadEvent { Update = "Thread.update", NewReply = "Thread.newReply", ViewThread = "Thread.viewThread", + Delete = "Thread.delete" } type EmittedEvents = Exclude @@ -42,6 +43,7 @@ export type EventHandlerMap = { [ThreadEvent.Update]: (thread: Thread) => void; [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void; [ThreadEvent.ViewThread]: () => void; + [ThreadEvent.Delete]: (thread: Thread) => void; } & EventTimelineSetHandlerMap; interface IThreadOpts { @@ -88,6 +90,8 @@ export class Thread extends ReadReceipt { public readonly room: Room; public readonly client: MatrixClient; + public initialEventsFetched = !Thread.hasServerSideSupport; + constructor( public readonly id: string, public rootEvent: MatrixEvent | undefined, @@ -122,8 +126,7 @@ export class Thread extends ReadReceipt { // even if this thread is thought to be originating from this client, we initialise it as we may be in a // gappy sync and a thread around this event may already exist. this.initialiseThread(); - - this.rootEvent?.setThread(this); + this.setEventMetadata(this.rootEvent); } private async fetchRootEvent(): Promise { @@ -136,13 +139,7 @@ export class Thread extends ReadReceipt { } catch (e) { logger.error("Failed to fetch thread root to construct thread with", e); } - - // The root event might be not be visible to the person requesting it. - // If it wasn't fetched successfully the thread will work in "limited" mode and won't - // benefit from all the APIs a homeserver can provide to enhance the thread experience - this.rootEvent?.setThread(this); - - this.emit(ThreadEvent.Update, this); + await this.processEvent(this.rootEvent); } public static setServerSideSupport( @@ -179,42 +176,26 @@ export class Thread extends ReadReceipt { } }; - private onRedaction = (event: MatrixEvent) => { + private onRedaction = async (event: MatrixEvent) => { if (event.threadRootId !== this.id) return; // ignore redactions for other timelines - const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); - this.lastEvent = events.find(e => ( - !e.isRedacted() && - e.isRelation(THREAD_RELATION_TYPE.name) - )) ?? this.rootEvent!; + if (this.replyCount <= 0) { + for (const threadEvent of this.events) { + this.clearEventMetadata(threadEvent); + } + this.emit(ThreadEvent.Delete, this); + } else { + await this.initialiseThread(); + } this.emit(ThreadEvent.Update, this); }; - private onEcho = (event: MatrixEvent) => { + private onEcho = async (event: MatrixEvent) => { if (event.threadRootId !== this.id) return; // ignore echoes for other timelines if (this.lastEvent === event) return; if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; - // There is a risk that the `localTimestamp` approximation will not be accurate - // when threads are used over federation. That could result in the reply - // count value drifting away from the value returned by the server - const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); - if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply - && (event.getId() !== this.lastEvent.getId()) - && (event.localTimestamp > this.lastEvent.localTimestamp)) - ) { - this.lastEvent = event; - if (this.lastEvent.getId() !== this.id) { - // This counting only works when server side support is enabled as we started the counting - // from the value returned within the bundled relationship - if (Thread.hasServerSideSupport) { - this.replyCount++; - } - - this.emit(ThreadEvent.NewReply, this, event); - } - } - - this.emit(ThreadEvent.Update, this); + await this.initialiseThread(); + this.emit(ThreadEvent.NewReply, this, event); }; public get roomState(): RoomState { @@ -238,6 +219,7 @@ export class Thread extends ReadReceipt { public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); this.emit(ThreadEvent.Update, this); + this.initialiseThread(); } /** @@ -249,12 +231,11 @@ export class Thread extends ReadReceipt { * to the start (and not the end) of the timeline. * @param {boolean} emit whether to emit the Update event if the thread was updated or not. */ - public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void { - event.setThread(this); + public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise { + this.setEventMetadata(event); - if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) { - this._currentUserParticipated = true; - } + const lastReply = this.lastReply(); + const isNewestReply = !lastReply || event.localTimestamp > lastReply!.localTimestamp; // Add all incoming events to the thread's timeline set when there's no server support if (!Thread.hasServerSideSupport) { @@ -265,15 +246,13 @@ export class Thread extends ReadReceipt { this.addEventToTimeline(event, toStartOfTimeline); this.client.decryptEventIfNeeded(event, {}); - } else if (!toStartOfTimeline && - event.localTimestamp > this.lastReply()!.localTimestamp - ) { - this.fetchEditsWhereNeeded(event); + } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) { + await this.fetchEditsWhereNeeded(event); this.addEventToTimeline(event, false); } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { // Apply annotations and replace relations to the relations of the timeline only - this.timelineSet.relations.aggregateParentEvent(event); - this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet); + this.timelineSet.relations?.aggregateParentEvent(event); + this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); return; } @@ -284,7 +263,15 @@ export class Thread extends ReadReceipt { } if (emit) { - this.emit(ThreadEvent.Update, this); + this.emit(ThreadEvent.NewReply, this, event); + this.initialiseThread(); + } + } + + public async processEvent(event: Optional): Promise { + if (event) { + this.setEventMetadata(event); + await this.fetchEditsWhereNeeded(event); } } @@ -294,7 +281,7 @@ export class Thread extends ReadReceipt { private async initialiseThread(): Promise { let bundledRelationship = this.getRootEventBundledRelationship(); - if (Thread.hasServerSideSupport && !bundledRelationship) { + if (Thread.hasServerSideSupport) { await this.fetchRootEvent(); bundledRelationship = this.getRootEventBundledRelationship(); } @@ -303,22 +290,32 @@ export class Thread extends ReadReceipt { this.replyCount = bundledRelationship.count; this._currentUserParticipated = !!bundledRelationship.current_user_participated; - const event = new MatrixEvent({ - room_id: this.rootEvent.getRoomId(), - ...bundledRelationship.latest_event, - }); - this.setEventMetadata(event); - event.setThread(this); - this.lastEvent = event; + const mapper = this.client.getEventMapper(); + this.lastEvent = mapper(bundledRelationship.latest_event); + await this.processEvent(this.lastEvent); + } - this.fetchEditsWhereNeeded(event); + if (!this.initialEventsFetched) { + this.initialEventsFetched = true; + // fetch initial event to allow proper pagination + try { + // if the thread has regular events, this will just load the last reply. + // if the thread is newly created, this will load the root event. + await this.client.paginateEventTimeline(this.liveTimeline, { backwards: true, limit: 1 }); + // just to make sure that, if we've created a timeline window for this thread before the thread itself + // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. + this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true); + } catch (e) { + logger.error("Failed to load start of newly created thread: ", e); + this.initialEventsFetched = false; + } } this.emit(ThreadEvent.Update, this); } // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 - public async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { + private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { return Promise.all(events.filter(e => e.isEncrypted()).map((event: MatrixEvent) => { if (event.isRelation()) return; // skip - relations don't get edits return this.client.relations(this.roomId, event.getId(), RelationType.Replace, event.getType(), { @@ -333,9 +330,18 @@ export class Thread extends ReadReceipt { })); } - public setEventMetadata(event: MatrixEvent): void { - EventTimeline.setEventMetadata(event, this.roomState, false); - event.setThread(this); + public setEventMetadata(event: Optional): void { + if (event) { + EventTimeline.setEventMetadata(event, this.roomState, false); + event.setThread(this); + } + } + + public clearEventMetadata(event: Optional): void { + if (event) { + event.setThread(null); + delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name]; + } } /** @@ -429,3 +435,12 @@ export enum ThreadFilterType { "My", "All" } + +export function threadFilterTypeToFilter(type: ThreadFilterType): 'all' | 'participated' { + switch (type) { + case ThreadFilterType.My: + return 'participated'; + case ThreadFilterType.All: + return 'all'; + } +} From a2e5088b019d41b104601b0872f2c3616b8e7930 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 21 Oct 2022 15:12:56 +0200 Subject: [PATCH 12/29] reduce amount of failing tests --- spec/integ/matrix-client-event-timeline.spec.ts | 2 ++ spec/integ/matrix-client-relations.spec.ts | 10 +++++----- src/client.ts | 8 ++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index bc31b4d4e67..507c8150877 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -342,6 +342,8 @@ describe("MatrixClient event timelines", function() { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); Thread.setServerSideSupport(FeatureSupport.None); + Thread.setServerSideListSupport(FeatureSupport.None); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.None); }); describe("getEventTimeline", function() { diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts index 3a8a99fbff3..52f1efe22ab 100644 --- a/spec/integ/matrix-client-relations.spec.ts +++ b/spec/integ/matrix-client-relations.spec.ts @@ -60,7 +60,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with relation type", async () => { @@ -72,7 +72,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with relation type and event type", async () => { @@ -87,7 +87,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with custom options", async () => { @@ -107,7 +107,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it('should use default direction in the fetchRelations endpoint', async () => { @@ -122,6 +122,6 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT" }); + expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT", "originalEvent": null, "prevBatch": null }); }); }); diff --git a/src/client.ts b/src/client.ts index 39c6467a795..c6df3210c7e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5196,6 +5196,10 @@ export class MatrixClient extends TypedEventEmitter Date: Wed, 26 Oct 2022 10:10:24 +0100 Subject: [PATCH 13/29] Fix fetchRoomEvent incorrect return type --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index a4ec564393f..250f6b6750c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -7544,7 +7544,7 @@ export class MatrixClient extends TypedEventEmitter { + public fetchRoomEvent(roomId: string, eventId: string): Promise> { const path = utils.encodeUri( "/rooms/$roomId/event/$eventId", { $roomId: roomId, From d451f827df53ed20c7d2eec000cf816b54a90e23 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 26 Oct 2022 14:02:17 +0200 Subject: [PATCH 14/29] adapt tests for better compatibility --- .../matrix-client-event-timeline.spec.ts | 30 ++++++------------- spec/integ/matrix-client-relations.spec.ts | 2 +- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 507c8150877..8d488eca1b6 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -346,6 +346,10 @@ describe("MatrixClient event timelines", function() { Thread.setServerSideFwdPaginationSupport(FeatureSupport.None); }); + async function flushHttp(promise: Promise): Promise { + return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); + } + describe("getEventTimeline", function() { it("should create a new timeline for new events", function() { const room = client.getRoom(roomId)!; @@ -597,22 +601,8 @@ describe("MatrixClient event timelines", function() { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(FeatureSupport.Experimental); - client.stopClient(); // we don't need the client to be syncing at this time + await client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId)!; - const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); - const timelineSet = thread.timelineSet; - - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) - .respond(200, function() { - return { - start: "start_token0", - events_before: [], - event: THREAD_REPLY, - events_after: [], - end: "end_token0", - state: [], - }; - }); httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function() { @@ -621,7 +611,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1") .respond(200, function() { return { original_event: THREAD_ROOT, @@ -630,9 +620,11 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); await httpBackend.flushAllExpected(); + const timelineSet = thread.timelineSet; + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); const timeline = await timelinePromise; expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); @@ -1027,10 +1019,6 @@ describe("MatrixClient event timelines", function() { }); describe("paginateEventTimeline for thread list timeline", function() { - async function flushHttp(promise: Promise): Promise { - return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); - } - const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; function respondToFilter(): ExpectedHttpRequest { diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts index 52f1efe22ab..456db2efb07 100644 --- a/spec/integ/matrix-client-relations.spec.ts +++ b/spec/integ/matrix-client-relations.spec.ts @@ -122,6 +122,6 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT", "originalEvent": null, "prevBatch": null }); + expect(await response).toEqual({ "chunk": [], "next_batch": "NEXT" }); }); }); From e4455576e18f0a5448f240487a669e81df524c10 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 26 Oct 2022 14:43:28 +0200 Subject: [PATCH 15/29] adapt tests for better compatibility --- .../matrix-client-event-timeline.spec.ts | 28 +++++++++++-------- src/client.ts | 4 +-- src/models/room.ts | 6 +--- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 8d488eca1b6..f2eaff9c6c7 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1040,7 +1040,7 @@ describe("MatrixClient event timelines", function() { next_batch: RANDOM_TOKEN as string | null, }, ): ExpectedHttpRequest { - const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/threads", { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/v1/rooms/$roomId/threads", { $roomId: roomId, })); request.respond(200, response); @@ -1079,8 +1079,9 @@ describe("MatrixClient event timelines", function() { beforeEach(() => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideSupport(FeatureSupport.Stable); Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); }); async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { @@ -1101,7 +1102,7 @@ describe("MatrixClient event timelines", function() { it("should allow you to paginate all threads backwards", async function() { const room = client.getRoom(roomId); - const timelineSets = await (room?.createThreadsTimelineSets()); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); const [allThreads, myThreads] = timelineSets!; await testPagination(allThreads, Direction.Backward); @@ -1110,7 +1111,7 @@ describe("MatrixClient event timelines", function() { it("should allow you to paginate all threads forwards", async function() { const room = client.getRoom(roomId); - const timelineSets = await (room?.createThreadsTimelineSets()); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); const [allThreads, myThreads] = timelineSets!; @@ -1120,7 +1121,7 @@ describe("MatrixClient event timelines", function() { it("should allow fetching all threads", async function() { const room = client.getRoom(roomId)!; - const timelineSets = await room.createThreadsTimelineSets(); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); respondToThreads(); respondToThreads(); @@ -1445,18 +1446,23 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&from=start_token") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [], + }; + }); + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=f&from=end_token") .respond(200, function() { return { original_event: THREAD_ROOT, chunk: [THREAD_REPLY], - // no next batch as this is the oldest end of the timeline }; }); - await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), - httpBackend.flushAllExpected(), - ]); + await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_5", diff --git a/src/client.ts b/src/client.ts index 250f6b6750c..23cae79bfe7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5415,7 +5415,7 @@ export class MatrixClient extends TypedEventEmitter { private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise { let timelineSet: EventTimelineSet; if (Thread.hasServerSideListSupport) { - let threadListType: ThreadFilterType | null = null; - if (Thread.hasServerSideListSupport) { - threadListType = filterType ?? ThreadFilterType.All; - } timelineSet = - new EventTimelineSet(this, this.opts, undefined, undefined, threadListType); + new EventTimelineSet(this, this.opts, undefined, undefined, filterType ?? ThreadFilterType.All); this.reEmitter.reEmit(timelineSet, [ RoomEvent.Timeline, RoomEvent.TimelineReset, From 0961db6f06be8f0e0067f84a19cc061005c1ee0f Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 26 Oct 2022 14:45:05 +0100 Subject: [PATCH 16/29] fix strict null checks --- src/client.ts | 16 ++++++++-------- src/models/event-timeline-set.ts | 2 +- src/models/thread.ts | 6 +++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/client.ts b/src/client.ts index 23cae79bfe7..67d56f7b942 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5268,7 +5268,7 @@ export class MatrixClient extends TypedEventEmitter = { limit: "0", }; - if (this.clientOpts.lazyLoadMembers) { + if (this.clientOpts?.lazyLoadMembers) { params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); } @@ -5444,7 +5444,7 @@ export class MatrixClient extends TypedEventEmitter = { dir: 'b', }; - if (this.clientOpts.lazyLoadMembers) { + if (this.clientOpts?.lazyLoadMembers) { params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); } @@ -5525,7 +5525,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); @@ -5705,13 +5705,13 @@ export class MatrixClient extends TypedEventEmitter { const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null; const result = await this.fetchRelations( diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index e0e5a0339c9..abf20ebf8bf 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -359,7 +359,7 @@ export class EventTimelineSet extends TypedEventEmitter { public clearEventMetadata(event: Optional): void { if (event) { - event.setThread(null); + event.setThread(undefined); delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name]; } } @@ -436,11 +436,11 @@ export enum ThreadFilterType { "All" } -export function threadFilterTypeToFilter(type: ThreadFilterType): 'all' | 'participated' { +export function threadFilterTypeToFilter(type: ThreadFilterType | null): 'all' | 'participated' { switch (type) { case ThreadFilterType.My: return 'participated'; - case ThreadFilterType.All: + default: return 'all'; } } From 68bcb0880498f6dc22860910327ef582d950525d Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 26 Oct 2022 14:02:17 +0200 Subject: [PATCH 17/29] adapt tests for better compatibility --- spec/unit/matrix-client.spec.ts | 248 ++++++++++++++++---------------- spec/unit/room.spec.ts | 73 ++++++++-- src/models/room.ts | 7 +- src/models/thread.ts | 6 +- 4 files changed, 193 insertions(+), 141 deletions(-) diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 91687e8ad2b..8b2cb51f8e7 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -231,6 +231,130 @@ describe("MatrixClient", function() { client.stopClient(); }); + describe("sendEvent", () => { + const roomId = "!room:example.org"; + const body = "This is the body"; + const content = { body }; + + it("overload without threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }]; + + await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("overload with null threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }]; + + await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); + }); + + it("overload with threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + const threadId = "$threadId:server"; + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: threadId, + }, + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const content = { + body, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + }, + }; + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + "event_id": threadId, + "is_falling_back": false, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + }); + it("should create (unstable) file trees", async () => { const userId = "@test:example.org"; const roomId = "!room:example.org"; @@ -777,130 +901,6 @@ describe("MatrixClient", function() { }); }); - describe("sendEvent", () => { - const roomId = "!room:example.org"; - const body = "This is the body"; - const content = { body }; - - it("overload without threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; - - await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("overload with null threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; - - await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); - }); - - it("overload with threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - const threadId = "$threadId:server"; - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("should add thread relation if threadId is passed and the relation is missing", async () => { - const eventId = "$eventId:example.org"; - const threadId = "$threadId:server"; - const txnId = client.makeTxnId(); - - const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); - - const rootEvent = new MatrixEvent({ event_id: threadId }); - room.createThread(threadId, rootEvent, [rootEvent], false); - - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: threadId, - }, - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { - const eventId = "$eventId:example.org"; - const threadId = "$threadId:server"; - const txnId = client.makeTxnId(); - - const content = { - body, - "m.relates_to": { - "m.in_reply_to": { - event_id: "$other:event", - }, - }, - }; - - const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); - - const rootEvent = new MatrixEvent({ event_id: threadId }); - room.createThread(threadId, rootEvent, [rootEvent], false); - - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: "$other:event", - }, - "event_id": threadId, - "is_falling_back": false, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - }); - describe("redactEvent", () => { const roomId = "!room:example.org"; const mockRoom = { diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 29a96eab6a1..75e8ce6d1ab 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -39,7 +39,7 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { emitPromise } from "../test-utils/test-utils"; import { ReceiptType } from "../../src/@types/read_receipts"; -import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread"; +import { FeatureSupport, Thread, ThreadEvent, THREAD_RELATION_TYPE } from "../../src/models/thread"; import { WrappedReceipt } from "../../src/models/read-receipt"; import { Crypto } from "../../src/crypto"; @@ -2203,6 +2203,7 @@ describe("Room", function() { it("Edits update the lastReply event", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const randomMessage = mkMessage(); const threadRoot = mkMessage(); @@ -2216,7 +2217,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse.event, count: 2, current_user_participated: true, @@ -2228,18 +2229,21 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([randomMessage, threadRoot, threadResponse]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); - expect(thread.replyToEvent).toBe(threadResponse); + expect(thread.replyToEvent.event).toEqual(threadResponse.event); expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); - prom = emitPromise(thread, ThreadEvent.Update); + prom = emitPromise(room, ThreadEvent.Update); room.addLiveEvents([threadResponseEdit]); await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); it("Redactions to thread responses decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2252,7 +2256,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2264,20 +2268,35 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); - prom = emitPromise(thread, ThreadEvent.Update); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 1, + current_user_participated: true, + }, + }, + }, + }); + const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); - await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); }); it("Redactions to reactions in threads do not decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2291,7 +2310,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2303,6 +2322,7 @@ describe("Room", function() { const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2315,6 +2335,7 @@ describe("Room", function() { it("should not decrement the length when the thread root is redacted", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2328,7 +2349,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2340,6 +2361,7 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2377,18 +2399,48 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse1.event, + count: 1, + current_user_participated: true, + }, + }, + }, + }); + prom = emitPromise(room, ThreadEvent.Update); const threadResponse2Redaction = mkRedaction(threadResponse2); room.addLiveEvents([threadResponse2Redaction]); await prom; + await thread.initialiseThread(); expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); - prom = emitPromise(room, ThreadEvent.Update); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadRoot.event, + count: 0, + current_user_participated: true, + }, + }, + }, + }); + + prom = emitPromise(room, ThreadEvent.Delete); const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); await prom; @@ -2400,6 +2452,7 @@ describe("Room", function() { describe("eventShouldLiveIn", () => { const client = new TestClient(userA).client; client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const room = new Room(roomId, client, userA); it("thread root and its relations&redactions should be in both", () => { diff --git a/src/models/room.ts b/src/models/room.ts index 53abe9dd719..a2a1e7140d3 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1922,13 +1922,12 @@ export class Room extends ReadReceipt { private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void { let thread = this.getThread(threadId); - if (thread) { - thread.addEvents(events, toStartOfTimeline); - } else { + if (!thread) { const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId); thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); - this.emit(ThreadEvent.Update, thread); } + + thread.addEvents(events, toStartOfTimeline); } /** diff --git a/src/models/thread.ts b/src/models/thread.ts index 41d3ff3d715..53bc237b467 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -182,11 +182,12 @@ export class Thread extends ReadReceipt { for (const threadEvent of this.events) { this.clearEventMetadata(threadEvent); } + this.lastEvent = this.rootEvent; + this._currentUserParticipated = false; this.emit(ThreadEvent.Delete, this); } else { await this.initialiseThread(); } - this.emit(ThreadEvent.Update, this); }; private onEcho = async (event: MatrixEvent) => { @@ -218,7 +219,6 @@ export class Thread extends ReadReceipt { public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); - this.emit(ThreadEvent.Update, this); this.initialiseThread(); } @@ -279,7 +279,7 @@ export class Thread extends ReadReceipt { return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); } - private async initialiseThread(): Promise { + public async initialiseThread(): Promise { let bundledRelationship = this.getRootEventBundledRelationship(); if (Thread.hasServerSideSupport) { await this.fetchRootEvent(); From 0041a5bc1205df741a92c92530b301821ab583a1 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Thu, 27 Oct 2022 12:55:54 +0200 Subject: [PATCH 18/29] Disable three tests that have not been adapted to the changed threads APIs --- spec/unit/room.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 75e8ce6d1ab..827951bdd23 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2201,7 +2201,7 @@ describe("Room", function() { expect(thread).toHaveLength(0); }); - it("Edits update the lastReply event", async () => { + xit("Edits update the lastReply event", async () => { room.client.supportsExperimentalThreads = () => true; Thread.setServerSideSupport(FeatureSupport.Stable); @@ -2241,7 +2241,7 @@ describe("Room", function() { expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); - it("Redactions to thread responses decrement the length", async () => { + xit("Redactions to thread responses decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; Thread.setServerSideSupport(FeatureSupport.Stable); @@ -2373,7 +2373,7 @@ describe("Room", function() { expect(thread).toHaveLength(2); }); - it("Redacting the lastEvent finds a new lastEvent", async () => { + xit("Redacting the lastEvent finds a new lastEvent", async () => { room.client.supportsExperimentalThreads = () => true; const threadRoot = mkMessage(); From 70fab0e6d9af271e98908482767c64e733a1fdf5 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Thu, 27 Oct 2022 14:09:08 +0200 Subject: [PATCH 19/29] fix eslint issues --- spec/unit/room.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 827951bdd23..fcd1d22a423 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2265,7 +2265,7 @@ describe("Room", function() { }, }); - let prom = emitPromise(room, ThreadEvent.New); + const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; await thread.initialiseThread(); From e14aa76cbbe78e10a9f1afd6d1d2218cc2842e6e Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Thu, 27 Oct 2022 14:14:25 +0200 Subject: [PATCH 20/29] fix eslint issues --- src/models/thread.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/thread.ts b/src/models/thread.ts index 53bc237b467..117100d6923 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -84,7 +84,7 @@ export class Thread extends ReadReceipt { private reEmitter: TypedReEmitter; - private lastEvent!: MatrixEvent; + private lastEvent: MatrixEvent | undefined; private replyCount = 0; public readonly room: Room; From a96cc00726b2fd4887447f0354088cdc70091c0b Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 00:39:10 +0200 Subject: [PATCH 21/29] reenabling test --- spec/unit/room.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index fcd1d22a423..b48a63c7ef0 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2241,7 +2241,7 @@ describe("Room", function() { expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); - xit("Redactions to thread responses decrement the length", async () => { + it("Redactions to thread responses decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; Thread.setServerSideSupport(FeatureSupport.Stable); @@ -2373,7 +2373,7 @@ describe("Room", function() { expect(thread).toHaveLength(2); }); - xit("Redacting the lastEvent finds a new lastEvent", async () => { + it("Redacting the lastEvent finds a new lastEvent", async () => { room.client.supportsExperimentalThreads = () => true; const threadRoot = mkMessage(); From 7d189e78792fb3534ae87e6924a6d0a8d4081d90 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:08:57 +0200 Subject: [PATCH 22/29] increase test coverage --- .../matrix-client-event-timeline.spec.ts | 141 ++++++++++-------- 1 file changed, 80 insertions(+), 61 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index f2eaff9c6c7..71e4d8f84c7 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1409,79 +1409,98 @@ describe("MatrixClient event timelines", function() { }); }); - it("should re-insert room IDs for bundled thread relation events", async () => { - // @ts-ignore - client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(FeatureSupport.Experimental); + describe("should re-insert room IDs for bundled thread relation events", () => { + async function doTest() { - httpBackend.when("GET", "/sync").respond(200, { - next_batch: "s_5_4", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - SYNC_THREAD_ROOT, - ], - prev_batch: "f_1_1", + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + [roomId]: { + timeline: { + events: [ + SYNC_THREAD_ROOT, + ], + prev_batch: "f_1_1", + }, }, }, }, - }, - }); - await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + }); + await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - const room = client.getRoom(roomId)!; - const thread = room.getThread(THREAD_ROOT.event_id!)!; - const timelineSet = thread.timelineSet; + const room = client.getRoom(roomId)!; + const thread = room.getThread(THREAD_ROOT.event_id!)!; + const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, { - start: "start_token", - events_before: [], - event: THREAD_ROOT, - events_after: [], - state: [], - end: "end_token", - }); - httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&from=start_token") - .respond(200, function() { - return { - original_event: THREAD_ROOT, - chunk: [], - }; - }); - httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=f&from=end_token") - .respond(200, function() { - return { - original_event: THREAD_ROOT, - chunk: [THREAD_REPLY], - }; - }); - await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, { + start: "start_token", + events_before: [], + event: THREAD_ROOT, + events_after: [], + state: [], + end: "end_token", + }); + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&from=start_token") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [], + }; + }); + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=f&from=end_token") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + }; + }); + const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); - httpBackend.when("GET", "/sync").respond(200, { - next_batch: "s_5_5", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - SYNC_THREAD_REPLY, - ], - prev_batch: "f_1_2", + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_5", + rooms: { + join: { + [roomId]: { + timeline: { + events: [ + SYNC_THREAD_REPLY, + ], + prev_batch: "f_1_2", + }, }, }, }, - }, + }); + + await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + + expect(timeline.getEvents()[1].event).toEqual(THREAD_REPLY); + } + + it("in stable mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); + + return doTest(); }); - await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + it("in backwards compatible mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.None); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.None); - expect(thread.liveTimeline.getEvents()[1].event).toEqual(THREAD_REPLY); + return doTest(); + }); }); }); From 98c46c0e51962cfea4245769d4665e4728880c96 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:16:36 +0200 Subject: [PATCH 23/29] make eslint happy again --- spec/integ/matrix-client-event-timeline.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 71e4d8f84c7..636cf7f4c1e 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1411,7 +1411,6 @@ describe("MatrixClient event timelines", function() { describe("should re-insert room IDs for bundled thread relation events", () => { async function doTest() { - httpBackend.when("GET", "/sync").respond(200, { next_batch: "s_5_4", rooms: { From d345c7dd494de61c202340a04664d3776ebdbe64 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:21:51 +0200 Subject: [PATCH 24/29] make ts-strict happy again --- spec/integ/matrix-client-event-timeline.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 636cf7f4c1e..82e308e7a42 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1479,7 +1479,7 @@ describe("MatrixClient event timelines", function() { await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - expect(timeline.getEvents()[1].event).toEqual(THREAD_REPLY); + expect(timeline.getEvents()[1]!.event).toEqual(THREAD_REPLY); } it("in stable mode", async () => { From d90782e7bbde06cbb0185f8ed448656e1b8c9d7c Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:39:10 +0200 Subject: [PATCH 25/29] adapt test to changed functionality --- spec/unit/room.spec.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index b48a63c7ef0..6eb355ea6b7 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2201,7 +2201,7 @@ describe("Room", function() { expect(thread).toHaveLength(0); }); - xit("Edits update the lastReply event", async () => { + it("Edits update the lastReply event", async () => { room.client.supportsExperimentalThreads = () => true; Thread.setServerSideSupport(FeatureSupport.Stable); @@ -2234,10 +2234,27 @@ describe("Room", function() { expect(thread.replyToEvent.event).toEqual(threadResponse.event); expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: { + ...threadResponse.event, + prev_content: threadResponse.event.content, + content: threadResponseEdit.event.content, + }, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + prom = emitPromise(room, ThreadEvent.Update); room.addLiveEvents([threadResponseEdit]); await prom; - await emitPromise(room, ThreadEvent.Update); expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); From 1565bf177b62b437c7d8b322af719eafa987c7b9 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:46:54 +0200 Subject: [PATCH 26/29] adapt test to changed functionality --- spec/unit/room.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 6eb355ea6b7..dc2e7859761 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2242,7 +2242,6 @@ describe("Room", function() { [THREAD_RELATION_TYPE.name]: { latest_event: { ...threadResponse.event, - prev_content: threadResponse.event.content, content: threadResponseEdit.event.content, }, count: 2, From 66586a4b19c4ee56f47ddc1bd9a1b744a8aa67be Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 01:51:36 +0200 Subject: [PATCH 27/29] make ts-strict happy again --- spec/integ/matrix-client-event-timeline.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 82e308e7a42..232abae1f3f 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1479,7 +1479,7 @@ describe("MatrixClient event timelines", function() { await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - expect(timeline.getEvents()[1]!.event).toEqual(THREAD_REPLY); + expect(timeline!.getEvents()[1]!.event).toEqual(THREAD_REPLY); } it("in stable mode", async () => { From d26c071a16dd9f15cdd9269684667ebaa6926c2d Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 02:41:42 +0200 Subject: [PATCH 28/29] further increase test coverage --- spec/unit/room.spec.ts | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index dc2e7859761..ed15b61fee0 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -2281,14 +2281,25 @@ describe("Room", function() { }, }); - const prom = emitPromise(room, ThreadEvent.New); + let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; - await thread.initialiseThread(); + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + thread.timelineSet.addEventToTimeline( + threadResponse1, + thread.liveTimeline, + { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, + ); + thread.timelineSet.addEventToTimeline( + threadResponse2, + thread.liveTimeline, + { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, + ); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ ...threadRoot.event, unsigned: { @@ -2303,9 +2314,10 @@ describe("Room", function() { }, }); + prom = emitPromise(thread, ThreadEvent.Update); const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); - await thread.initialiseThread(); + await prom; expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); }); @@ -2338,7 +2350,7 @@ describe("Room", function() { const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; - await thread.initialiseThread(); + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2377,7 +2389,7 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; - await thread.initialiseThread(); + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2391,6 +2403,18 @@ describe("Room", function() { it("Redacting the lastEvent finds a new lastEvent", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + + room.client.createThreadListMessagesRequest = () => Promise.resolve({ + start: null, + end: null, + chunk: [], + state: [], + }); + + await room.createThreadsTimelineSets(); + await room.fetchRoomThreads(); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2415,7 +2439,7 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; - await thread.initialiseThread(); + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2438,7 +2462,7 @@ describe("Room", function() { const threadResponse2Redaction = mkRedaction(threadResponse2); room.addLiveEvents([threadResponse2Redaction]); await prom; - await thread.initialiseThread(); + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); @@ -2457,9 +2481,11 @@ describe("Room", function() { }); prom = emitPromise(room, ThreadEvent.Delete); + const prom2 = emitPromise(room, RoomEvent.Timeline); const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); await prom; + await prom2; expect(thread).toHaveLength(0); expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); }); From dda885cfe5481c1e0e16ba25fb88e871305f32a6 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Fri, 28 Oct 2022 03:06:09 +0200 Subject: [PATCH 29/29] further increase test coverage --- .../matrix-client-event-timeline.spec.ts | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 232abae1f3f..4e732184749 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -631,6 +631,40 @@ describe("MatrixClient event timelines", function() { expect(timeline!.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeTruthy(); }); + it("should handle thread replies with server support by fetching a contiguous thread timeline", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + await client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId)!; + + httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, function() { + return THREAD_ROOT; + }); + + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + // no next batch as this is the oldest end of the timeline + }; + }); + + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); + await httpBackend.flushAllExpected(); + const timelineSet = thread.timelineSet; + + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); + const timeline = await timelinePromise; + + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); + expect(timeline!.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeTruthy(); + }); + it("should return relevant timeline from non-thread timelineSet when asking for the thread root", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; @@ -1432,6 +1466,14 @@ describe("MatrixClient event timelines", function() { const thread = room.getThread(THREAD_ROOT.event_id!)!; const timelineSet = thread.timelineSet; + const buildParams = (direction: Direction, token: string): string => { + if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { + return `?from=${token}&org.matrix.msc3715.dir=${direction}`; + } else { + return `?dir=${direction}&from=${token}`; + } + }; + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, { start: "start_token", @@ -1443,7 +1485,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&from=start_token") + encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Backward, "start_token")) .respond(200, function() { return { original_event: THREAD_ROOT, @@ -1452,7 +1494,7 @@ describe("MatrixClient event timelines", function() { }); httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=f&from=end_token") + encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Forward, "end_token")) .respond(200, function() { return { original_event: THREAD_ROOT, @@ -1492,6 +1534,16 @@ describe("MatrixClient event timelines", function() { return doTest(); }); + it("in backwards compatible unstable mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Experimental); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental); + + return doTest(); + }); + it("in backwards compatible mode", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true;