polfan-server-js-client 0.2.96 → 0.2.98

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.
@@ -0,0 +1,63 @@
1
+ import { EventTarget } from "../EventTarget";
2
+ import { ChatStateTracker } from "./ChatStateTracker";
3
+ import { ObservableIndexedObjectCollection } from "../IndexedObjectCollection";
4
+ import { ChatLocation, FollowedTopic } from "../types/src";
5
+ interface EventMap {
6
+ change: {};
7
+ }
8
+ export declare class FollowedTopicsManager extends EventTarget<EventMap> {
9
+ private tracker;
10
+ private readonly followedTopics;
11
+ private readonly followedTopicsPromises;
12
+ private readonly deferredSession;
13
+ private readonly summariesCache;
14
+ constructor(tracker: ChatStateTracker);
15
+ /**
16
+ * Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
17
+ * Then you can get them using getRoomFollowedTopics().
18
+ * @see getForRoom
19
+ */
20
+ cacheForSpace(spaceId: string | null): Promise<void>;
21
+ /**
22
+ * Get followed topics for the given room.
23
+ * @return Undefined if you are not in the room, collection otherwise.
24
+ */
25
+ getForRoom(roomId: string): Promise<ObservableIndexedObjectCollection<FollowedTopic> | undefined>;
26
+ /**
27
+ * Batch acknowledge all messages for given room.
28
+ */
29
+ ackRoom(roomId: string): Promise<void>;
30
+ /**
31
+ * Summarize all unread messages or mentions from any topic in given location.
32
+ * This method uses an internal cache, so it's ok to call it multiple times.
33
+ * Capture the 'change' event to determine when it's worth calling this method again due to data changes.
34
+ * @return Undefined if you are not in room.
35
+ */
36
+ summarize(location: ChatLocation): Promise<{
37
+ mentionCount: number;
38
+ isUnread: boolean;
39
+ }>;
40
+ /**
41
+ * For internal use. If you want to delete the message, execute a proper command on client object.
42
+ * @internal
43
+ */
44
+ _deleteByTopicIds(roomId: string, ...topicIds: string[]): void;
45
+ private handleSession;
46
+ private handleNewMessage;
47
+ private handleFollowedTopicUpdated;
48
+ private handleTopicFollowed;
49
+ private handleRoomDeleted;
50
+ private handleRoomJoin;
51
+ private handleRoomLeft;
52
+ private handleNewTopic;
53
+ private handleTopicDeleted;
54
+ /**
55
+ * Invalidate the summaries cache intentionally, only for the locations affected by the change.
56
+ */
57
+ private invalidateUnreadSummaries;
58
+ private invalidateUnreadSummariesForRooms;
59
+ private updateLocallyFollowedTopicOnNewMessage;
60
+ private setFollowedTopicsArray;
61
+ private clearRoomFollowedTopicsStructures;
62
+ }
63
+ export {};
@@ -1,68 +1,23 @@
1
1
  import { ChatStateTracker } from "./ChatStateTracker";
2
- import { FollowedTopic, Message, ChatLocation } from "../types/src";
3
- import { ObservableIndexedObjectCollection } from "../IndexedObjectCollection";
2
+ import { Message, ChatLocation } from "../types/src";
4
3
  import { RoomMessagesHistory } from "./RoomMessagesHistory";
