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.
@@ -14,6 +14,16 @@ 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
+ *
20
+ * Call this whenever the transport can no longer deliver an answer (the
21
+ * connection dropped). The server will never reply to those commands, so
22
+ * leaving them pending would hang every caller awaiting them forever -
23
+ * including cached lookups in the state tracker, which would then keep a
24
+ * view stuck on a loader even after a successful reconnect.
25
+ */
26
+ protected failAwaitingResponses(error: any): void;
17
27
  }
18
28
  export type CommandResult<ResultT> = {
19
29
  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,13 @@ 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
+ /**
50
+ * Pending promise returned by connect(). Kept until the client is either
51
+ * authenticated or gives up, so that an automatic reconnect settles the
52
+ * original caller instead of stranding it on a superseded promise.
53
+ */
54
+ protected connectPromise: Promise<void> | null;
49
55
  protected pingMonitorInterval?: NodeJS.Timeout;
50
56
  protected inFlightPingTimeout: NodeJS.Timeout;
51
57
  protected lastReceivedMessageAt?: number;
@@ -57,6 +63,16 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
57
63
  private sendEnvelope;
58
64
  private onMessage;
59
65
  private onClose;
66
+ /**
67
+ * Resolve (or reject, when an error is given) a pending connect() promise.
68
+ * No-op when there is nothing pending.
69
+ */
70
+ private settleConnect;
71
+ /**
72
+ * Reject every command that has not been answered yet - both the ones still
73
+ * waiting in the send queue and the ones already sent to the server.
74
+ */
75
+ private failPendingCommands;
60
76
  private sendFromQueue;
61
77
  private triggerConnectionTimeout;
62
78
  private isConnectingWsState;
@@ -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.107",
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,28 @@ 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
+ *
172
+ * Call this whenever the transport can no longer deliver an answer (the
173
+ * connection dropped). The server will never reply to those commands, so
174
+ * leaving them pending would hang every caller awaiting them forever -
175
+ * including cached lookups in the state tracker, which would then keep a
176
+ * view stuck on a loader even after a successful reconnect.
177
+ */
178
+ protected failAwaitingResponses(error: any): void {
179
+ if (! this.awaitingResponse.size) {
180
+ return;
181
+ }
182
+
183
+ const pending = Array.from(this.awaitingResponse.values());
184
+ this.awaitingResponse.clear();
185
+
186
+ for (const [, reject] of pending) {
187
+ reject(error);
188
+ }
189
+ }
168
190
  }
169
191
 
170
192
  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,13 @@ 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
+ /**
53
+ * Pending promise returned by connect(). Kept until the client is either
54
+ * authenticated or gives up, so that an automatic reconnect settles the
55
+ * original caller instead of stranding it on a superseded promise.
56
+ */
57
+ protected connectPromise: Promise<void> | null = null;
52
58
  protected pingMonitorInterval?: NodeJS.Timeout;
53
59
  protected inFlightPingTimeout: NodeJS.Timeout;
54
60
  protected lastReceivedMessageAt?: number;
@@ -67,9 +73,14 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
67
73
 
68
74
  public async connect(): Promise<void> {
69
75
  if (this.isOpenWsState() || this.isConnectingWsState()) {
70
- return;
76
+ return this.connectPromise ?? undefined;
71
77
  }
72
78
 
79
+ // Reuse the promise of an attempt that has not settled yet (an
80
+ // automatic reconnect), so the caller that started connecting is
81
+ // resolved by whichever attempt eventually authenticates.
82
+ this.connectPromise ??= new Promise<void>((...args) => this.authenticatedResolvers = args);
83
+
73
84
  const params = new URLSearchParams(this.options.queryParams ?? {});
74
85
  params.set('token', this.options.token);
75
86
 
@@ -81,11 +92,12 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
81
92
  this.options.connectingTimeoutMs ?? 10000
82
93
  );
83
94
  this.authenticated = false;
84
- return new Promise((...args) => this.authenticatedResolvers = args);
95
+
96
+ return this.connectPromise;
85
97
  }
86
98
 
87
99
  public disconnect(): void {
88
- this.sendQueue = [];
100
+ this.failPendingCommands(new Error('Client disconnected before the command was answered'));
89
101
  this.ws?.close(1000); // Normal closure
90
102
  this.ws = null;
91
103
  }
@@ -133,11 +145,11 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
133
145
  this.authenticated = isAuthenticated;
134
146
  if (isAuthenticated) {
135
147
  this.startConnectionMonitor();
136
- this.authenticatedResolvers[0]();
148
+ this.settleConnect();
137
149
  this.emit(this.Event.connect);
138
150
  this.sendFromQueue();
139
151
  } else {
140
- this.authenticatedResolvers[1](envelope.data);
152
+ this.settleConnect(envelope.data);
141
153
  }
142
154
  }
143
155
  }
@@ -146,12 +158,54 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
146
158
  this.stopConnectionMonitor();
147
159
  clearTimeout(this.connectingTimeoutId);
148
160
  const reconnect = event.code !== 1000; // Connection was closed because of error
