Skip to content

Commit 3a3dcfb

Browse files
authored
Load Thread List with server-side assistance (MSC3856) (#2602)
* feature detection code for thread list api * fix bug where createThreadsTimelineSets would sometimes return nothing * initial implementation of thread listing msc * tests for thread list pagination
1 parent 21a6f61 commit 3a3dcfb

File tree

8 files changed

+681
-118
lines changed

8 files changed

+681
-118
lines changed

spec/integ/matrix-client-event-timeline.spec.ts

Lines changed: 339 additions & 11 deletions
Large diffs are not rendered by default.

spec/unit/room.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
3838
import { TestClient } from "../TestClient";
3939
import { emitPromise } from "../test-utils/test-utils";
4040
import { ReceiptType } from "../../src/@types/read_receipts";
41-
import { Thread, ThreadEvent } from "../../src/models/thread";
41+
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
4242
import { WrappedReceipt } from "../../src/models/read-receipt";
4343

4444
describe("Room", function() {
@@ -2408,7 +2408,7 @@ describe("Room", function() {
24082408
});
24092409

24102410
it("should aggregate relations in thread event timeline set", () => {
2411-
Thread.setServerSideSupport(true, true);
2411+
Thread.setServerSideSupport(FeatureSupport.Stable);
24122412
const threadRoot = mkMessage();
24132413
const rootReaction = mkReaction(threadRoot);
24142414
const threadResponse = mkThreadResponse(threadRoot);

src/client.ts

Lines changed: 173 additions & 43 deletions
Large diffs are not rendered by default.

src/models/event-timeline-set.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,12 +123,15 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
123123
* @param {MatrixClient=} client the Matrix client which owns this EventTimelineSet,
124124
* can be omitted if room is specified.
125125
* @param {Thread=} thread the thread to which this timeline set relates.
126+
* @param {boolean} isThreadTimeline Whether this timeline set relates to a thread list timeline
127+
* (e.g., All threads or My threads)
126128
*/
127129
constructor(
128130
public readonly room: Room | undefined,
129131
opts: IOpts = {},
130132
client?: MatrixClient,
131133
public readonly thread?: Thread,
134+
public readonly isThreadTimeline: boolean = false,
132135
) {
133136
super();
134137

src/models/event-timeline.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export class EventTimeline {
9999
private endState: RoomState;
100100
private prevTimeline?: EventTimeline;
101101
private nextTimeline?: EventTimeline;
102-
public paginationRequests: Record<Direction, Promise<boolean>> = {
102+
public paginationRequests: Record<Direction, Promise<boolean> | null> = {
103103
[Direction.Backward]: null,
104104
[Direction.Forward]: null,
105105
};
@@ -311,7 +311,7 @@ export class EventTimeline {
311311
* token for going backwards in time; EventTimeline.FORWARDS to set the
312312
* pagination token for going forwards in time.
313313
*/
314-
public setPaginationToken(token: string, direction: Direction): void {
314+
public setPaginationToken(token: string | null, direction: Direction): void {
315315
this.getState(direction).paginationToken = token;
316316
}
317317

src/models/room-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
9797
// XXX: Should be read-only
9898
public members: Record<string, RoomMember> = {}; // userId: RoomMember
9999
public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
100-
public paginationToken: string = null;
100+
public paginationToken: string | null = null;
101101

102102
public readonly beacons = new Map<BeaconIdentifier, Beacon>();
103103
private _liveBeaconIds: BeaconIdentifier[] = [];

src/models/room.ts

Lines changed: 132 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
359359
}
360360

361361
private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null;
362-
public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet]> {
362+
public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> {
363363
if (this.threadTimelineSetsPromise) {
364364
return this.threadTimelineSetsPromise;
365365
}
@@ -372,10 +372,13 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
372372
]);
373373
const timelineSets = await this.threadTimelineSetsPromise;
374374
this.threadsTimelineSets.push(...timelineSets);
375+
return timelineSets;
375376
} catch (e) {
376377
this.threadTimelineSetsPromise = null;
378+
return null;
377379
}
378380
}
381+
return null;
379382
}
380383

381384
/**
@@ -1612,7 +1615,14 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
16121615

16131616
private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> {
16141617
let timelineSet: EventTimelineSet;
1615-
if (Thread.hasServerSideSupport) {
1618+
if (Thread.hasServerSideListSupport) {
1619+
timelineSet =
1620+
new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport));
1621+
this.reEmitter.reEmit(timelineSet, [
1622+
RoomEvent.Timeline,
1623+
RoomEvent.TimelineReset,
1624+
]);
1625+
} else if (Thread.hasServerSideSupport) {
16161626
const filter = await this.getThreadListFilter(filterType);
16171627

16181628
timelineSet = this.getOrCreateFilteredTimelineSet(
@@ -1645,81 +1655,148 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
16451655
return timelineSet;
16461656
}
16471657

1648-
public threadsReady = false;
1658+
private threadsReady = false;
1659+
1660+
/**
1661+
* Takes the given thread root events and creates threads for them.
1662+
* @param events
1663+
* @param toStartOfTimeline
1664+
*/
1665+
public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void {
1666+
for (const rootEvent of events) {
1667+
EventTimeline.setEventMetadata(
1668+
rootEvent,
1669+
this.currentState,
1670+
toStartOfTimeline,
1671+
);
1672+
if (!this.getThread(rootEvent.getId())) {
1673+
this.createThread(rootEvent.getId(), rootEvent, [], toStartOfTimeline);
1674+
}
1675+
}
1676+
}
16491677

1678+
/**
1679+
* Fetch the bare minimum of room threads required for the thread list to work reliably.
1680+
* With server support that means fetching one page.
1681+
* Without server support that means fetching as much at once as the server allows us to.
1682+
*/
16501683
public async fetchRoomThreads(): Promise<void> {
16511684
if (this.threadsReady || !this.client.supportsExperimentalThreads()) {
16521685
return;
16531686
}
16541687

1655-
const allThreadsFilter = await this.getThreadListFilter();
1656-
1657-
const { chunk: events } = await this.client.createMessagesRequest(
1658-
this.roomId,
1659-
"",
1660-
Number.MAX_SAFE_INTEGER,
1661-
Direction.Backward,
1662-
allThreadsFilter,
1663-
);
1664-
1665-
if (!events.length) return;
1666-
1667-
// Sorted by last_reply origin_server_ts
1668-
const threadRoots = events
1669-
.map(this.client.getEventMapper())
1670-
.sort((eventA, eventB) => {
1671-
/**
1672-
* `origin_server_ts` in a decentralised world is far from ideal
1673-
* but for lack of any better, we will have to use this
1674-
* Long term the sorting should be handled by homeservers and this
1675-
* is only meant as a short term patch
1676-
*/
1677-
const threadAMetadata = eventA
1678-
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
1679-
const threadBMetadata = eventB
1680-
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
1681-
return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts;
1682-
});
1688+
if (Thread.hasServerSideListSupport) {
1689+
await Promise.all([
1690+
this.fetchRoomThreadList(ThreadFilterType.All),
1691+
this.fetchRoomThreadList(ThreadFilterType.My),
1692+
]);
1693+
} else {
1694+
const allThreadsFilter = await this.getThreadListFilter();
1695+
1696+
const { chunk: events } = await this.client.createMessagesRequest(
1697+
this.roomId,
1698+
"",
1699+
Number.MAX_SAFE_INTEGER,
1700+
Direction.Backward,
1701+
allThreadsFilter,
1702+
);
16831703

1684-
let latestMyThreadsRootEvent: MatrixEvent;
1685-
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
1686-
for (const rootEvent of threadRoots) {
1687-
this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
1688-
duplicateStrategy: DuplicateStrategy.Ignore,
1689-
fromCache: false,
1690-
roomState,
1691-
});
1704+
if (!events.length) return;
1705+
1706+
// Sorted by last_reply origin_server_ts
1707+
const threadRoots = events
1708+
.map(this.client.getEventMapper())
1709+
.sort((eventA, eventB) => {
1710+
/**
1711+
* `origin_server_ts` in a decentralised world is far from ideal
1712+
* but for lack of any better, we will have to use this
1713+
* Long term the sorting should be handled by homeservers and this
1714+
* is only meant as a short term patch
1715+
*/
1716+
const threadAMetadata = eventA
1717+
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
1718+
const threadBMetadata = eventB
1719+
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
1720+
return threadAMetadata.latest_event.origin_server_ts -
1721+
threadBMetadata.latest_event.origin_server_ts;
1722+
});
16921723

1693-
const threadRelationship = rootEvent
1694-
.getServerAggregatedRelation<IThreadBundledRelationship>(RelationType.Thread);
1695-
if (threadRelationship.current_user_participated) {
1696-
this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
1724+
let latestMyThreadsRootEvent: MatrixEvent;
1725+
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
1726+
for (const rootEvent of threadRoots) {
1727+
this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
16971728
duplicateStrategy: DuplicateStrategy.Ignore,
16981729
fromCache: false,
16991730
roomState,
17001731
});
1701-
latestMyThreadsRootEvent = rootEvent;
1702-
}
17031732

1704-
if (!this.getThread(rootEvent.getId())) {
1705-
this.createThread(rootEvent.getId(), rootEvent, [], true);
1733+
const threadRelationship = rootEvent
1734+
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
1735+
if (threadRelationship.current_user_participated) {
1736+
this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
1737+
duplicateStrategy: DuplicateStrategy.Ignore,
1738+
fromCache: false,
1739+
roomState,
1740+
});
1741+
latestMyThreadsRootEvent = rootEvent;
1742+
}
17061743
}
1707-
}
17081744

1709-
this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]);
1710-
if (latestMyThreadsRootEvent) {
1711-
this.client.decryptEventIfNeeded(latestMyThreadsRootEvent);
1745+
this.processThreadRoots(threadRoots, true);
1746+
1747+
this.client.decryptEventIfNeeded(threadRoots[threadRoots.length -1]);
1748+
if (latestMyThreadsRootEvent) {
1749+
this.client.decryptEventIfNeeded(latestMyThreadsRootEvent);
1750+
}
17121751
}
17131752

