polfan-server-js-client 0.2.110 → 0.2.111
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 +13 -15
- package/build/index.cjs.js +505 -105
- 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 +6 -0
- package/build/types/IndexedObjectCollection.d.ts +8 -0
- package/build/types/WebSocketChatClient.d.ts +12 -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 +18 -0
- package/src/IndexedObjectCollection.ts +31 -0
- package/src/WebSocketChatClient.ts +58 -7
- package/src/state-tracker/AsyncUtils.ts +6 -0
- package/src/state-tracker/EmoticonsManager.ts +7 -3
- 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 +35 -8
- 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 +339 -0
|
@@ -14,6 +14,12 @@ export declare abstract class AbstractChatClient<AdditionalEvents extends ExtraE
|
|
|
14
14
|
protected createPromiseFromCommandEnvelope<CommandT extends keyof CommandsMap>(envelope: Envelope<CommandRequest<CommandT>>): Promise<CommandResult<CommandResponse<CommandT>>>;
|
|
15
15
|
protected handleIncomingEnvelope(envelope: Envelope): void;
|
|
16
16
|
protected handleEnvelopeSendError(envelope: Envelope, error: any): void;
|
|
17
|
+
/**
|
|
18
|
+
* Reject every command that is still waiting for a response.
|
|
19
|
+
* Call this whenever the transport can no longer deliver an answer (the
|
|
20
|
+
* connection dropped).
|
|
21
|
+
*/
|
|
22
|
+
protected failAwaitingResponses(error: any): void;
|
|
17
23
|
}
|
|
18
24
|
export type CommandResult<ResultT> = {
|
|
19
25
|
data?: ResultT;
|
|
@@ -51,6 +51,14 @@ export declare class ObservableIndexedObjectCollection<ItemT, EventMapT extends
|
|
|
51
51
|
set(...items: ItemT[]): void;
|
|
52
52
|
delete(...ids: string[]): void;
|
|
53
53
|
deleteAll(): void;
|
|
54
|
+
/**
|
|
55
|
+
* Bring the collection to exactly match the provided items: upsert every
|
|
56
|
+
* provided item and remove any existing item whose id is not present in the
|
|
57
|
+
* provided set. Emits at most a single `change` event describing both the
|
|
58
|
+
* set and the deleted ids, so bound consumers can update in place without
|
|
59
|
+
* ever observing an intermediate empty state (unlike deleteAll + set).
|
|
60
|
+
*/
|
|
61
|
+
reconcile(...items: ItemT[]): void;
|
|
54
62
|
createMirror(): ObservableIndexedObjectCollection<ItemT, EventMapT>;
|
|
55
63
|
on<EventName extends keyof EventMapT & string>(eventName: EventName, handler: EventHandler<EventMapT[EventName]>): this;
|
|
56
64
|
once<EventName extends keyof EventMapT & string>(eventName: EventName, handler: EventHandler<EventMapT[EventName]>): this;
|
|
@@ -45,7 +45,8 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
|
|
|
45
45
|
protected sendQueue: Envelope[];
|
|
46
46
|
protected connectingTimeoutId: any;
|
|
47
47
|
protected authenticated: boolean;
|
|
48
|
-
protected authenticatedResolvers: [() => void, (error: Error) => void];
|
|
48
|
+
protected authenticatedResolvers: [() => void, (error: Error) => void] | null;
|
|
49
|
+
protected connectPromise: Promise<void> | null;
|
|
49
50
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
50
51
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
51
52
|
protected lastReceivedMessageAt?: number;
|
|
@@ -57,6 +58,16 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
|
|
|
57
58
|
private sendEnvelope;
|
|
58
59
|
private onMessage;
|
|
59
60
|
private onClose;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve (or reject, when an error is given) a pending connect() promise.
|
|
63
|
+
* No-op when there is nothing pending.
|
|
64
|
+
*/
|
|
65
|
+
private settleConnect;
|
|
66
|
+
/**
|
|
67
|
+
* Reject every command that has not been answered yet - both the ones still
|
|
68
|
+
* waiting in the send queue and the ones already sent to the server.
|
|
69
|
+
*/
|
|
70
|
+
private failPendingCommands;
|
|
60
71
|
private sendFromQueue;
|
|
61
72
|
private triggerConnectionTimeout;
|
|
62
73
|
private isConnectingWsState;
|
|
@@ -17,6 +17,11 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
17
17
|
private readonly followedTopicsPromises;
|
|
18
18
|
private readonly deferredSession;
|
|
19
19
|
private readonly summariesCache;
|
|
20
|
+
/**
|
|
21
|
+
* Rooms whose cached followed-topics are stale after a reconnect and must
|
|
22
|
+
* be refetched (and reconciled in place) the next time they are accessed.
|
|
23
|
+
*/
|
|
24
|
+
private readonly staleRooms;
|
|
20
25
|
constructor(tracker: ChatStateTracker);
|
|
21
26
|
/**
|
|
22
27
|
* Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
|
|
@@ -61,6 +66,19 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
61
66
|
private invalidateUnreadSummaries;
|
|
62
67
|
private invalidateUnreadSummariesForRooms;
|
|
63
68
|
private updateLocallyFollowedTopicOnNewMessage;
|
|
69
|
+
/**
|
|
70
|
+
* Reconcile the followed-topics collection for a single room to exactly
|
|
71
|
+
* match the provided list (upsert present, drop absent) without emitting an
|
|
72
|
+
* intermediate empty state, and clear its stale marker. Does not touch the
|
|
73
|
+
* unread summaries cache - callers decide how to invalidate it.
|
|
74
|
+
*/
|
|
75
|
+
private applyRoomFollowedTopics;
|
|
76
|
+
/**
|
|
77
|
+
* Reconcile a batch of rooms from a single bulk GetFollowedTopics response.
|
|
78
|
+
* Rooms with no followed topics in the response are reconciled to empty, so
|
|
79
|
+
* topics unfollowed/removed during the downtime are correctly dropped.
|
|
80
|
+
*/
|
|
81
|
+
private reconcileRoomsFollowedTopics;
|
|
64
82
|
private setFollowedTopicsArray;
|
|
65
83
|
private clearRoomFollowedTopicsStructures;
|
|
66
84
|
}
|
|
@@ -13,6 +13,18 @@ export declare class RoomMessagesHistory {
|
|
|
13
13
|
* @param peek If true, do not create a cache for this topic and do not allow it to collect new messages.
|
|
14
14
|
*/
|
|
15
15
|
getMessagesWindow(topicId: string, peek?: boolean): Promise<TopicHistoryWindow | undefined>;
|
|
16
|
+
/**
|
|
17
|
+
* Re-synchronise this room's history after a reconnect without discarding
|
|
18
|
+
* the existing window objects (which would blank the UI and, for ephemeral
|
|
19
|
+
* rooms, permanently drop live-only history).
|
|
20
|
+
*
|
|
21
|
+
* The window bindings are preserved; only windows that the application had
|
|
22
|
+
* actually pulled to the latest page (state === LATEST) are refreshed, with
|
|
23
|
+
* a single resetToLatest instead of a chain of catch-up requests. Windows
|
|
24
|
+
* that were never pulled (LIVE) or belong to an ephemeral room are left
|
|
25
|
+
* untouched so their in-memory context survives the reconnect.
|
|
26
|
+
*/
|
|
27
|
+
resync(room: Room): Promise<void>;
|
|
16
28
|
private handleRoomUpdated;
|
|
17
29
|
private handleNewTopic;
|
|
18
30
|
private handleTopicDeleted;
|
|
@@ -64,7 +64,7 @@ export declare abstract class TraversableRemoteCollection<ItemT, EventMapT exten
|
|
|
64
64
|
get hasLatest(): boolean;
|
|
65
65
|
get hasOldest(): boolean;
|
|
66
66
|
abstract createMirror(): TraversableRemoteCollection<ItemT, EventMapT>;
|
|
67
|
-
resetToLatest(): Promise<void>;
|
|
67
|
+
resetToLatest(force?: boolean): Promise<void>;
|
|
68
68
|
fetchPrevious(): Promise<void>;
|
|
69
69
|
fetchNext(): Promise<void>;
|
|
70
70
|
jumpTo(id: string): Promise<void>;
|
|
@@ -99,7 +99,7 @@ export declare class TopicHistoryWindow extends TraversableRemoteCollection<Mess
|
|
|
99
99
|
createMirror(): TopicHistoryWindow;
|
|
100
100
|
get isTraverseLocked(): boolean;
|
|
101
101
|
setTraverseLock(lock: boolean): Promise<void>;
|
|
102
|
-
resetToLatest(): Promise<void>;
|
|
102
|
+
resetToLatest(force?: boolean): Promise<void>;
|
|
103
103
|
fetchNext(): Promise<void>;
|
|
104
104
|
fetchPrevious(): Promise<void>;
|
|
105
105
|
jumpTo(id: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -165,6 +165,24 @@ export abstract class AbstractChatClient<AdditionalEvents extends ExtraEventMap
|
|
|
165
165
|
this.awaitingResponse.get(envelope.ref)[1](error);
|
|
166
166
|
this.awaitingResponse.delete(envelope.ref);
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Reject every command that is still waiting for a response.
|
|
171
|
+
* Call this whenever the transport can no longer deliver an answer (the
|
|
172
|
+
* connection dropped).
|
|
173
|
+
*/
|
|
174
|
+
protected failAwaitingResponses(error: any): void {
|
|
175
|
+
if (! this.awaitingResponse.size) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const pending = Array.from(this.awaitingResponse.values());
|
|
180
|
+
this.awaitingResponse.clear();
|
|
181
|
+
|
|
182
|
+
for (const [, reject] of pending) {
|
|
183
|
+
reject(error);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
export type CommandResult<ResultT> = {data?: ResultT, error?: ErrorType};
|
|
@@ -226,6 +226,37 @@ export class ObservableIndexedObjectCollection<
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Bring the collection to exactly match the provided items: upsert every
|
|
231
|
+
* provided item and remove any existing item whose id is not present in the
|
|
232
|
+
* provided set. Emits at most a single `change` event describing both the
|
|
233
|
+
* set and the deleted ids, so bound consumers can update in place without
|
|
234
|
+
* ever observing an intermediate empty state (unlike deleteAll + set).
|
|
235
|
+
*/
|
|
236
|
+
public reconcile(...items: ItemT[]) {
|
|
237
|
+
const incomingIds = new Set(items.map(item => this.getId(item)));
|
|
238
|
+
const deletedItems: string[] = [];
|
|
239
|
+
|
|
240
|
+
for (const existing of this.items) {
|
|
241
|
+
const id = this.getId(existing);
|
|
242
|
+
if (! incomingIds.has(id)) {
|
|
243
|
+
deletedItems.push(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (! items.length && ! deletedItems.length) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this._items.delete(...deletedItems);
|
|
252
|
+
super.set(...items);
|
|
253
|
+
|
|
254
|
+
this.eventTarget.emit('change', {
|
|
255
|
+
setItems: items.map(item => this.getId(item)),
|
|
256
|
+
deletedItems,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
229
260
|
public createMirror(): ObservableIndexedObjectCollection<ItemT, EventMapT> {
|
|
230
261
|
const copy = new ObservableIndexedObjectCollection<ItemT, EventMapT>(this.id);
|
|
231
262
|
copy.eventTarget = this.eventTarget;
|
|
@@ -48,7 +48,8 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
48
48
|
protected sendQueue: Envelope[] = [];
|
|
49
49
|
protected connectingTimeoutId: any;
|
|
50
50
|
protected authenticated: boolean;
|
|
51
|
-
protected authenticatedResolvers: [() => void, (error: Error) => void];
|
|
51
|
+
protected authenticatedResolvers: [() => void, (error: Error) => void] | null = null;
|
|
52
|
+
protected connectPromise: Promise<void> | null = null;
|
|
52
53
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
53
54
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
54
55
|
protected lastReceivedMessageAt?: number;
|
|
@@ -67,9 +68,14 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
67
68
|
|
|
68
69
|
public async connect(): Promise<void> {
|
|
69
70
|
if (this.isOpenWsState() || this.isConnectingWsState()) {
|
|
70
|
-
return;
|
|
71
|
+
return this.connectPromise ?? undefined;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// Reuse the promise of an attempt that has not settled yet (an
|
|
75
|
+
// automatic reconnect), so the caller that started connecting is
|
|
76
|
+
// resolved by whichever attempt eventually authenticates.
|
|
77
|
+
this.connectPromise ??= new Promise<void>((...args) => this.authenticatedResolvers = args);
|
|
78
|
+
|
|
73
79
|
const params = new URLSearchParams(this.options.queryParams ?? {});
|
|
74
80
|
params.set('token', this.options.token);
|
|
75
81
|
|
|
@@ -81,11 +87,12 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
81
87
|
this.options.connectingTimeoutMs ?? 10000
|
|
82
88
|
);
|
|
83
89
|
this.authenticated = false;
|
|
84
|
-
|
|
90
|
+
|
|
91
|
+
return this.connectPromise;
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
public disconnect(): void {
|
|
88
|
-
this.
|
|
95
|
+
this.failPendingCommands(new Error('Client disconnected before the command was answered'));
|
|
89
96
|
this.ws?.close(1000); // Normal closure
|
|
90
97
|
this.ws = null;
|
|
91
98
|
}
|
|
@@ -133,11 +140,11 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
133
140
|
this.authenticated = isAuthenticated;
|
|
134
141
|
if (isAuthenticated) {
|
|
135
142
|
this.startConnectionMonitor();
|
|
136
|
-
this.
|
|
143
|
+
this.settleConnect();
|
|
137
144
|
this.emit(this.Event.connect);
|
|
138
145
|
this.sendFromQueue();
|
|
139
146
|
} else {
|
|
140
|
-
this.
|
|
147
|
+
this.settleConnect(envelope.data);
|
|
141
148
|
}
|
|
142
149
|
}
|
|
143
150
|
}
|
|
@@ -146,12 +153,54 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
146
153
|
this.stopConnectionMonitor();
|
|
147
154
|
clearTimeout(this.connectingTimeoutId);
|
|
148
155
|
const reconnect = event.code !== 1000; // Connection was closed because of error
|
|
156
|
+
|
|
157
|
+
// The server can no longer answer anything that was queued or in
|
|
158
|
+
// flight, so settle those promises instead of leaving them pending.
|
|
159
|
+
this.failPendingCommands(new Error('Connection closed before the command was answered'));
|
|
160
|
+
|
|
149
161
|
if (reconnect) {
|
|
162
|
+
// Keep a pending connect() promise unsettled - the retry below is
|
|
163
|
+
// expected to authenticate and will resolve it.
|
|
150
164
|
void this.connect();
|
|
165
|
+
} else {
|
|
166
|
+
this.settleConnect(new Error('Connection closed before authentication'));
|
|
151
167
|
}
|
|
168
|
+
|
|
152
169
|
this.emit(this.Event.disconnect, reconnect);
|
|
153
170
|
}
|
|
154
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Resolve (or reject, when an error is given) a pending connect() promise.
|
|
174
|
+
* No-op when there is nothing pending.
|
|
175
|
+
*/
|
|
176
|
+
private settleConnect(error?: any): void {
|
|
177
|
+
const resolvers = this.authenticatedResolvers;
|
|
178
|
+
|
|
179
|
+
this.authenticatedResolvers = null;
|
|
180
|
+
this.connectPromise = null;
|
|
181
|
+
|
|
182
|
+
if (! resolvers) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
error ? resolvers[1](error) : resolvers[0]();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reject every command that has not been answered yet - both the ones still
|
|
191
|
+
* waiting in the send queue and the ones already sent to the server.
|
|
192
|
+
*/
|
|
193
|
+
private failPendingCommands(error: Error): void {
|
|
194
|
+
const queued = this.sendQueue;
|
|
195
|
+
this.sendQueue = [];
|
|
196
|
+
|
|
197
|
+
for (const envelope of queued) {
|
|
198
|
+
this.handleEnvelopeSendError(envelope, error);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.failAwaitingResponses(error);
|
|
202
|
+
}
|
|
203
|
+
|
|
155
204
|
private sendFromQueue(): void {
|
|
156
205
|
// Send awaiting data to server
|
|
157
206
|
let lastDelay = 0;
|
|
@@ -198,7 +247,9 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
198
247
|
this.ws.close(3000); // Service Restart (reconnect)
|
|
199
248
|
}, this.options.ping.pongBackTimeoutMs);
|
|
200
249
|
|
|
201
|
-
|
|
250
|
+
// A rejection here means the connection dropped while the ping was
|
|
251
|
+
// in flight; onClose already handles that, so just stop waiting.
|
|
252
|
+
this.send('Ping', {}).catch(() => undefined).then(() => {
|
|
202
253
|
clearTimeout(this.inFlightPingTimeout);
|
|
203
254
|
this.inFlightPingTimeout = undefined;
|
|
204
255
|
});
|
|
@@ -14,6 +14,12 @@ export class PromiseRegistry {
|
|
|
14
14
|
|
|
15
15
|
public register<T = any>(promise: Promise<T>, key: string): void {
|
|
16
16
|
this.promises.set([key, promise]);
|
|
17
|
+
|
|
18
|
+
promise.catch(() => {
|
|
19
|
+
if (this.promises.get(key) === promise) {
|
|
20
|
+
this.promises.delete(key);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
public registerByFunction(fn: () => Promise<any>, key: string): void {
|
|
@@ -44,8 +44,10 @@ export class EmoticonsManager {
|
|
|
44
44
|
this.list.set([spaceId, new ObservableIndexedObjectCollection<Emoticon>('id')]);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// handleEmoticons always carries the full list for a location, so
|
|
48
|
+
// reconcile in place: a reconnect refetch drops removed emoticons and
|
|
49
|
+
// adds new ones without blanking a bound (visible) collection.
|
|
50
|
+
this.list.get(spaceId).reconcile(...event.emoticons);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
private handleNewEmoticon(ev: NewEmoticon): void {
|
|
@@ -63,7 +65,9 @@ export class EmoticonsManager {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
private handleSession(): void {
|
|
66
|
-
|
|
68
|
+
// Keep cached emoticon collections (they may be bound to visible
|
|
69
|
+
// pickers); just drop the fetch guards so the next access refetches and
|
|
70
|
+
// reconciles them in place.
|
|
67
71
|
this.emoticonsPromises.forgetAll();
|
|
68
72
|
}
|
|
69
73
|
}
|
|
@@ -30,6 +30,12 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
30
30
|
private readonly deferredSession = new DeferredTask();
|
|
31
31
|
private readonly summariesCache = new Map<string, UnreadSummary>();
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Rooms whose cached followed-topics are stale after a reconnect and must
|
|
35
|
+
* be refetched (and reconciled in place) the next time they are accessed.
|
|
36
|
+
*/
|
|
37
|
+
private readonly staleRooms = new Set<string>();
|
|
38
|
+
|
|
33
39
|
public constructor(private tracker: ChatStateTracker) {
|
|
34
40
|
super();
|
|
35
41
|
|
|
@@ -64,8 +70,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
64
70
|
return;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
73
|
+
const needsFetch = roomIds.some(roomId => ! this.followedTopics.has(roomId) || this.staleRooms.has(roomId));
|
|
74
|
+
if (! needsFetch) {
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
71
77
|
|
|
@@ -76,7 +82,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
76
82
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {spaceId}});
|
|
77
83
|
if (result.error) throw result.error;
|
|
78
84
|
|
|
79
|
-
this.
|
|
85
|
+
this.reconcileRoomsFollowedTopics(roomIds, result.data.followedTopics);
|
|
80
86
|
}, spaceRegistryKey);
|
|
81
87
|
}
|
|
82
88
|
|
|
@@ -92,7 +98,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
92
98
|
return undefined;
|
|
93
99
|
}
|
|
94
100
|
|
|
95
|
-
if (! this.followedTopics.has(roomId)) {
|
|
101
|
+
if (! this.followedTopics.has(roomId) || this.staleRooms.has(roomId)) {
|
|
96
102
|
if (this.followedTopicsPromises.notExist(roomId)) {
|
|
97
103
|
this.followedTopicsPromises.registerByFunction(async () => {
|
|
98
104
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {roomId}});
|
|
@@ -101,7 +107,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
101
107
|
throw result.error;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
|
-
this.
|
|
110
|
+
this.applyRoomFollowedTopics(roomId, result.data.followedTopics);
|
|
111
|
+
this.invalidateUnreadSummaries(roomId);
|
|
105
112
|
}, roomId);
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -212,7 +219,13 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
private handleSession(ev: Session): void {
|
|
215
|
-
|
|
222
|
+
// Keep cached followed-topic collections (they drive unread indicators
|
|
223
|
+
// that would otherwise blank on reconnect), but mark them stale and drop
|
|
224
|
+
// the fetch guards so the next access/caching refetches and reconciles
|
|
225
|
+
// them in place.
|
|
226
|
+
for (const roomId of this.followedTopics.items.keys()) {
|
|
227
|
+
this.staleRooms.add(roomId);
|
|
228
|
+
}
|
|
216
229
|
this.followedTopicsPromises.forgetAll();
|
|
217
230
|
this.invalidateUnreadSummaries();
|
|
218
231
|
this.deferredSession.resolve();
|
|
@@ -364,6 +377,40 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
364
377
|
this.invalidateUnreadSummaries(ev.message.location.roomId, ev.message.location.topicId);
|
|
365
378
|
}
|
|
366
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Reconcile the followed-topics collection for a single room to exactly
|
|
382
|
+
* match the provided list (upsert present, drop absent) without emitting an
|
|
383
|
+
* intermediate empty state, and clear its stale marker. Does not touch the
|
|
384
|
+
* unread summaries cache - callers decide how to invalidate it.
|
|
385
|
+
*/
|
|
386
|
+
private applyRoomFollowedTopics(roomId: string, followedTopics: FollowedTopic[]): void {
|
|
387
|
+
if (! this.followedTopics.has(roomId)) {
|
|
388
|
+
this.followedTopics.set([roomId, new ObservableIndexedObjectCollection<FollowedTopic>(
|
|
389
|
+
followedTopic => followedTopic.location.topicId
|
|
390
|
+
)]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
this.followedTopics.get(roomId).reconcile(...followedTopics);
|
|
394
|
+
this.staleRooms.delete(roomId);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Reconcile a batch of rooms from a single bulk GetFollowedTopics response.
|
|
399
|
+
* Rooms with no followed topics in the response are reconciled to empty, so
|
|
400
|
+
* topics unfollowed/removed during the downtime are correctly dropped.
|
|
401
|
+
*/
|
|
402
|
+
private reconcileRoomsFollowedTopics(roomIds: string[], followedTopics: FollowedTopic[]): void {
|
|
403
|
+
const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
|
|
404
|
+
|
|
405
|
+
followedTopics.forEach(followedTopic => {
|
|
406
|
+
(roomToTopics[followedTopic.location.roomId] ??= []).push(followedTopic);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
roomIds.forEach(roomId => this.applyRoomFollowedTopics(roomId, roomToTopics[roomId] ?? []));
|
|
410
|
+
|
|
411
|
+
this.invalidateUnreadSummariesForRooms(roomIds);
|
|
412
|
+
}
|
|
413
|
+
|
|
367
414
|
private setFollowedTopicsArray(roomIds: string[], followedTopics: FollowedTopic[]): void {
|
|
368
415
|
const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
|
|
369
416
|
|
|
@@ -391,6 +438,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
391
438
|
private clearRoomFollowedTopicsStructures(roomId: string): void {
|
|
392
439
|
this.followedTopics.delete(roomId);
|
|
393
440
|
this.followedTopicsPromises.forget(roomId);
|
|
441
|
+
this.staleRooms.delete(roomId);
|
|
394
442
|
this.invalidateUnreadSummaries(roomId);
|
|
395
443
|
}
|
|
396
444
|
}
|
|
@@ -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();
|