5
4
  export declare class MessagesManager {
6
5
  private tracker;
7
6
  private readonly roomHistories;
8
- private readonly followedTopics;
9
- private readonly followedTopicsPromises;
10
7
  private readonly deferredSession;
11
- private readonly unreadSummariesCache;
12
8
  constructor(tracker: ChatStateTracker);
13
9
  /**
14
10
  * Get history manager for given room ID.
15
11
  */
16
12
  getRoomHistory(roomId: string): Promise<RoomMessagesHistory | undefined>;
17
- /**
18
- * Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
19
- * Then you can get them using getRoomFollowedTopics().
20
- * @see getRoomFollowedTopics
21
- */
22
- cacheSpaceFollowedTopics(spaceId: string | null): Promise<void>;
23
- /**
24
- * Get followed topics for the given room.
25
- * @return Undefined if you are not in the room, collection otherwise.
26
- */
27
- getRoomFollowedTopics(roomId: string): Promise<ObservableIndexedObjectCollection<FollowedTopic> | undefined>;
28
- /**
29
- * Batch acknowledge all messages for given room.
30
- */
31
- ackRoom(roomId: string): Promise<void>;
32
- /**
33
- * Calculate missed messages with mentions from any topic in given room.
34
- * @return Undefined if you are not in room.
35
- */
36
- summarizeUnreadMessages(location: ChatLocation): Promise<{
37
- mentionCount: number;
38
- isUnread: boolean;
39
- }>;
40
- /**
41
- * For internal use. If you want to delete the message, execute a proper command on client object.
42
- * @internal
43
- */
44
- _deleteByTopicIds(roomId: string, ...topicIds: string[]): void;
45
13
  /**
46
14
  * For internal use.
47
15
  * @internal
48
16
  */
49
17
  _resolveLastMessage(location: ChatLocation): Promise<Message | null>;
50
- /**
51
- * Wyczyść cache celowo, tylko dla lokalizacji których dotyczy zmiana.
52
- */
53
- private invalidateUnreadSummaries;
54
- private invalidateUnreadSummariesForRooms;
55
18
  private createHistoryForNewRoom;
56
- private handleNewMessage;
57
- private handleFollowedTopicUpdated;
58
- private handleTopicFollowed;
59
19
  private handleRoomDeleted;
60
20
  private handleRoomJoin;
61
21
  private handleRoomLeft;
62
- private handleNewTopic;
63
- private handleTopicDeleted;
64
22
  private handleSession;
65
- private updateLocallyFollowedTopicOnNewMessage;
66
- private setFollowedTopicsArray;
67
- private clearRoomFollowedTopicsStructures;
68
23
  }
@@ -2,9 +2,11 @@ import { ObservableIndexedObjectCollection } from "../IndexedObjectCollection";
2
2
  import { Room, RoomMember, Topic } from "../types/src";
3
3
  import { ChatStateTracker } from "./ChatStateTracker";
4
4
  import { MessagesManager } from "./MessagesManager";
