polfan-server-js-client 0.2.105 → 0.2.106

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.
@@ -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;
@@ -11,6 +11,11 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
11
11
  private readonly followedTopicsPromises;
12
12
  private readonly deferredSession;
13
13
  private readonly summariesCache;
14
+ /**
15
+ * Rooms whose cached followed-topics are stale after a reconnect and must
16
+ * be refetched (and reconciled in place) the next time they are accessed.
17
+ */
18
+ private readonly staleRooms;
14
19
  constructor(tracker: ChatStateTracker);
15
20
  /**
16
21
  * Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
@@ -58,6 +63,19 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
58
63
  private invalidateUnreadSummaries;
59
64
  private invalidateUnreadSummariesForRooms;
60
65
  private updateLocallyFollowedTopicOnNewMessage;
66
+ /**
67
+ * Reconcile the followed-topics collection for a single room to exactly
68
+ * match the provided list (upsert present, drop absent) without emitting an
69
+ * intermediate empty state, and clear its stale marker. Does not touch the
70
+ * unread summaries cache - callers decide how to invalidate it.
71
+ */
72
+ private applyRoomFollowedTopics;
73
+ /**
74
+ * Reconcile a batch of rooms from a single bulk GetFollowedTopics response.
75
+ * Rooms with no followed topics in the response are reconciled to empty, so
76
+ * topics unfollowed/removed during the downtime are correctly dropped.
77
+ */
78
+ private reconcileRoomsFollowedTopics;
61
79
  private setFollowedTopicsArray;
62
80
  private clearRoomFollowedTopicsStructures;
63
81
  }