1753+
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
17141754
this.threadsReady = true;
1755+
}
17151756

1716-
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
1757+
/**
1758+
* Fetch a single page of threadlist messages for the specific thread filter
1759+
* @param filter
1760+
* @private
1761+
*/
1762+
private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
1763+
const timelineSet = filter === ThreadFilterType.My
1764+
? this.threadsTimelineSets[1]
1765+
: this.threadsTimelineSets[0];
1766+
1767+
const { chunk: events, end } = await this.client.createThreadListMessagesRequest(
1768+
this.roomId,
1769+
null,
1770+
undefined,
1771+
Direction.Backward,
1772+
timelineSet.getFilter(),
1773+
);
1774+
1775+
timelineSet.getLiveTimeline().setPaginationToken(end, Direction.Backward);
1776+
1777+
if (!events.length) return;
1778+
1779+
const matrixEvents = events.map(this.client.getEventMapper());
1780+
this.processThreadRoots(matrixEvents, true);
1781+
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
1782+
for (const rootEvent of matrixEvents) {
1783+
timelineSet.addLiveEvent(rootEvent, {
1784+
duplicateStrategy: DuplicateStrategy.Replace,
1785+
fromCache: false,
1786+
roomState,
1787+
});
1788+
}
17171789
}
17181790

17191791
private onThreadNewReply(thread: Thread): void {
1792+
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
17201793
for (const timelineSet of this.threadsTimelineSets) {
17211794
timelineSet.removeEvent(thread.id);
1722-
timelineSet.addLiveEvent(thread.rootEvent);
1795+
timelineSet.addLiveEvent(thread.rootEvent, {
1796+
duplicateStrategy: DuplicateStrategy.Replace,
1797+
fromCache: false,
1798+
roomState,
1799+
});
17231800
}
17241801
}
17251802