5
+ import { FollowedTopicsManager } from "./FollowedTopicsManager";
5
6
  export declare class RoomsManager {
6
7
  private tracker;
7
8
  readonly messages: MessagesManager;
9
+ readonly followedTopics: FollowedTopicsManager;
8
10
  private readonly list;
9
11
  private readonly topics;
10
12
  private readonly members;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polfan-server-js-client",
3
- "version": "0.2.96",
3
+ "version": "0.2.98",
4
4
  "description": "JavaScript client library for handling communication with Polfan chat server.",
5
5
  "author": "Jarosław Żak",
6
6
  "license": "MIT",
@@ -39,15 +39,14 @@ export class IndexedCollection<KeyT, ValueT> {
39
39
  this.items.clear();
40
40
  }
41
41
 
42
- public findBy(field: keyof ValueT, valueToFind: any, limit: number = null): IndexedCollection<KeyT, ValueT> {
42
+ public findBy(field: keyof ValueT, valueToFind: any, limit: number = Infinity): IndexedCollection<KeyT, ValueT> {
43
43
  const result = new IndexedCollection<KeyT, ValueT>();
44
- let item;
45
- while (!(item = this.items.entries().next().value).done) {
46
- if (limit && result.length === limit) {
44
+ for (const [key, value] of this.items.entries()) {
45
+ if (result.length >= limit) {
47
46
  break;
48
47
  }
49
- if (item[1][field] === valueToFind) {
50
- result.set(item);
48
+ if (value[field] === valueToFind) {
49
+ result.set([key, value]);
51
50
  }
52
51
  }
53
52
  return result;
@@ -0,0 +1,377 @@
1
+ import {EventTarget} from "../EventTarget";
2
+ import {ChatStateTracker} from "./ChatStateTracker";
3
+ import {DeferredTask, PromiseRegistry} from "./AsyncUtils";
4
+ import {IndexedCollection, ObservableIndexedObjectCollection} from "../IndexedObjectCollection";
5
+ import {
6
+ ChatLocation,
7
+ FollowedTopic,
8
+ FollowedTopicUpdated,
9
+ NewMessage, NewTopic,
10
+ RoomDeleted,
11
+ RoomJoined, RoomLeft,
12
+ Session, TopicDeleted,
13
+ TopicFollowed
14
+ } from "../types/src";
15
+
16
+ interface EventMap {
17
+ change: {};
18
+ }
19
+
20
+ export class FollowedTopicsManager extends EventTarget<EventMap> {
21
+ private readonly followedTopics = new IndexedCollection<string, ObservableIndexedObjectCollection<FollowedTopic>>();
22
+ private readonly followedTopicsPromises = new PromiseRegistry();
23
+ private readonly deferredSession = new DeferredTask();
24
+ private readonly summariesCache = new Map<string, { mentionCount: number, isUnread: boolean }>();
25
+
26
+ public constructor(private tracker: ChatStateTracker) {
27
+ super();
28
+
29
+ this.tracker.client.on('Session', ev => this.handleSession(ev));
30
+ this.tracker.client.on('RoomJoined', ev => this.handleRoomJoin(ev));
31
+ this.tracker.client.on('NewTopic', ev => this.handleNewTopic(ev));
32
+ this.tracker.client.on('FollowedTopicUpdated', ev => this.handleFollowedTopicUpdated(ev));
33
+ this.tracker.client.on('TopicFollowed', ev => this.handleTopicFollowed(ev));
34
+ this.tracker.client.on('NewMessage', ev => this.handleNewMessage(ev));
35
+ this.tracker.client.on('RoomDeleted', ev => this.handleRoomDeleted(ev));
36
+ this.tracker.client.on('RoomLeft', ev => this.handleRoomLeft(ev));
37
+ this.tracker.client.on('TopicDeleted', ev => this.handleTopicDeleted(ev));
38
+ }
39
+
40
+ /**
41
+ * Cache followed topics for all joined rooms in a space and fetch them in bulk if necessary.
42
+ * Then you can get them using getRoomFollowedTopics().
43
+ * @see getForRoom
44
+ */
45
+ public async cacheForSpace(spaceId: string | null): Promise<void> {
46
+ if (spaceId && ! (await this.tracker.spaces.get()).has(spaceId)) {
47
+ throw new Error(`You are not in space ${spaceId}`);
48
+ }
49
+
50
+ const rooms = await this.tracker.rooms.get();
51
+ const roomIds = spaceId
52
+ ? rooms.findBy('spaceId', spaceId).items.map(r => r.id)
53
+ : rooms.items.filter(r => !r.spaceId).map(r => r.id);
54
+
55
+ if (!roomIds.length) {
56
+ return;
57
+ }
58
+
59
+ const isAlreadyCached = roomIds.every(roomId => this.followedTopics.has(roomId));
60
+ if (isAlreadyCached) {
61
+ return;
62
+ }
63
+
64
+ const spaceRegistryKey = `space_fetch_${spaceId || 'spaceless'}`;
65
+
66
+ if (this.followedTopicsPromises.notExist(spaceRegistryKey)) {
67
+ this.followedTopicsPromises.registerByFunction(async () => {
68
+ const result = await this.tracker.client.send('GetFollowedTopics', {location: {spaceId}});
69
+ if (result.error) throw result.error;
70
+
71
+ this.setFollowedTopicsArray(roomIds, result.data.followedTopics);
72
+ }, spaceRegistryKey);
73
+ }
74
+
75
+ await this.followedTopicsPromises.get(spaceRegistryKey);
76
+ }
77
+
78
+ /**
79
+ * Get followed topics for the given room.
80
+ * @return Undefined if you are not in the room, collection otherwise.
81
+ */
82
+ public async getForRoom(roomId: string): Promise<ObservableIndexedObjectCollection<FollowedTopic> | undefined> {
83
+ if (! (await this.tracker.rooms.get()).has(roomId)) {
84
+ return undefined;
85
+ }
86
+
87
+ if (! this.followedTopics.has(roomId)) {
88
+ if (this.followedTopicsPromises.notExist(roomId)) {
89
+ this.followedTopicsPromises.registerByFunction(async () => {
90
+ const result = await this.tracker.client.send('GetFollowedTopics', {location: {roomId}});
91
+
92
+ if (result.error) {
93
+ throw result.error;
94
+ }
95
+
96
+ this.setFollowedTopicsArray([roomId], result.data.followedTopics);
97
+ }, roomId);
98
+ }
99
+
100
+ await this.followedTopicsPromises.get(roomId);
101
+ }
102
+
103
+ return this.followedTopics.get(roomId);
104
+ }
105
+
106
+ /**
107
+ * Batch acknowledge all messages for given room.
108
+ */
109
+ public async ackRoom(roomId: string): Promise<void> {
110
+ const collection = await this.getForRoom(roomId);
111
+
112
+ if (! collection) {
113
+ return;
114
+ }
115
+
116
+ for (const followedTopic of collection.items) {
117
+ if (followedTopic.isUnread) {
118
+ await this.tracker.client.send('Ack', {location: followedTopic.location});
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Summarize all unread messages or mentions from any topic in given location.
125
+ * This method uses an internal cache, so it's ok to call it multiple times.
126
+ * Capture the 'change' event to determine when it's worth calling this method again due to data changes.
127
+ * @return Undefined if you are not in room.
128
+ */
129
+ public async summarize(location: ChatLocation): Promise<{ mentionCount: number, isUnread: boolean }> {
130
+ const cacheKey = location.topicId
131
+ ? `topic:${location.roomId}:${location.topicId}`
132
+ : location.roomId
133
+ ? `room:${location.roomId}`
134
+ : location.spaceId
135
+ ? `space:${location.spaceId}`
136
+ : 'spaceless';
137
+
138
+ if (this.summariesCache.has(cacheKey)) {
139
+ return this.summariesCache.get(cacheKey)!;
140
+ }
141
+
142
+ let roomIds: string[] = [];
143
+ let targetTopicId: string | undefined;
144
+ const rooms = await this.tracker.rooms.get();
145
+
146
+ if (location.topicId) {
147
+ if (!location.roomId) {
148
+ throw new Error("roomId is required when querying by topicId");
149
+ }
150
+ roomIds = [location.roomId];
151
+ targetTopicId = location.topicId;
152
+ } else if (location.roomId) {
153
+ roomIds = [location.roomId];
154
+ } else if (location.spaceId) {
155
+ await this.cacheForSpace(location.spaceId);
156
+ roomIds = rooms.findBy('spaceId', location.spaceId).items.map(r => r.id);
157
+ } else {
158
+ await this.cacheForSpace(null);
159
+ roomIds = rooms.items.filter(r => !r.spaceId).map(r => r.id);
160
+ }
161
+
162
+ let mentionCount = 0;
163
+ let isUnread = false;
164
+
165
+ for (const roomId of roomIds) {
166
+ const collection = await this.getForRoom(roomId);
167
+
168
+ if (!collection) {
169
+ continue;
170
+ }
171
+
172
+ for (const topic of collection.items) {
173
+ if (targetTopicId && topic.location.topicId !== targetTopicId) {
174
+ continue;
175
+ }
176
+
177
+ if (topic.isUnread) {
178
+ isUnread = true;
179
+ }
180
+
181
+ mentionCount += (topic.mentionCount ?? 0);
182
+ }
183
+ }
184
+
185
+ const result = { mentionCount, isUnread };
186
+ this.summariesCache.set(cacheKey, result);
187
+
188
+ return result;
189
+ }
190
+
191
+ /**
192
+ * For internal use. If you want to delete the message, execute a proper command on client object.
193
+ * @internal
194
+ */
195
+ public _deleteByTopicIds(roomId: string, ...topicIds: string[]): void {
196
+ this.followedTopics.get(roomId)?.delete(...topicIds);
197
+ topicIds.forEach(topicId => this.invalidateUnreadSummaries(roomId, topicId));
198
+ }
199
+
200
+ private handleSession(ev: Session): void {
201
+ this.followedTopics.deleteAll();
202
+ this.followedTopicsPromises.forgetAll();
203
+ this.invalidateUnreadSummaries();
204
+ this.deferredSession.resolve();
205
+ }
206
+
207
+ private handleNewMessage(ev: NewMessage): void {
208
+ void this.updateLocallyFollowedTopicOnNewMessage(ev);
209
+ }
210
+
211
+ private handleFollowedTopicUpdated(ev: FollowedTopicUpdated): void {
212
+ this.followedTopics.get(ev.followedTopic.location.roomId)?.set(ev.followedTopic);
213
+ this.invalidateUnreadSummaries(ev.followedTopic.location.roomId, ev.followedTopic.location.topicId);
214
+ }
215
+
216
+ private handleTopicFollowed(ev: TopicFollowed): void {
217
+ this.setFollowedTopicsArray([ev.followedTopic.location.roomId], [ev.followedTopic]);
218
+ this.invalidateUnreadSummaries(ev.followedTopic.location.roomId, ev.followedTopic.location.topicId);
219
+ }
220
+
221
+ private handleRoomDeleted(ev: RoomDeleted): void {
222
+ this.clearRoomFollowedTopicsStructures(ev.id);
223
+ }
224
+
225
+ private handleRoomJoin(ev: RoomJoined): void {
226
+ this.clearRoomFollowedTopicsStructures(ev.room.id);
227
+ }
228
+
229
+ private handleRoomLeft(ev: RoomLeft): void {
230
+ this.clearRoomFollowedTopicsStructures(ev.id);
231
+ }
232
+
233
+ private async handleNewTopic(ev: NewTopic): Promise<void> {
234
+ if (this.followedTopics.has(ev.roomId)) {
235
+ // Check if the new topic is followed by user
236
+ // only if client asked for followed topics list before for this room
237
+ const result = await this.tracker.client.send(
238
+ 'GetFollowedTopics',
239
+ {location: {roomId: ev.roomId, topicId: ev.topic.id}},
240
+ );
241
+ const followedTopic = result.data.followedTopics[0];
242
+ if (followedTopic) {
243
+ this.followedTopics.get(ev.roomId).set(followedTopic);
244
+ this.invalidateUnreadSummaries(ev.roomId, ev.topic.id);
245
+ }
246
+ }
247
+ }
248
+
249
+ private handleTopicDeleted(ev: TopicDeleted): void {
250
+ this.followedTopics.get(ev.location.roomId)?.delete(ev.location.topicId);
251
+ this.invalidateUnreadSummaries(ev.location.roomId, ev.location.topicId);
252
+ }
253
+
254
+ /**
255
+ * Invalidate the summaries cache intentionally, only for the locations affected by the change.
256
+ */
257
+ private invalidateUnreadSummaries(roomId?: string, topicId?: string): void {
258
+ if (roomId) {
259
+ this.summariesCache.delete(`room:${roomId}`);
260
+
261
+ if (topicId) {
262
+ this.summariesCache.delete(`topic:${roomId}:${topicId}`);
263
+ } else {
264
+ for (const key of this.summariesCache.keys()) {
265
+ if (key.startsWith(`topic:${roomId}:`)) {
266
+ this.summariesCache.delete(key);
267
+ }
268
+ }
269
+ }
270
+
271
+ for (const key of this.summariesCache.keys()) {
272
+ if (key.startsWith('space:') || key === 'spaceless') {
273
+ this.summariesCache.delete(key);
274
+ }
275
+ }
276
+ } else {
277
+ this.summariesCache.clear();
278
+ }
279
+
280
+ this.emit('change');
281
+ }
282
+
283
+ private invalidateUnreadSummariesForRooms(roomIds: string[]): void {
284
+ const roomIdsSet = new Set(roomIds);
285
+ roomIds.forEach(roomId => {
286
+ this.summariesCache.delete(`room:${roomId}`);
287
+ });
288
+
289
+ for (const key of this.summariesCache.keys()) {
290
+ if (key.startsWith('space:') || key === 'spaceless') {
291
+ this.summariesCache.delete(key);
292
+ continue;
293
+ }
294
+ if (key.startsWith('topic:')) {
295
+ const parts = key.split(':');
296
+ if (parts.length >= 2 && roomIdsSet.has(parts[1])) {
297
+ this.summariesCache.delete(key);
298
+ }
299
+ }
300
+ }
301
+
302
+ this.emit('change');
303
+ }
304
+
305
+ private async updateLocallyFollowedTopicOnNewMessage(ev: NewMessage): Promise<void> {
306
+ const roomFollowedTopics = this.followedTopics.get(ev.message.location.roomId);
307
+ const followedTopic = roomFollowedTopics?.get(ev.message.location.topicId);
308
+
309
+ if (!roomFollowedTopics || !followedTopic || ev.message.type === 'Ephemeral') {
310
+ // Skip if we don't follow this room or message is ephemeral
311
+ return;
312
+ }
313
+
314
+ const me = await this.tracker.getMe();
315
+ const isMe = ev.message.author.user.id === me.id;
316
+ let update: Partial<FollowedTopic>;
317
+
318
+ if (isMe) {
319
+ // Reset missed messages count if new message is authored by me
320
+ update = {missed: 0, lastAckMessageId: ev.message.id};
321
+ } else if (ev.message.type === 'Text') {
322
+ // ...check for mentions if it's text message...
323
+ const member = await this.tracker.rooms.getMe(ev.message.location.roomId);
324
+ const roleIds = [...(member.spaceMember?.roles ?? []), ...member.roles];
325
+ const mentionHandlers = [
326
+ ...roleIds.map(id => `<@&${id}>`),
327
+ `<@${me.id}>`,
328
+ ];
329
+ const mentionExists = mentionHandlers.some(handler => ev.message.content.includes(handler));
330
+
331
+ update = {
332
+ missed: followedTopic.missed === null ? null : followedTopic.missed + 1,
333
+ isUnread: true,
334
+ mentionCount: followedTopic.mentionCount + (mentionExists ? 1 : 0),
335
+ };
336
+ } else {
337
+ // ...or just mark as unread.
338
+ update = {
339
+ missed: followedTopic.missed === null ? null : followedTopic.missed + 1,
340
+ isUnread: true,
341
+ };
342
+ }
343
+
344
+ roomFollowedTopics.set({...followedTopic, ...update});
345
+ this.invalidateUnreadSummaries(ev.message.location.roomId, ev.message.location.topicId);
346
+ }
347
+
348
+ private setFollowedTopicsArray(roomIds: string[], followedTopics: FollowedTopic[]): void {
349
+ const roomToTopics: {[roomId: string]: FollowedTopic[]} = {};
350
+
351
+ // Reassign followed topics to limit collection change event emit
352
+ followedTopics.forEach(followedTopic => {
353
+ roomToTopics[followedTopic.location.roomId] ??= [];
354
+ roomToTopics[followedTopic.location.roomId].push(followedTopic);
355
+ });
356
+
357
+ roomIds.forEach(roomId => {
358
+ if (! this.followedTopics.has(roomId)) {
359
+ this.followedTopics.set([roomId, new ObservableIndexedObjectCollection<FollowedTopic>(
360
+ followedTopic => followedTopic.location.topicId
361
+ )]);
362
+ }
363
+
364
+ if (roomToTopics[roomId]) {
365
+ this.followedTopics.get(roomId).set(...roomToTopics[roomId]);
366
+ }
367
+ });
368
+
369
+ this.invalidateUnreadSummariesForRooms(roomIds);
370
+ }
371
+
372
+ private clearRoomFollowedTopicsStructures(roomId: string): void {
373
+ this.followedTopics.delete(roomId);
374
+ this.followedTopicsPromises.forget(roomId);
375
+ this.invalidateUnreadSummaries(roomId);
376
+ }
377
+ }