polfan-server-js-client 0.2.107 → 0.2.109
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/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26_[Changes]/shelved.patch +174 -0
- package/.idea/shelf/Uncommitted_changes_before_Checkout_at_26_07_2026_21_26__Changes_.xml +4 -0
- package/.idea/workspace.xml +21 -13
- package/build/index.cjs.js +110 -524
- 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 +0 -10
- package/build/types/IndexedObjectCollection.d.ts +0 -8
- package/build/types/WebSocketChatClient.d.ts +1 -17
- package/build/types/index.d.ts +2 -1
- package/build/types/state-tracker/FollowedTopicsManager.d.ts +6 -22
- package/build/types/state-tracker/RoomMessagesHistory.d.ts +0 -12
- package/build/types/state-tracker/TopicHistoryWindow.d.ts +2 -2
- package/package.json +1 -1
- package/src/AbstractChatClient.ts +0 -22
- package/src/IndexedObjectCollection.ts +0 -31
- package/src/WebSocketChatClient.ts +7 -63
- package/src/index.ts +2 -0
- package/src/state-tracker/AsyncUtils.ts +0 -11
- package/src/state-tracker/EmoticonsManager.ts +3 -7
- package/src/state-tracker/FollowedTopicsManager.ts +17 -59
- package/src/state-tracker/MessagesManager.ts +2 -21
- package/src/state-tracker/PermissionsManager.ts +1 -5
- package/src/state-tracker/RelationshipsManager.ts +5 -5
- package/src/state-tracker/RoomMessagesHistory.ts +1 -41
- package/src/state-tracker/RoomsManager.ts +4 -21
- package/src/state-tracker/SpacesManager.ts +8 -35
- package/src/state-tracker/TopicHistoryWindow.ts +4 -4
- package/src/state-tracker/UsersManager.ts +1 -3
- package/tests/collections.test.ts +0 -92
- package/tests/pending-commands.test.ts +0 -171
- package/tests/state-reconnect.test.ts +0 -257
|
@@ -14,16 +14,6 @@ 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;
|
|
27
17
|
}
|
|
28
18
|
export type CommandResult<ResultT> = {
|
|
29
19
|
data?: ResultT;
|
|
@@ -51,14 +51,6 @@ 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;
|
|
62
54
|
createMirror(): ObservableIndexedObjectCollection<ItemT, EventMapT>;
|
|
63
55
|
on<EventName extends keyof EventMapT & string>(eventName: EventName, handler: EventHandler<EventMapT[EventName]>): this;
|
|
64
56
|
once<EventName extends keyof EventMapT & string>(eventName: EventName, handler: EventHandler<EventMapT[EventName]>): this;
|
|
@@ -45,13 +45,7 @@ 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]
|
|
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;
|
|
48
|
+
protected authenticatedResolvers: [() => void, (error: Error) => void];
|
|
55
49
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
56
50
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
57
51
|
protected lastReceivedMessageAt?: number;
|
|
@@ -63,16 +57,6 @@ export declare class WebSocketChatClient extends AbstractChatClient<Pick<WebSock
|
|
|
63
57
|
private sendEnvelope;
|
|
64
58
|
private onMessage;
|
|
65
59
|
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;
|
|
76
60
|
private sendFromQueue;
|
|
77
61
|
private triggerConnectionTimeout;
|
|
78
62
|
private isConnectingWsState;
|
package/build/types/index.d.ts
CHANGED
|
@@ -6,5 +6,6 @@ import { Permissions, PermissionDefinition, Layer } from "./Permissions";
|
|
|
6
6
|
import * as ChatTypes from './types/src';
|
|
7
7
|
import { extractUserFromMember } from "./state-tracker/functions";
|
|
8
8
|
import { AbstractRestClient } from "./AbstractRestClient";
|
|
9
|
+
import { UnreadSummary } from "./state-tracker/FollowedTopicsManager";
|
|
9
10
|
export { IndexedCollection, ObservableIndexedCollection, IndexedObjectCollection, ObservableIndexedObjectCollection, Permissions, PermissionDefinition, Layer, WebSocketChatClient, WebApiChatClient, FilesClient, AbstractRestClient, extractUserFromMember, };
|
|
10
|
-
export type { ChatTypes, File, };
|
|
11
|
+
export type { ChatTypes, File, UnreadSummary, };
|
|
@@ -5,17 +5,17 @@ import { ChatLocation, FollowedTopic } from "../types/src";
|
|
|
5
5
|
interface EventMap {
|
|
6
6
|
change: {};
|
|
7
7
|
}
|
|
8
|
+
export interface UnreadSummary {
|
|
9
|
+
mentionCount: number;
|
|
10
|
+
unreadTopicCount: number;
|
|
11
|
+
isUnread: boolean;
|
|
12
|
+
}
|
|
8
13
|
export declare class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
9
14
|
private tracker;
|
|
10
15
|
private readonly followedTopics;
|
|
11
16
|
private readonly followedTopicsPromises;
|
|
12
17
|
private readonly deferredSession;
|
|
13
18
|
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;
|
|
19
19
|
constructor(tracker: ChatStateTracker);
|
|
20
20
|
/**
|
|
21
21
|
* Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
|
|
@@ -38,10 +38,7 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
38
38
|
* Capture the 'change' event to determine when it's worth calling this method again due to data changes.
|
|
39
39
|
* @return Undefined if you are not in room.
|
|
40
40
|
*/
|
|
41
|
-
summarize(location: ChatLocation): Promise<
|
|
42
|
-
mentionCount: number;
|
|
43
|
-
isUnread: boolean;
|
|
44
|
-
}>;
|
|
41
|
+
summarize(location: ChatLocation): Promise<UnreadSummary>;
|
|
45
42
|
/**
|
|
46
43
|
* For internal use. If you want to delete the message, execute a proper command on client object.
|
|
47
44
|
* @internal
|
|
@@ -63,19 +60,6 @@ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
63
60
|
private invalidateUnreadSummaries;
|
|
64
61
|
private invalidateUnreadSummariesForRooms;
|
|
65
62
|
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;
|
|
79
63
|
private setFollowedTopicsArray;
|
|
80
64
|
private clearRoomFollowedTopicsStructures;
|
|
81
65
|
}
|
|
@@ -13,18 +13,6 @@ 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>;
|
|
28
16
|
private handleRoomUpdated;
|
|
29
17
|
private handleNewTopic;
|
|
30
18
|
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(
|
|
67
|
+
resetToLatest(): 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(
|
|
102
|
+
resetToLatest(): Promise<void>;
|
|
103
103
|
fetchNext(): Promise<void>;
|
|
104
104
|
fetchPrevious(): Promise<void>;
|
|
105
105
|
jumpTo(id: string): Promise<void>;
|
package/package.json
CHANGED
|
@@ -165,28 +165,6 @@ 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
|
-
}
|
|
190
168
|
}
|
|
191
169
|
|
|
192
170
|
export type CommandResult<ResultT> = {data?: ResultT, error?: ErrorType};
|
|
@@ -226,37 +226,6 @@ 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
|
-
|
|
260
229
|
public createMirror(): ObservableIndexedObjectCollection<ItemT, EventMapT> {
|
|
261
230
|
const copy = new ObservableIndexedObjectCollection<ItemT, EventMapT>(this.id);
|
|
262
231
|
copy.eventTarget = this.eventTarget;
|
|
@@ -48,13 +48,7 @@ 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]
|
|
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;
|
|
51
|
+
protected authenticatedResolvers: [() => void, (error: Error) => void];
|
|
58
52
|
protected pingMonitorInterval?: NodeJS.Timeout;
|
|
59
53
|
protected inFlightPingTimeout: NodeJS.Timeout;
|
|
60
54
|
protected lastReceivedMessageAt?: number;
|
|
@@ -73,14 +67,9 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
73
67
|
|
|
74
68
|
public async connect(): Promise<void> {
|
|
75
69
|
if (this.isOpenWsState() || this.isConnectingWsState()) {
|
|
76
|
-
return
|
|
70
|
+
return;
|
|
77
71
|
}
|
|
78
72
|
|
|
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
|
-
|
|
84
73
|
const params = new URLSearchParams(this.options.queryParams ?? {});
|
|
85
74
|
params.set('token', this.options.token);
|
|
86
75
|
|
|
@@ -92,12 +81,11 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
92
81
|
this.options.connectingTimeoutMs ?? 10000
|
|
93
82
|
);
|
|
94
83
|
this.authenticated = false;
|
|
95
|
-
|
|
96
|
-
return this.connectPromise;
|
|
84
|
+
return new Promise((...args) => this.authenticatedResolvers = args);
|
|
97
85
|
}
|
|
98
86
|
|
|
99
87
|
public disconnect(): void {
|
|
100
|
-
this.
|
|
88
|
+
this.sendQueue = [];
|
|
101
89
|
this.ws?.close(1000); // Normal closure
|
|
102
90
|
this.ws = null;
|
|
103
91
|
}
|
|
@@ -145,11 +133,11 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
145
133
|
this.authenticated = isAuthenticated;
|
|
146
134
|
if (isAuthenticated) {
|
|
147
135
|
this.startConnectionMonitor();
|
|
148
|
-
this.
|
|
136
|
+
this.authenticatedResolvers[0]();
|
|
149
137
|
this.emit(this.Event.connect);
|
|
150
138
|
this.sendFromQueue();
|
|
151
139
|
} else {
|
|
152
|
-
this.
|
|
140
|
+
this.authenticatedResolvers[1](envelope.data);
|
|
153
141
|
}
|
|
154
142
|
}
|
|
155
143
|
}
|
|
@@ -158,54 +146,12 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
158
146
|
this.stopConnectionMonitor();
|
|
159
147
|
clearTimeout(this.connectingTimeoutId);
|
|
160
148
|
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
|
-
|
|
166
149
|
if (reconnect) {
|
|
167
|
-
// Keep a pending connect() promise unsettled - the retry below is
|
|
168
|
-
// expected to authenticate and will resolve it.
|
|
169
150
|
void this.connect();
|
|
170
|
-
} else {
|
|
171
|
-
this.settleConnect(new Error('Connection closed before authentication'));
|
|
172
151
|
}
|
|
173
|
-
|
|
174
152
|
this.emit(this.Event.disconnect, reconnect);
|
|
175
153
|
}
|
|
176
154
|
|
|
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
|
-
|
|
209
155
|
private sendFromQueue(): void {
|
|
210
156
|
// Send awaiting data to server
|
|
211
157
|
let lastDelay = 0;
|
|
@@ -252,9 +198,7 @@ export class WebSocketChatClient extends AbstractChatClient<Pick<WebSocketEventM
|
|
|
252
198
|
this.ws.close(3000); // Service Restart (reconnect)
|
|
253
199
|
}, this.options.ping.pongBackTimeoutMs);
|
|
254
200
|
|
|
255
|
-
|
|
256
|
-
// in flight; onClose already handles that, so just stop waiting.
|
|
257
|
-
this.send('Ping', {}).catch(() => undefined).then(() => {
|
|
201
|
+
this.send('Ping', {}).then(() => {
|
|
258
202
|
clearTimeout(this.inFlightPingTimeout);
|
|
259
203
|
this.inFlightPingTimeout = undefined;
|
|
260
204
|
});
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { Permissions, PermissionDefinition, Layer } from "./Permissions";
|
|
|
11
11
|
import * as ChatTypes from './types/src';
|
|
12
12
|
import {extractUserFromMember} from "./state-tracker/functions";
|
|
13
13
|
import {AbstractRestClient} from "./AbstractRestClient";
|
|
14
|
+
import {UnreadSummary} from "./state-tracker/FollowedTopicsManager";
|
|
14
15
|
|
|
15
16
|
export {
|
|
16
17
|
IndexedCollection, ObservableIndexedCollection,
|
|
@@ -25,4 +26,5 @@ export {
|
|
|
25
26
|
export type {
|
|
26
27
|
ChatTypes,
|
|
27
28
|
File,
|
|
29
|
+
UnreadSummary,
|
|
28
30
|
};
|
|
@@ -14,17 +14,6 @@ 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
|
-
// 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
17
|
}
|
|
29
18
|
|
|
30
19
|
public registerByFunction(fn: () => Promise<any>, key: string): void {
|
|
@@ -44,10 +44,8 @@ export class EmoticonsManager {
|
|
|
44
44
|
this.list.set([spaceId, new ObservableIndexedObjectCollection<Emoticon>('id')]);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// adds new ones without blanking a bound (visible) collection.
|
|
50
|
-
this.list.get(spaceId).reconcile(...event.emoticons);
|
|
47
|
+
const collection = this.list.get(spaceId);
|
|
48
|
+
collection.set(...event.emoticons);
|
|
51
49
|
}
|
|
52
50
|
|
|
53
51
|
private handleNewEmoticon(ev: NewEmoticon): void {
|
|
@@ -65,9 +63,7 @@ export class EmoticonsManager {
|
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
private handleSession(): void {
|
|
68
|
-
|
|
69
|
-
// pickers); just drop the fetch guards so the next access refetches and
|
|
70
|
-
// reconciles them in place.
|
|
66
|
+
this.list.deleteAll();
|
|
71
67
|
this.emoticonsPromises.forgetAll();
|
|
72
68
|
}
|
|
73
69
|
}
|
|
@@ -17,17 +17,17 @@ interface EventMap {
|
|
|
17
17
|
change: {};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export interface UnreadSummary {
|
|
21
|
+
mentionCount: number;
|
|
22
|
+
unreadTopicCount: number;
|
|
23
|
+
isUnread: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
21
27
|
private readonly followedTopics = new IndexedCollection<string, ObservableIndexedObjectCollection<FollowedTopic>>();
|
|
22
28
|
private readonly followedTopicsPromises = new PromiseRegistry();
|
|
23
29
|
private readonly deferredSession = new DeferredTask();
|
|
24
|
-
private readonly summariesCache = new Map<string,
|
|
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>();
|
|
30
|
+
private readonly summariesCache = new Map<string, UnreadSummary>();
|
|
31
31
|
|
|
32
32
|
public constructor(private tracker: ChatStateTracker) {
|
|
33
33
|
super();
|
|
@@ -63,8 +63,8 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
66
|
+
const isAlreadyCached = roomIds.every(roomId => this.followedTopics.has(roomId));
|
|
67
|
+
if (isAlreadyCached) {
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
70
|
|
|
@@ -75,7 +75,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
75
75
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {spaceId}});
|
|
76
76
|
if (result.error) throw result.error;
|
|
77
77
|
|
|
78
|
-
this.
|
|
78
|
+
this.setFollowedTopicsArray(roomIds, result.data.followedTopics);
|
|
79
79
|
}, spaceRegistryKey);
|
|
80
80
|
}
|
|
81
81
|
|
|
@@ -91,7 +91,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
91
91
|
return undefined;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
if (! this.followedTopics.has(roomId)
|
|
94
|
+
if (! this.followedTopics.has(roomId)) {
|
|
95
95
|
if (this.followedTopicsPromises.notExist(roomId)) {
|
|
96
96
|
this.followedTopicsPromises.registerByFunction(async () => {
|
|
97
97
|
const result = await this.tracker.client.send('GetFollowedTopics', {location: {roomId}});
|
|
@@ -100,8 +100,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
100
100
|
throw result.error;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
this.
|
|
104
|
-
this.invalidateUnreadSummaries(roomId);
|
|
103
|
+
this.setFollowedTopicsArray([roomId], result.data.followedTopics);
|
|
105
104
|
}, roomId);
|
|
106
105
|
}
|
|
107
106
|
|
|
@@ -134,7 +133,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
134
133
|
* Capture the 'change' event to determine when it's worth calling this method again due to data changes.
|
|
135
134
|
* @return Undefined if you are not in room.
|
|
136
135
|
*/
|
|
137
|
-
public async summarize(location: ChatLocation): Promise<
|
|
136
|
+
public async summarize(location: ChatLocation): Promise<UnreadSummary> {
|
|
138
137
|
const cacheKey = location.topicId
|
|
139
138
|
? `topic:${location.roomId}:${location.topicId}`
|
|
140
139
|
: location.roomId
|
|
@@ -168,7 +167,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
168
167
|
}
|
|
169
168
|
|
|
170
169
|
let mentionCount = 0;
|
|
171
|
-
let
|
|
170
|
+
let unreadTopicCount = 0;
|
|
172
171
|
|
|
173
172
|
for (const roomId of roomIds) {
|
|
174
173
|
const collection = await this.getForRoom(roomId);
|
|
@@ -183,14 +182,14 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
if (topic.isUnread) {
|
|
186
|
-
|
|
185
|
+
unreadTopicCount++;
|
|
187
186
|
}
|
|
188
187
|
|
|
189
188
|
mentionCount += (topic.mentionCount ?? 0);
|
|
190
189
|
}
|
|
191
190
|
}
|
|
192
191
|
|
|
193
|
-
const result = { mentionCount, isUnread };
|
|
192
|
+
const result = { mentionCount, unreadTopicCount, isUnread: unreadTopicCount > 0 };
|
|
194
193
|
this.summariesCache.set(cacheKey, result);
|
|
195
194
|
|
|
196
195
|
return result;
|
|
@@ -206,13 +205,7 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
206
205
|
}
|
|
207
206
|
|
|
208
207
|
private handleSession(ev: Session): void {
|
|
209
|
-
|
|
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
|
-
}
|
|
208
|
+
this.followedTopics.deleteAll();
|
|
216
209
|
this.followedTopicsPromises.forgetAll();
|
|
217
210
|
this.invalidateUnreadSummaries();
|
|
218
211
|
this.deferredSession.resolve();
|
|
@@ -364,40 +357,6 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
364
357
|
this.invalidateUnreadSummaries(ev.message.location.roomId, ev.message.location.topicId);
|
|
365
358
|
}
|
|
366
359
|
|
|
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
|
-
|
|
401
360
|
private setFollowedTopicsArray(roomIds: string[], followedTopics: FollowedTopic[]): void {
|
|
402
361
|
const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
|
|
403
362
|
|
|
@@ -425,7 +384,6 @@ export class FollowedTopicsManager extends EventTarget<EventMap> {
|
|
|
425
384
|
private clearRoomFollowedTopicsStructures(roomId: string): void {
|
|
426
385
|
this.followedTopics.delete(roomId);
|
|
427
386
|
this.followedTopicsPromises.forget(roomId);
|
|
428
|
-
this.staleRooms.delete(roomId);
|
|
429
387
|
this.invalidateUnreadSummaries(roomId);
|
|
430
388
|
}
|
|
431
389
|
}
|
|
@@ -71,27 +71,8 @@ export class MessagesManager {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
private handleSession(ev: Session): void {
|
|
74
|
-
|
|
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
|
-
|
|
74
|
+
this.roomHistories.deleteAll();
|
|
75
|
+
ev.state.rooms.forEach(room => this.createHistoryForNewRoom(room));
|
|
95
76
|
this.deferredSession.resolve();
|
|
96
77
|
}
|
|
97
78
|
}
|
|
@@ -308,11 +308,7 @@ export class PermissionsManager extends EventTarget<PermissionsManagerEventMap>
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
private handleSession(ev: Session): void {
|
|
311
|
-
|
|
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.
|
|
311
|
+
this.overwrites.deleteAll();
|
|
315
312
|
this.overwritesPromises.forgetAll();
|
|
316
|
-
this.emit('change');
|
|
317
313
|
}
|
|
318
314
|
}
|
|
@@ -43,9 +43,10 @@ export class RelationshipsManager {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
private handleRelationships(ev: Relationships): void {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
this.relationships.deleteAll();
|
|
47
|
+
ev.relationships.forEach(relationship => {
|
|
48
|
+
this.relationships.set(relationship);
|
|
49
|
+
});
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
private handleNewRelationship(ev: NewRelationship): void {
|
|
@@ -61,8 +62,7 @@ export class RelationshipsManager {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
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.
|
|
66
65
|
this.promises.forgetAll();
|
|
66
|
+
this.relationships.deleteAll();
|
|
67
67
|
}
|
|
68
68
|
}
|