polfan-server-js-client 0.2.109 → 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.
@@ -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;
@@ -8,6 +8,7 @@ interface EventMap {
8
8
  export interface UnreadSummary {
9
9
  mentionCount: number;
10
10
  unreadTopicCount: number;
11
+ unreadRoomCount: number;
11
12
  isUnread: boolean;
12
13
  }
13
14
  export declare class FollowedTopicsManager extends EventTarget<EventMap> {
@@ -16,6 +17,11 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
16
17
  private readonly followedTopicsPromises;
17
18
  private readonly deferredSession;
18
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;
19
25
  constructor(tracker: ChatStateTracker);
20
26
  /**
21
27
  * Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
@@ -60,6 +66,19 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
60
66
  private invalidateUnreadSummaries;
61
67
  private invalidateUnreadSummariesForRooms;
62
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;
63
82
  private setFollowedTopicsArray;
64
83
  private clearRoomFollowedTopicsStructures;
65
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polfan-server-js-client",
3
- "version": "0.2.109",
3
+ "version": "0.2.111",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -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
- return new Promise((...args) => this.authenticatedResolvers = args);
90
+
91
+ return this.connectPromise;
85
92
  }
86
93
 
87
94
  public disconnect(): void {
88
- this.sendQueue = [];
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.authenticatedResolvers[0]();
143
+ this.settleConnect();
137
144
  this.emit(this.Event.connect);
138
145
  this.sendFromQueue();
139
146
  } else {
140
- this.authenticatedResolvers[1](envelope.data);
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
- this.send('Ping', {}).then(() => {
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
- const collection = this.list.get(spaceId);
48
- collection.set(...event.emoticons);
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
- this.list.deleteAll();
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
  }
@@ -20,6 +20,7 @@ interface EventMap {
20
20
  export interface UnreadSummary {
21
21
  mentionCount: number;
22
22
  unreadTopicCount: number;
23
+ unreadRoomCount: number;
23
24
  isUnread: boolean;
24
25
  }
25
26
 
@@ -29,6 +30,12 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
29
30
  private readonly deferredSession = new DeferredTask();
30
31
  private readonly summariesCache = new Map<string, UnreadSummary>();
31
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
+
32
39
  public constructor(private tracker: ChatStateTracker) {
33
40
  super();
34
41
 
@@ -63,8 +70,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
63
70
  return;
64
71
  }
65
72
 
66
- const isAlreadyCached = roomIds.every(roomId => this.followedTopics.has(roomId));
67
- if (isAlreadyCached) {
73
+ const needsFetch = roomIds.some(roomId => ! this.followedTopics.has(roomId) || this.staleRooms.has(roomId));
74
+ if (! needsFetch) {
68
75
  return;
69
76
  }
70
77
 
@@ -75,7 +82,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
75
82
  const result = await this.tracker.client.send('GetFollowedTopics', {location: {spaceId}});
76
83
  if (result.error) throw result.error;
77
84
 
78
- this.setFollowedTopicsArray(roomIds, result.data.followedTopics);
85
+ this.reconcileRoomsFollowedTopics(roomIds, result.data.followedTopics);
79
86
  }, spaceRegistryKey);
80
87
  }
81
88
 
@@ -91,7 +98,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
91
98
  return undefined;
92
99
  }
93
100
 
94
- if (! this.followedTopics.has(roomId)) {
101
+ if (! this.followedTopics.has(roomId) || this.staleRooms.has(roomId)) {
95
102
  if (this.followedTopicsPromises.notExist(roomId)) {
96
103
  this.followedTopicsPromises.registerByFunction(async () => {
97
104
  const result = await this.tracker.client.send('GetFollowedTopics', {location: {roomId}});
@@ -100,7 +107,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
100
107
  throw result.error;
101
108
  }
102
109
 
103
- this.setFollowedTopicsArray([roomId], result.data.followedTopics);
110
+ this.applyRoomFollowedTopics(roomId, result.data.followedTopics);
111
+ this.invalidateUnreadSummaries(roomId);
104
112
  }, roomId);
105
113
  }
106
114
 
@@ -168,6 +176,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
168
176
 
169
177
  let mentionCount = 0;
170
178
  let unreadTopicCount = 0;
179
+ let unreadRoomCount = 0;
171
180
 
172
181
  for (const roomId of roomIds) {
173
182
  const collection = await this.getForRoom(roomId);
@@ -176,12 +185,17 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
176
185
  continue;
177
186
  }
178
187
 
188
+ let isRoomUnreadCount = false;
179
189
  for (const topic of collection.items) {
180
190
  if (targetTopicId && topic.location.topicId !== targetTopicId) {
181
191
  continue;
182
192
  }
183
193
 
184
194
  if (topic.isUnread) {
195
+ if (!isRoomUnreadCount) {
196
+ unreadRoomCount++;
197
+ isRoomUnreadCount = true;
198
+ }
185
199
  unreadTopicCount++;
186
200
  }
187
201
 
@@ -189,7 +203,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
189
203
  }
190
204
  }
191
205
 
192
- const result = { mentionCount, unreadTopicCount, isUnread: unreadTopicCount > 0 };
206
+ const result = { mentionCount, unreadTopicCount, unreadRoomCount, isUnread: unreadTopicCount > 0 };
193
207
  this.summariesCache.set(cacheKey, result);
194
208
 
195
209
  return result;
@@ -205,7 +219,13 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
205
219
  }
206
220
 
207
221
  private handleSession(ev: Session): void {
208
- this.followedTopics.deleteAll();
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
+ }
209
229
  this.followedTopicsPromises.forgetAll();
210
230
  this.invalidateUnreadSummaries();
211
231
  this.deferredSession.resolve();
@@ -357,6 +377,40 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
357
377
  this.invalidateUnreadSummaries(ev.message.location.roomId, ev.message.location.topicId);
358
378
  }
359
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
+
360
414
  private setFollowedTopicsArray(roomIds: string[], followedTopics: FollowedTopic[]): void {
361
415
  const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
362
416
 
@@ -384,6 +438,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
384
438
  private clearRoomFollowedTopicsStructures(roomId: string): void {
385
439
  this.followedTopics.delete(roomId);
386
440
  this.followedTopicsPromises.forget(roomId);
441
+ this.staleRooms.delete(roomId);
387
442
  this.invalidateUnreadSummaries(roomId);
388
443
  }
389
444
  }
@@ -71,8 +71,27 @@ export class MessagesManager {
71
71
  }
72
72
 
73
73
  private handleSession(ev: Session): void {
74
- this.roomHistories.deleteAll();
75
- ev.state.rooms.forEach(room => this.createHistoryForNewRoom(room));
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
- this.overwrites.deleteAll();
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
- this.relationships.deleteAll();
47
- ev.relationships.forEach(relationship => {
48
- this.relationships.set(relationship);
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;