161
+
162
+ // The server can no longer answer anything that was queued or in
163
+ // flight, so settle those promises instead of leaving them pending.
164
+ this.failPendingCommands(new Error('Connection closed before the command was answered'));
165
+
149
166
  if (reconnect) {
167
+ // Keep a pending connect() promise unsettled - the retry below is
168
+ // expected to authenticate and will resolve it.
150
169
  void this.connect();
170
+ } else {
171
+ this.settleConnect(new Error('Connection closed before authentication'));
151
172
  }
173
+
152
174
  this.emit(this.Event.disconnect, reconnect);
153
175
  }
154
176
 
177
+ /**
178
+ * Resolve (or reject, when an error is given) a pending connect() promise.
179
+ * No-op when there is nothing pending.
180
+ */
181
+ private settleConnect(error?: any): void {
182
+ const resolvers = this.authenticatedResolvers;
183
+
184
+ this.authenticatedResolvers = null;
185
+ this.connectPromise = null;
186
+
187
+ if (! resolvers) {
188
+ return;
189
+ }
190
+
191
+ error ? resolvers[1](error) : resolvers[0]();
192
+ }
193
+
194
+ /**
195
+ * Reject every command that has not been answered yet - both the ones still
196
+ * waiting in the send queue and the ones already sent to the server.
197
+ */
198
+ private failPendingCommands(error: Error): void {
199
+ const queued = this.sendQueue;
200
+ this.sendQueue = [];
201
+
202
+ for (const envelope of queued) {
203
+ this.handleEnvelopeSendError(envelope, error);
204
+ }
205
+
206
+ this.failAwaitingResponses(error);
207
+ }
208
+
155
209
  private sendFromQueue(): void {
156
210
  // Send awaiting data to server
157
211
  let lastDelay = 0;
@@ -198,7 +252,9 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
198
252
  this.ws.close(3000); // Service Restart (reconnect)
199
253
  }, this.options.ping.pongBackTimeoutMs);
200
254
 
201
- this.send('Ping', {}).then(() => {
255
+ // A rejection here means the connection dropped while the ping was
256
+ // in flight; onClose already handles that, so just stop waiting.
257
+ this.send('Ping', {}).catch(() => undefined).then(() => {
202
258
  clearTimeout(this.inFlightPingTimeout);
203
259
  this.inFlightPingTimeout = undefined;
204
260
  });
@@ -1,42 +1,53 @@
1
- import {IndexedCollection} from "../IndexedObjectCollection";
2
-
3
- export class DeferredTask {
4
- public readonly promise: Promise<void>;
5
- public resolve: () => void;
6
-
7
- public constructor() {
8
- this.promise = new Promise<void>((resolve) => this.resolve = resolve);
9
- }
10
- }
11
-
12
- export class PromiseRegistry {
13
- private promises = new IndexedCollection<string, Promise<any>>();
14
-
15
- public register<T = any>(promise: Promise<T>, key: string): void {
16
- this.promises.set([key, promise]);
17
- }
18
-
19
- public registerByFunction(fn: () => Promise<any>, key: string): void {
20
- this.register(fn(), key);
21
- }
22
-
23
- public get<T = any>(key: string): Promise<T> | undefined {
24
- return this.promises.get(key);
25
- }
26
-
27
- public has(key: string): boolean {
28
- return this.promises.has(key);
29
- }
30
-
31
- public notExist(key: string): boolean {
32
- return ! this.has(key);
33
- }
34
-
35
- public forget(...keys: string[]): void {
36
- this.promises.delete(...keys);
37
- }
38
-
39
- public forgetAll(): void {
40
- this.promises.deleteAll();
41
- }
1
+ import {IndexedCollection} from "../IndexedObjectCollection";
2
+
3
+ export class DeferredTask {
4
+ public readonly promise: Promise<void>;
5
+ public resolve: () => void;
6
+
7
+ public constructor() {
8
+ this.promise = new Promise<void>((resolve) => this.resolve = resolve);
9
+ }
10
+ }
11
+
12
+ export class PromiseRegistry {
13
+ private promises = new IndexedCollection<string, Promise<any>>();
14
+
15
+ public register<T = any>(promise: Promise<T>, key: string): void {
16
+ this.promises.set([key, promise]);
17
+
18
+ // Never cache a failed lookup: drop it so the next access retries
19
+ // instead of replaying the same rejection forever (a request issued
20
+ // while the client was offline would otherwise poison the key). The
21
+ // handler also keeps the cached promise from surfacing as an unhandled
22
+ // rejection - callers still receive the rejection from their own await.
23
+ promise.catch(() => {
24
+ if (this.promises.get(key) === promise) {
25
+ this.promises.delete(key);
26
+ }
27
+ });
28
+ }
29
+
30
+ public registerByFunction(fn: () => Promise<any>, key: string): void {
31
+ this.register(fn(), key);
32
+ }
33
+
34
+ public get<T = any>(key: string): Promise<T> | undefined {
35
+ return this.promises.get(key);
36
+ }
37
+
38
+ public has(key: string): boolean {
39
+ return this.promises.has(key);
40
+ }
41
+
42
+ public notExist(key: string): boolean {
43
+ return ! this.has(key);
44
+ }
45
+
46
+ public forget(...keys: string[]): void {
47
+ this.promises.delete(...keys);
48
+ }
49
+
50
+ public forgetAll(): void {
51
+ this.promises.deleteAll();
52
+ }
42
53
  }
@@ -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
  }