@@ -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.105",
3
+ "version": "0.2.106",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -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;
@@ -1,69 +1,73 @@
1
- import {ChatStateTracker} from "./ChatStateTracker";
2
- import {
3
- IndexedCollection,
4
- ObservableIndexedObjectCollection
5
- } from "../IndexedObjectCollection";
6
- import {Emoticon, EmoticonDeleted, Emoticons, NewEmoticon, SpaceDeleted} from "../types/src";
7
- import {PromiseRegistry} from "./AsyncUtils";
8
-
9
- const GLOBAL_KEY = 'global';
10
-
11
- export class EmoticonsManager {
12
- private list: IndexedCollection<string, ObservableIndexedObjectCollection<Emoticon>> = new IndexedCollection();
13
- private emoticonsPromises = new PromiseRegistry();
14
-
15
- public constructor(private tracker: ChatStateTracker) {
16
- this.tracker.client.on('Emoticons', ev => this.handleEmoticons(ev));
17
- this.tracker.client.on('NewEmoticon', ev => this.handleNewEmoticon(ev));
18
- this.tracker.client.on('EmoticonDeleted', ev => this.handleEmoticonDeleted(ev));
19
- this.tracker.client.on('SpaceDeleted', ev => this.handleSpaceDeleted(ev));
20
- this.tracker.client.on('Session', () => this.handleSession());
21
- }
22
-
23
- public async get(spaceId?: string): Promise<ObservableIndexedObjectCollection<Emoticon>> {
24
- const key = spaceId ?? GLOBAL_KEY;
25
-
26
- if (this.emoticonsPromises.notExist(key)) {
27
- this.emoticonsPromises.registerByFunction(async () => {
28
- const result = await this.tracker.client.send('GetEmoticons', {spaceId});
29
- if (result.error) {
30
- throw result.error;
31
- }
32
- this.handleEmoticons(result.data);
33
- }, key);
34
- }
35
-
36
- await this.emoticonsPromises.get(key);
37
- return this.list.get(key);
38
- }
39
-
40
- private handleEmoticons(event: Emoticons): void {
41
- const spaceId = event.location.spaceId ?? GLOBAL_KEY;
42
-
43
- if (!this.list.has(spaceId)) {
44
- this.list.set([spaceId, new ObservableIndexedObjectCollection<Emoticon>('id')]);
45
- }
46
-
47
- const collection = this.list.get(spaceId);
48
- collection.set(...event.emoticons);
49
- }
50
-
51
- private handleNewEmoticon(ev: NewEmoticon): void {
52
- const collection = this.list.get(ev.emoticon.spaceId ?? GLOBAL_KEY);
53
- collection?.set(ev.emoticon);
54
- }
55
-
56
- private handleEmoticonDeleted(ev: EmoticonDeleted): void {
57
- const collection = this.list.get(ev.spaceId ?? GLOBAL_KEY);
58
- collection?.delete(ev.emoticonId);
59
- }
60
-
61
- private handleSpaceDeleted(event: SpaceDeleted): void {
62
- this.list.delete(event.id);
63
- }
64
-
65
- private handleSession(): void {
66
- this.list.deleteAll();
67
- this.emoticonsPromises.forgetAll();
68
- }
1
+ import {ChatStateTracker} from "./ChatStateTracker";
2
+ import {
3
+ IndexedCollection,
4
+ ObservableIndexedObjectCollection
5
+ } from "../IndexedObjectCollection";
6
+ import {Emoticon, EmoticonDeleted, Emoticons, NewEmoticon, SpaceDeleted} from "../types/src";
7
+ import {PromiseRegistry} from "./AsyncUtils";
8
+
9
+ const GLOBAL_KEY = 'global';
10
+
11
+ export class EmoticonsManager {
12
+ private list: IndexedCollection<string, ObservableIndexedObjectCollection<Emoticon>> = new IndexedCollection();
13
+ private emoticonsPromises = new PromiseRegistry();
14
+
15
+ public constructor(private tracker: ChatStateTracker) {
16
+ this.tracker.client.on('Emoticons', ev => this.handleEmoticons(ev));
17
+ this.tracker.client.on('NewEmoticon', ev => this.handleNewEmoticon(ev));
18
+ this.tracker.client.on('EmoticonDeleted', ev => this.handleEmoticonDeleted(ev));
19
+ this.tracker.client.on('SpaceDeleted', ev => this.handleSpaceDeleted(ev));
20
+ this.tracker.client.on('Session', () => this.handleSession());
21
+ }
22
+
23
+ public async get(spaceId?: string): Promise<ObservableIndexedObjectCollection<Emoticon>> {
24
+ const key = spaceId ?? GLOBAL_KEY;
25
+
26
+ if (this.emoticonsPromises.notExist(key)) {
27
+ this.emoticonsPromises.registerByFunction(async () => {
28
+ const result = await this.tracker.client.send('GetEmoticons', {spaceId});
29
+ if (result.error) {
30
+ throw result.error;
31
+ }
32
+ this.handleEmoticons(result.data);
33
+ }, key);
34
+ }
35
+
36
+ await this.emoticonsPromises.get(key);
37
+ return this.list.get(key);
38
+ }
39
+
40
+ private handleEmoticons(event: Emoticons): void {
41
+ const spaceId = event.location.spaceId ?? GLOBAL_KEY;
42
+
43
+ if (!this.list.has(spaceId)) {
44
+ this.list.set([spaceId, new ObservableIndexedObjectCollection<Emoticon>('id')]);
45
+ }
46
+
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);
51
+ }
52
+
53
+ private handleNewEmoticon(ev: NewEmoticon): void {
54
+ const collection = this.list.get(ev.emoticon.spaceId ?? GLOBAL_KEY);
55
+ collection?.set(ev.emoticon);
56
+ }
57
+
58
+ private handleEmoticonDeleted(ev: EmoticonDeleted): void {
59
+ const collection = this.list.get(ev.spaceId ?? GLOBAL_KEY);
60
+ collection?.delete(ev.emoticonId);
61
+ }
62
+
63
+ private handleSpaceDeleted(event: SpaceDeleted): void {
64
+ this.list.delete(event.id);
65
+ }
66
+
67
+ private handleSession(): void {
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.
71
+ this.emoticonsPromises.forgetAll();
72
+ }
69
73
  }
@@ -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 isAlreadyCached = roomIds.every(roomId => this.followedTopics.has(roomId));
61
- if (isAlreadyCached) {
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.setFollowedTopicsArray(roomIds, result.data.followedTopics);
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.setFollowedTopicsArray([roomId], result.data.followedTopics);
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
- this.followedTopics.deleteAll();
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
- 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,40 @@ 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
+ await window.setTraverseLock(this.traverseLock);
63
+
64
+ // Ephemeral history lives only in memory (the server does not
65
+ // persist it), so never refetch/replace it on reconnect.
66
+ if (this.traverseLock) {
67
+ continue;
68
+ }
69
+
70
+ if (window.state === WindowState.LATEST) {
71
+ await window.resetToLatest(true);
72
+ }
73
+ }
74
+ }
75
+
42
76
  private async handleRoomUpdated(ev: RoomUpdated): Promise<void> {
43
77
  if (this.room.id === ev.room.id) {
44
78
  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
- this.list.deleteAll();
311
- this.topics.deleteAll();
312
- this.topicsPromises.forgetAll();
313
- this.members.deleteAll();
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();