@@ -1865,8 +1942,6 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
18651942
this.lastThread = thread;
18661943
}
18671944

1868-
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
1869-
18701945
if (this.threadsReady) {
18711946
this.threadsTimelineSets.forEach(timelineSet => {
18721947
if (thread.rootEvent) {
@@ -1883,6 +1958,8 @@ export class Room extends ReadReceipt<EmittedEvents, RoomEventHandlerMap> {
18831958
});
18841959
}
18851960

1961+
this.emit(ThreadEvent.New, thread, toStartOfTimeline);
1962+
18861963
return thread;
18871964
}
18881965

src/models/thread.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,28 @@ interface IThreadOpts {
5151
client: MatrixClient;
5252
}
5353

54+
export enum FeatureSupport {
55+
None = 0,
56+
Experimental = 1,
57+
Stable = 2
58+
}
59+
60+
export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport {
61+
if (stable) {
62+
return FeatureSupport.Stable;
63+
} else if (unstable) {
64+
return FeatureSupport.Experimental;
65+
} else {
66+
return FeatureSupport.None;
67+
}
68+
}
69+
5470
/**
5571
* @experimental
5672
*/
5773
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
58-
public static hasServerSideSupport: boolean;
74+
public static hasServerSideSupport = FeatureSupport.None;
75+
public static hasServerSideListSupport = FeatureSupport.None;
5976

6077
/**
6178
* A reference to all the events ID at the bottom of the threads
@@ -134,15 +151,23 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
134151
this.emit(ThreadEvent.Update, this);
135152
}
136153

137-
public static setServerSideSupport(hasServerSideSupport: boolean, useStable: boolean): void {
138-
Thread.hasServerSideSupport = hasServerSideSupport;
139-
if (!useStable) {
154+
public static setServerSideSupport(
155+
status: FeatureSupport,
156+
): void {
157+
Thread.hasServerSideSupport = status;
158+
if (status !== FeatureSupport.Stable) {
140159
FILTER_RELATED_BY_SENDERS.setPreferUnstable(true);
141160
FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(true);
142161
THREAD_RELATION_TYPE.setPreferUnstable(true);
143162
}
144163
}
145164

165+
public static setServerSideListSupport(
166+
status: FeatureSupport,
167+
): void {
168+
Thread.hasServerSideListSupport = status;
169+
}
170+
146171
private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
147172
if (event?.isRelation(THREAD_RELATION_TYPE.name) &&
148173
this.room.eventShouldLiveIn(event).threadId === this.id &&

0 commit comments

Comments
 (0)