polfan-server-js-client 0.2.105 → 0.2.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.idea/workspace.xml +32 -33
- package/build/index.cjs.js +533 -118
- package/build/index.cjs.js.map +1 -1
- package/build/index.umd.js +1 -1
- package/build/index.umd.js.map +1 -1
- package/build/types/AbstractChatClient.d.ts +10 -0
- package/build/types/IndexedObjectCollection.d.ts +8 -0
- package/build/types/WebSocketChatClient.d.ts +17 -1
- package/build/types/state-tracker/FollowedTopicsManager.d.ts +18 -0
- package/build/types/state-tracker/RoomMessagesHistory.d.ts +12 -0
- package/build/types/state-tracker/TopicHistoryWindow.d.ts +2 -2
- package/package.json +1 -1
- package/src/AbstractChatClient.ts +22 -0
- package/src/IndexedObjectCollection.ts +31 -0
- package/src/WebSocketChatClient.ts +63 -7
- package/src/state-tracker/AsyncUtils.ts +52 -41
- package/src/state-tracker/EmoticonsManager.ts +72 -68
- package/src/state-tracker/FollowedTopicsManager.ts +54 -6
- package/src/state-tracker/MessagesManager.ts +21 -2
- package/src/state-tracker/PermissionsManager.ts +5 -1
- package/src/state-tracker/RelationshipsManager.ts +5 -5
- package/src/state-tracker/RoomMessagesHistory.ts +41 -1
- package/src/state-tracker/RoomsManager.ts +21 -4
- package/src/state-tracker/SpacesManager.ts +340 -313
- package/src/state-tracker/TopicHistoryWindow.ts +4 -4
- package/src/state-tracker/UsersManager.ts +3 -1
- package/tests/collections.test.ts +92 -0
- package/tests/pending-commands.test.ts +171 -0
- package/tests/state-reconnect.test.ts +257 -0
|
@@ -23,6 +23,12 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
23
23
|
private readonly deferredSession = new DeferredTask();
|
|
24
24
|
private readonly summariesCache = new Map<string, { mentionCount: number, isUnread: boolean }>();
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Rooms whose cached followed-topics are stale after a reconnect and must
|
|
28
|
+
* be refetched (and reconciled in place) the next time they are accessed.
|
|
29
|
+
*/
|
|
30
|
+
private readonly staleRooms = new Set<string>();
|
|
31
|
+
|
|
26
32
|
public constructor(private tracker: ChatStateTracker) {
|
|
27
33
|
super();
|
|
28
34
|
|
|
@@ -57,8 +63,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
57
63
|
return;
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
66
|
+
const needsFetch = roomIds.some(roomId => ! this.followedTopics.has(roomId) || this.staleRooms.has(roomId));
|
|
67
|
+
if (! needsFetch) {
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -69,7 +75,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
69
75
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {spaceId}});
|
|
70
76
|
if (result.error) throw result.error;
|
|
71
77
|
|
|
72
|
-
this.
|
|
78
|
+
this.reconcileRoomsFollowedTopics(roomIds, result.data.followedTopics);
|
|
73
79
|
}, spaceRegistryKey);
|
|
74
80
|
}
|
|
75
81
|
|
|
@@ -85,7 +91,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
85
91
|
return undefined;
|
|
86
92
|
}
|
|
87
93
|
|
|
88
|
-
if (! this.followedTopics.has(roomId)) {
|
|
94
|
+
if (! this.followedTopics.has(roomId) || this.staleRooms.has(roomId)) {
|
|
89
95
|
if (this.followedTopicsPromises.notExist(roomId)) {
|
|
90
96
|
this.followedTopicsPromises.registerByFunction(async () => {
|
|
91
97
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {roomId}});
|
|
@@ -94,7 +100,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
94
100
|
throw result.error;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
this.
|
|
103
|
+
this.applyRoomFollowedTopics(roomId, result.data.followedTopics);
|
|
104
|
+
this.invalidateUnreadSummaries(roomId);
|
|
98
105
|
}, roomId);
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -199,7 +206,13 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
199
206
|
}
|
|
200
207
|
|
|
201
208
|
private handleSession(ev: Session): void {
|
|
202
|
-
|
|
209
|
+
// Keep cached followed-topic collections (they drive unread indicators
|
|
210
|
+
// that would otherwise blank on reconnect), but mark them stale and drop
|
|
211
|
+
// the fetch guards so the next access/caching refetches and reconciles
|
|
212
|
+
// them in place.
|
|
213
|
+
for (const roomId of this.followedTopics.items.keys()) {
|
|
214
|
+
this.staleRooms.add(roomId);
|
|
215
|
+
}
|
|
203
216
|
this.followedTopicsPromises.forgetAll();
|
|
204
217
|
this.invalidateUnreadSummaries();
|
|
205
218
|
this.deferredSession.resolve();
|
|
@@ -351,6 +364,40 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
351
364
|
this.invalidateUnreadSummaries(ev.message.location.roomId, ev.message.location.topicId);
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
/**
|
|
368
|
+
* Reconcile the followed-topics collection for a single room to exactly
|
|
369
|
+
* match the provided list (upsert present, drop absent) without emitting an
|
|
370
|
+
* intermediate empty state, and clear its stale marker. Does not touch the
|
|
371
|
+
* unread summaries cache - callers decide how to invalidate it.
|
|
372
|
+
*/
|
|
373
|
+
private applyRoomFollowedTopics(roomId: string, followedTopics: FollowedTopic[]): void {
|
|
374
|
+
if (! this.followedTopics.has(roomId)) {
|
|
375
|
+
this.followedTopics.set([roomId, new ObservableIndexedObjectCollection<FollowedTopic>(
|
|
376
|
+
followedTopic => followedTopic.location.topicId
|
|
377
|
+
)]);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.followedTopics.get(roomId).reconcile(...followedTopics);
|
|
381
|
+
this.staleRooms.delete(roomId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Reconcile a batch of rooms from a single bulk GetFollowedTopics response.
|
|
386
|
+
* Rooms with no followed topics in the response are reconciled to empty, so
|
|
387
|
+
* topics unfollowed/removed during the downtime are correctly dropped.
|
|
388
|
+
*/
|
|
389
|
+
private reconcileRoomsFollowedTopics(roomIds: string[], followedTopics: FollowedTopic[]): void {
|
|
390
|
+
const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
|
|
391
|
+
|
|
392
|
+
followedTopics.forEach(followedTopic => {
|
|
393
|
+
(roomToTopics[followedTopic.location.roomId] ??= []).push(followedTopic);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
roomIds.forEach(roomId => this.applyRoomFollowedTopics(roomId, roomToTopics[roomId] ?? []));
|
|
397
|
+
|
|
398
|
+
this.invalidateUnreadSummariesForRooms(roomIds);
|
|
399
|
+
}
|
|
400
|
+
|
|
354
401
|
private setFollowedTopicsArray(roomIds: string[], followedTopics: FollowedTopic[]): void {
|
|
355
402
|
const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
|
|
356
403
|
|
|
@@ -378,6 +425,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
378
425
|
private clearRoomFollowedTopicsStructures(roomId: string): void {
|
|
379
426
|
this.followedTopics.delete(roomId);
|
|
380
427
|
this.followedTopicsPromises.forget(roomId);
|
|
428
|
+
this.staleRooms.delete(roomId);
|
|
381
429
|
this.invalidateUnreadSummaries(roomId);
|
|
382
430
|
}
|
|
383
431
|
}
|
|
@@ -71,8 +71,27 @@ export class MessagesManager {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
private handleSession(ev: Session): void {
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const stateRoomIds = new Set(ev.state.rooms.map(room => room.id));
|
|
75
|
+
|
|
76
|
+
// Drop histories only for rooms that no longer exist server-side.
|
|
77
|
+
for (const roomId of Array.from(this.roomHistories.items.keys())) {
|
|
78
|
+
if (! stateRoomIds.has(roomId)) {
|
|
79
|
+
this.roomHistories.delete(roomId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Keep existing histories (preserving loaded messages and, crucially,
|
|
84
|
+
// live-only ephemeral history), create histories for newly joined
|
|
85
|
+
// rooms, and resync survivors against the fresh room snapshot.
|
|
86
|
+
for (const room of ev.state.rooms) {
|
|
87
|
+
const history = this.roomHistories.get(room.id);
|
|
88
|
+
if (history) {
|
|
89
|
+
void history.resync(room);
|
|
90
|
+
} else {
|
|
91
|
+
this.createHistoryForNewRoom(room);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
this.deferredSession.resolve();
|
|
77
96
|
}
|
|
78
97
|
}
|
|
@@ -308,7 +308,11 @@ export class PermissionsManager extends EventTarget<PermissionsManagerEventMap>
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
private handleSession(ev: Session): void {
|
|
311
|
-
|
|
311
|
+
// Drop the fetch guards so permission checks recompute against fresh
|
|
312
|
+
// overwrites (getOverwrites refetches and upserts on the next access),
|
|
313
|
+
// and notify consumers to recompute so bound permission UI is never
|
|
314
|
+
// left showing stale results after a reconnect.
|
|
312
315
|
this.overwritesPromises.forgetAll();
|
|
316
|
+
this.emit('change');
|
|
313
317
|
}
|
|
314
318
|
}
|
|
@@ -43,10 +43,9 @@ export class RelationshipsManager {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
private handleRelationships(ev: Relationships): void {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
46
|
+
// Full list from the server: reconcile in place so a reconnect refetch
|
|
47
|
+
// updates a bound (visible) list without an intermediate empty state.
|
|
48
|
+
this.relationships.reconcile(...ev.relationships);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
private handleNewRelationship(ev: NewRelationship): void {
|
|
@@ -62,7 +61,8 @@ export class RelationshipsManager {
|
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
private handleSession(): void {
|
|
64
|
+
// Keep the (possibly bound) collection; just drop the fetch guard so the
|
|
65
|
+
// next get() refetches and reconciles it in place.
|
|
65
66
|
this.promises.forgetAll();
|
|
66
|
-
this.relationships.deleteAll();
|
|
67
67
|
}
|
|
68
68
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {ChatStateTracker} from "./ChatStateTracker";
|
|
2
2
|
import {NewTopic, Room, RoomUpdated, Topic, TopicDeleted} from "../types/src";
|
|
3
3
|
import {IndexedCollection,} from "../IndexedObjectCollection";
|
|
4
|
-
import {TopicHistoryWindow} from "./TopicHistoryWindow";
|
|
4
|
+
import {TopicHistoryWindow, WindowState} from "./TopicHistoryWindow";
|
|
5
5
|
|
|
6
6
|
export class RoomMessagesHistory {
|
|
7
7
|
private historyWindows = new IndexedCollection<string, TopicHistoryWindow>();
|
|
@@ -39,6 +39,46 @@ export class RoomMessagesHistory {
|
|
|
39
39
|
return this.historyWindows.get(topicId);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Re-synchronise this room's history after a reconnect without discarding
|
|
44
|
+
* the existing window objects (which would blank the UI and, for ephemeral
|
|
45
|
+
* rooms, permanently drop live-only history).
|
|
46
|
+
*
|
|
47
|
+
* The window bindings are preserved; only windows that the application had
|
|
48
|
+
* actually pulled to the latest page (state === LATEST) are refreshed, with
|
|
49
|
+
* a single resetToLatest instead of a chain of catch-up requests. Windows
|
|
50
|
+
* that were never pulled (LIVE) or belong to an ephemeral room are left
|
|
51
|
+
* untouched so their in-memory context survives the reconnect.
|
|
52
|
+
*/
|
|
53
|
+
public async resync(room: Room): Promise<void> {
|
|
54
|
+
this.room = room;
|
|
55
|
+
this.updateTraverseLock(room);
|
|
56
|
+
|
|
57
|
+
if (this.room.defaultTopic) {
|
|
58
|
+
this.createHistoryWindowForTopic(this.room.defaultTopic);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [, window] of Array.from(this.historyWindows.items)) {
|
|
62
|
+
try {
|
|
63
|
+
await window.setTraverseLock(this.traverseLock);
|
|
64
|
+
|
|
65
|
+
// Ephemeral history lives only in memory (the server does not
|
|
66
|
+
// persist it), so never refetch/replace it on reconnect.
|
|
67
|
+
if (this.traverseLock) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (window.state === WindowState.LATEST) {
|
|
72
|
+
await window.resetToLatest(true);
|
|
73
|
+
}
|
|
74
|
+
} catch (_e) {
|
|
75
|
+
// Best effort: the connection can drop again mid-resync. The
|
|
76
|
+
// window keeps its current content and stays usable, so the
|
|
77
|
+
// application can refresh it on demand.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
42
82
|
private async handleRoomUpdated(ev: RoomUpdated): Promise<void> {
|
|
43
83
|
if (this.room.id === ev.room.id) {
|
|
44
84
|
this.room = ev.room;
|
|
@@ -303,16 +303,33 @@ export class RoomsManager {
|
|
|
303
303
|
ev.members,
|
|
304
304
|
)
|
|
305
305
|
]);
|
|
306
|
+
} else {
|
|
307
|
+
// Reconcile into the existing (bound) collection so a reconnect
|
|
308
|
+
// refetch updates it in place instead of leaving stale members.
|
|
309
|
+
this.members.get(ev.id).reconcile(...ev.members);
|
|
306
310
|
}
|
|
307
311
|
}
|
|
308
312
|
|
|
309
313
|
private handleSession(ev: Session): void {
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
+
const stateRoomIds = new Set(ev.state.rooms.map(room => room.id));
|
|
315
|
+
|
|
316
|
+
// Remove only rooms that were left/deleted on the server during the
|
|
317
|
+
// downtime, reusing the cascade cleanup (members, topics, followed
|
|
318
|
+
// topics). Surviving rooms keep their identity and bindings.
|
|
319
|
+
const removedRoomIds = this.list.items
|
|
320
|
+
.filter(room => ! stateRoomIds.has(room.id))
|
|
321
|
+
.map(room => room.id);
|
|
322
|
+
if (removedRoomIds.length) {
|
|
323
|
+
this.deleteRoom(...removedRoomIds);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Invalidate lazy caches so the next access refetches fresh data, but
|
|
327
|
+
// keep the collection objects: handleRoomMembers reconciles into them,
|
|
328
|
+
// so bound views refresh in place without blanking.
|
|
314
329
|
this.membersPromises.forgetAll();
|
|
330
|
+
this.topicsPromises.forgetAll();
|
|
315
331
|
|
|
332
|
+
// Upsert surviving/new rooms from the authoritative snapshot.
|
|
316
333
|
this.addJoinedRooms(...ev.state.rooms);
|
|
317
334
|
|
|
318
335
|
this.deferredSession.resolve();
|