@stream-io/feeds-client 0.1.0

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.
Files changed (96) hide show
  1. package/@react-bindings/index.ts +2 -0
  2. package/CHANGELOG.md +44 -0
  3. package/LICENSE +219 -0
  4. package/README.md +9 -0
  5. package/dist/@react-bindings/hooks/useComments.d.ts +12 -0
  6. package/dist/@react-bindings/hooks/useStateStore.d.ts +3 -0
  7. package/dist/@react-bindings/index.d.ts +2 -0
  8. package/dist/index-react-bindings.browser.cjs +56 -0
  9. package/dist/index-react-bindings.browser.cjs.map +1 -0
  10. package/dist/index-react-bindings.browser.js +53 -0
  11. package/dist/index-react-bindings.browser.js.map +1 -0
  12. package/dist/index-react-bindings.node.cjs +56 -0
  13. package/dist/index-react-bindings.node.cjs.map +1 -0
  14. package/dist/index-react-bindings.node.js +53 -0
  15. package/dist/index-react-bindings.node.js.map +1 -0
  16. package/dist/index.browser.cjs +5799 -0
  17. package/dist/index.browser.cjs.map +1 -0
  18. package/dist/index.browser.js +5782 -0
  19. package/dist/index.browser.js.map +1 -0
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.node.cjs +5799 -0
  22. package/dist/index.node.cjs.map +1 -0
  23. package/dist/index.node.js +5782 -0
  24. package/dist/index.node.js.map +1 -0
  25. package/dist/src/Feed.d.ts +109 -0
  26. package/dist/src/FeedsClient.d.ts +63 -0
  27. package/dist/src/ModerationClient.d.ts +3 -0
  28. package/dist/src/common/ActivitySearchSource.d.ts +17 -0
  29. package/dist/src/common/ApiClient.d.ts +20 -0
  30. package/dist/src/common/BaseSearchSource.d.ts +87 -0
  31. package/dist/src/common/ConnectionIdManager.d.ts +11 -0
  32. package/dist/src/common/EventDispatcher.d.ts +11 -0
  33. package/dist/src/common/FeedSearchSource.d.ts +17 -0
  34. package/dist/src/common/Poll.d.ts +34 -0
  35. package/dist/src/common/SearchController.d.ts +41 -0
  36. package/dist/src/common/StateStore.d.ts +124 -0
  37. package/dist/src/common/TokenManager.d.ts +29 -0
  38. package/dist/src/common/UserSearchSource.d.ts +17 -0
  39. package/dist/src/common/gen-imports.d.ts +2 -0
  40. package/dist/src/common/rate-limit.d.ts +2 -0
  41. package/dist/src/common/real-time/StableWSConnection.d.ts +144 -0
  42. package/dist/src/common/real-time/event-models.d.ts +36 -0
  43. package/dist/src/common/types.d.ts +29 -0
  44. package/dist/src/common/utils.d.ts +54 -0
  45. package/dist/src/gen/feeds/FeedApi.d.ts +26 -0
  46. package/dist/src/gen/feeds/FeedsApi.d.ts +237 -0
  47. package/dist/src/gen/model-decoders/decoders.d.ts +3 -0
  48. package/dist/src/gen/model-decoders/event-decoder-mapping.d.ts +6 -0
  49. package/dist/src/gen/models/index.d.ts +3437 -0
  50. package/dist/src/gen/moderation/ModerationApi.d.ts +21 -0
  51. package/dist/src/gen-imports.d.ts +3 -0
  52. package/dist/src/state-updates/activity-reaction-utils.d.ts +10 -0
  53. package/dist/src/state-updates/activity-utils.d.ts +13 -0
  54. package/dist/src/state-updates/bookmark-utils.d.ts +14 -0
  55. package/dist/src/types-internal.d.ts +4 -0
  56. package/dist/src/types.d.ts +13 -0
  57. package/dist/src/utils.d.ts +1 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -0
  59. package/index.ts +13 -0
  60. package/package.json +85 -0
  61. package/src/Feed.ts +1070 -0
  62. package/src/FeedsClient.ts +352 -0
  63. package/src/ModerationClient.ts +3 -0
  64. package/src/common/ActivitySearchSource.ts +46 -0
  65. package/src/common/ApiClient.ts +197 -0
  66. package/src/common/BaseSearchSource.ts +238 -0
  67. package/src/common/ConnectionIdManager.ts +51 -0
  68. package/src/common/EventDispatcher.ts +52 -0
  69. package/src/common/FeedSearchSource.ts +94 -0
  70. package/src/common/Poll.ts +313 -0
  71. package/src/common/SearchController.ts +152 -0
  72. package/src/common/StateStore.ts +314 -0
  73. package/src/common/TokenManager.ts +112 -0
  74. package/src/common/UserSearchSource.ts +93 -0
  75. package/src/common/gen-imports.ts +2 -0
  76. package/src/common/rate-limit.ts +23 -0
  77. package/src/common/real-time/StableWSConnection.ts +761 -0
  78. package/src/common/real-time/event-models.ts +38 -0
  79. package/src/common/types.ts +40 -0
  80. package/src/common/utils.ts +194 -0
  81. package/src/gen/feeds/FeedApi.ts +129 -0
  82. package/src/gen/feeds/FeedsApi.ts +2192 -0
  83. package/src/gen/model-decoders/decoders.ts +1877 -0
  84. package/src/gen/model-decoders/event-decoder-mapping.ts +150 -0
  85. package/src/gen/models/index.ts +5882 -0
  86. package/src/gen/moderation/ModerationApi.ts +270 -0
  87. package/src/gen-imports.ts +3 -0
  88. package/src/state-updates/activity-reaction-utils.test.ts +348 -0
  89. package/src/state-updates/activity-reaction-utils.ts +107 -0
  90. package/src/state-updates/activity-utils.test.ts +257 -0
  91. package/src/state-updates/activity-utils.ts +80 -0
  92. package/src/state-updates/bookmark-utils.test.ts +383 -0
  93. package/src/state-updates/bookmark-utils.ts +157 -0
  94. package/src/types-internal.ts +5 -0
  95. package/src/types.ts +20 -0
  96. package/src/utils.ts +4 -0
package/src/Feed.ts ADDED
@@ -0,0 +1,1070 @@
1
+ import {
2
+ ActivityRequest,
3
+ FeedResponse,
4
+ GetOrCreateFeedRequest,
5
+ GetOrCreateFeedResponse,
6
+ QueryFollowsRequest,
7
+ WSEvent,
8
+ ActivityResponse,
9
+ CommentResponse,
10
+ PagerResponse,
11
+ SingleFollowRequest,
12
+ CommentReactionAddedEvent,
13
+ CommentReactionDeletedEvent,
14
+ BookmarkAddedEvent,
15
+ BookmarkDeletedEvent,
16
+ BookmarkUpdatedEvent,
17
+ } from './gen/models';
18
+ import { Patch, StateStore } from './common/StateStore';
19
+ import { EventDispatcher } from './common/EventDispatcher';
20
+ import { FeedApi } from './gen/feeds/FeedApi';
21
+ import { FeedsClient } from './FeedsClient';
22
+ import {
23
+ addActivitiesToState,
24
+ updateActivityInState,
25
+ removeActivityFromState,
26
+ } from './state-updates/activity-utils';
27
+ import {
28
+ addReactionToActivities,
29
+ removeReactionFromActivities,
30
+ } from './state-updates/activity-reaction-utils';
31
+ import {
32
+ addBookmarkToActivities,
33
+ removeBookmarkFromActivities,
34
+ updateBookmarkInActivities,
35
+ } from './state-updates/bookmark-utils';
36
+ import { FeedsApi, StreamResponse } from './gen-imports';
37
+ import { capitalize } from './common/utils';
38
+ import type {
39
+ ActivityIdOrCommentId,
40
+ GetCommentsRepliesRequest,
41
+ GetCommentsRequest,
42
+ LoadingStates,
43
+ PagerResponseWithLoadingStates,
44
+ } from './types';
45
+ import type { FromArray } from './types-internal';
46
+
47
+ export type FeedState = Omit<
48
+ Partial<GetOrCreateFeedResponse & FeedResponse>,
49
+ 'feed' | 'duration'
50
+ > & {
51
+ /**
52
+ * True when loading state using `getOrCreate`
53
+ */
54
+ is_loading: boolean;
55
+ /**
56
+ * True when loading activities using `getOrCreate` or `getNextPage`
57
+ */
58
+ is_loading_activities: boolean;
59
+
60
+ comments_by_entity_id: Record<
61
+ ActivityIdOrCommentId,
62
+ | {
63
+ pagination?: PagerResponseWithLoadingStates & {
64
+ // registered on first pagination attempt and then used for real-time updates & subsequent pagination calls
65
+ sort?: string;
66
+ };
67
+ /**
68
+ * Id of the "store" where the actual parent is stored in the comments array.
69
+ *
70
+ * Example:
71
+ * ```
72
+ * // top-level comment:
73
+ * const comment1 = {
74
+ * id: 'comment-1',
75
+ * object_id: 'activity-1',
76
+ * }
77
+ * // child comment:
78
+ * const comment2 = {
79
+ * id: 'comment-2',
80
+ * object_id: 'activity-1',
81
+ * parent_id: 'comment-1',
82
+ * }
83
+ * ```
84
+ * When these comments are loaded, they're stored in the state like this:
85
+ * ```
86
+ * {
87
+ * comments_by_entity_id: {
88
+ * 'activity-1': {
89
+ * comments: [comment1],
90
+ * parent_id: undefined,
91
+ * },
92
+ * 'comment-1': {
93
+ * comments: [comment2],
94
+ * parent_id: 'activity-1', // parent store where "comment-1" is located in "comments" array
95
+ * }
96
+ * }
97
+ * }
98
+ * ```
99
+ */
100
+ parent_id?: ActivityIdOrCommentId;
101
+ comments?: CommentResponse[];
102
+ }
103
+ | undefined
104
+ >;
105
+
106
+ followers_pagination?: LoadingStates & { sort?: string };
107
+
108
+ following_pagination?: LoadingStates & { sort?: string };
109
+ };
110
+
111
+ const END_OF_LIST = 'eol' as const;
112
+ const DEFAULT_COMMENT_PAGINATION = 'first' as const;
113
+
114
+ type EventHandlerByEventType = {
115
+ [Key in NonNullable<WSEvent['type']>]: Key extends Extract<
116
+ WSEvent,
117
+ { type: Key }
118
+ >['type']
119
+ ? ((event: Extract<WSEvent, { type: Key }>) => void) | undefined
120
+ : never;
121
+ };
122
+
123
+ export class Feed extends FeedApi {
124
+ readonly state: StateStore<FeedState>;
125
+ private static readonly noop = () => {};
126
+
127
+ private readonly eventHandlers: EventHandlerByEventType = {
128
+ 'feeds.activity.added': (event) => {
129
+ const currentActivities = this.currentState.activities;
130
+ const result = addActivitiesToState(
131
+ [event.activity],
132
+ currentActivities,
133
+ 'start',
134
+ );
135
+ if (result.changed) {
136
+ this.client.hydratePollCache([event.activity]);
137
+ this.state.partialNext({ activities: result.activities });
138
+ }
139
+ },
140
+ 'feeds.activity.deleted': (event) => {
141
+ const currentActivities = this.currentState.activities;
142
+ if (currentActivities) {
143
+ const result = removeActivityFromState(
144
+ event.activity,
145
+ currentActivities,
146
+ );
147
+ if (result.changed) {
148
+ this.state.partialNext({ activities: result.activities });
149
+ }
150
+ }
151
+ },
152
+ 'feeds.activity.reaction.added': (event) => {
153
+ const currentActivities = this.currentState.activities;
154
+ const connectedUser = this.client.state.getLatestValue().connectedUser;
155
+ const isCurrentUser = Boolean(
156
+ connectedUser && event.reaction.user.id === connectedUser.id,
157
+ );
158
+
159
+ const result = addReactionToActivities(
160
+ event,
161
+ currentActivities,
162
+ isCurrentUser,
163
+ );
164
+ if (result.changed) {
165
+ this.state.partialNext({ activities: result.activities });
166
+ }
167
+ },
168
+ 'feeds.activity.reaction.deleted': (event) => {
169
+ const currentActivities = this.currentState.activities;
170
+ const connectedUser = this.client.state.getLatestValue().connectedUser;
171
+ const isCurrentUser = Boolean(
172
+ connectedUser && event.reaction.user.id === connectedUser.id,
173
+ );
174
+
175
+ const result = removeReactionFromActivities(
176
+ event,
177
+ currentActivities,
178
+ isCurrentUser,
179
+ );
180
+ if (result.changed) {
181
+ this.state.partialNext({ activities: result.activities });
182
+ }
183
+ },
184
+ 'feeds.activity.reaction.updated': Feed.noop,
185
+ 'feeds.activity.removed_from_feed': (event) => {
186
+ const currentActivities = this.currentState.activities;
187
+ if (currentActivities) {
188
+ const result = removeActivityFromState(
189
+ event.activity,
190
+ currentActivities,
191
+ );
192
+ if (result.changed) {
193
+ this.state.partialNext({ activities: result.activities });
194
+ }
195
+ }
196
+ },
197
+ 'feeds.activity.updated': (event) => {
198
+ const currentActivities = this.currentState.activities;
199
+ if (currentActivities) {
200
+ const result = updateActivityInState(event.activity, currentActivities);
201
+ if (result.changed) {
202
+ this.client.hydratePollCache([event.activity]);
203
+ this.state.partialNext({ activities: result.activities });
204
+ }
205
+ }
206
+ },
207
+ 'feeds.bookmark.added': this.handleBookmarkAdded.bind(this),
208
+ 'feeds.bookmark.deleted': this.handleBookmarkDeleted.bind(this),
209
+ 'feeds.bookmark.updated': this.handleBookmarkUpdated.bind(this),
210
+ 'feeds.comment.added': (event) => {
211
+ const { comment } = event;
212
+ const forId = comment.parent_id ?? comment.object_id;
213
+
214
+ this.state.next((currentState) => {
215
+ const entityState = currentState.comments_by_entity_id[forId];
216
+ const newComments = entityState?.comments?.concat([]) ?? [];
217
+
218
+ if (
219
+ entityState?.pagination?.sort === 'last' &&
220
+ entityState?.pagination.next === END_OF_LIST
221
+ ) {
222
+ newComments.unshift(comment);
223
+ } else if (entityState?.pagination?.sort === 'first') {
224
+ newComments.push(comment);
225
+ } else {
226
+ // no other sorting option is supported yet
227
+ return currentState;
228
+ }
229
+
230
+ return {
231
+ ...currentState,
232
+ comments_by_entity_id: {
233
+ ...currentState.comments_by_entity_id,
234
+ [forId]: {
235
+ ...currentState.comments_by_entity_id[forId],
236
+ comments: newComments,
237
+ },
238
+ },
239
+ };
240
+ });
241
+ },
242
+ 'feeds.comment.deleted': ({ comment }) => {
243
+ const forId = comment.parent_id ?? comment.object_id;
244
+
245
+ this.state.next((currentState) => {
246
+ const newCommentsByEntityId = {
247
+ ...currentState.comments_by_entity_id,
248
+ [forId]: {
249
+ ...currentState.comments_by_entity_id[forId],
250
+ },
251
+ };
252
+
253
+ const index = this.getCommentIndex(comment, currentState);
254
+
255
+ if (newCommentsByEntityId?.[forId]?.comments?.length && index !== -1) {
256
+ newCommentsByEntityId[forId].comments = [
257
+ ...newCommentsByEntityId[forId].comments,
258
+ ];
259
+
260
+ newCommentsByEntityId[forId]?.comments?.splice(index, 1);
261
+ }
262
+
263
+ delete newCommentsByEntityId[comment.id];
264
+
265
+ return {
266
+ ...currentState,
267
+ comments_by_entity_id: newCommentsByEntityId,
268
+ };
269
+ });
270
+ },
271
+ 'feeds.comment.updated': (event) => {
272
+ const { comment } = event;
273
+ const forId = comment.parent_id ?? comment.object_id;
274
+
275
+ this.state.next((currentState) => {
276
+ const entityState = currentState.comments_by_entity_id[forId];
277
+
278
+ if (!entityState?.comments?.length) return currentState;
279
+
280
+ const index = this.getCommentIndex(comment, currentState);
281
+
282
+ if (index === -1) return currentState;
283
+
284
+ const newComments = [...entityState.comments];
285
+
286
+ newComments[index] = comment;
287
+
288
+ return {
289
+ ...currentState,
290
+ comments_by_entity_id: {
291
+ ...currentState.comments_by_entity_id,
292
+ [forId]: {
293
+ ...currentState.comments_by_entity_id[forId],
294
+ comments: newComments,
295
+ },
296
+ },
297
+ };
298
+ });
299
+ },
300
+ 'feeds.feed.created': Feed.noop,
301
+ 'feeds.feed.deleted': Feed.noop,
302
+ 'feeds.feed.updated': (event) => {
303
+ this.state.partialNext({ ...event.feed });
304
+ },
305
+ 'feeds.feed_group.changed': Feed.noop,
306
+ 'feeds.feed_group.deleted': Feed.noop,
307
+ 'feeds.follow.created': (event) => {
308
+ // filter non-accepted follows (the way getOrCreate does by default)
309
+ if (event.follow.status !== 'accepted') return;
310
+
311
+ // this feed followed someone
312
+ if (event.follow.source_feed.fid === this.fid) {
313
+ if (this.currentState.following_pagination?.next === END_OF_LIST) {
314
+ this.state.next((currentState) => ({
315
+ ...currentState,
316
+ ...event.follow.source_feed,
317
+ // TODO: respect sort
318
+ following: currentState.following
319
+ ? currentState.following.concat(event.follow)
320
+ : [event.follow],
321
+ }));
322
+ }
323
+ } else if (
324
+ // someone followed this feed
325
+ event.follow.target_feed.fid === this.fid
326
+ ) {
327
+ const source = event.follow.source_feed;
328
+ const connectedUser = this.client.state.getLatestValue().connectedUser;
329
+
330
+ this.state.next((currentState) => {
331
+ const newState = { ...currentState, ...event.follow.target_feed };
332
+
333
+ if (source.created_by.id === connectedUser?.id) {
334
+ newState.own_follows = newState.own_follows
335
+ ? newState.own_follows.concat(event.follow)
336
+ : [event.follow];
337
+ }
338
+
339
+ if (currentState.followers_pagination?.next === END_OF_LIST) {
340
+ // TODO: respect sort
341
+ newState.followers = newState.followers
342
+ ? newState.followers.concat(event.follow)
343
+ : [event.follow];
344
+ }
345
+
346
+ return newState;
347
+ });
348
+ }
349
+ },
350
+ 'feeds.follow.deleted': (event) => {
351
+ // this feed unfollowed someone
352
+ if (event.follow.source_feed.fid === this.fid) {
353
+ this.state.next((currentState) => {
354
+ return {
355
+ ...currentState,
356
+ ...event.follow.source_feed,
357
+ following: currentState.following?.filter(
358
+ (follow) =>
359
+ follow.target_feed.fid !== event.follow.target_feed.fid,
360
+ ),
361
+ };
362
+ });
363
+ } else if (
364
+ // someone unfollowed this feed
365
+ event.follow.target_feed.fid === this.fid
366
+ ) {
367
+ const source = event.follow.source_feed;
368
+ const connectedUser = this.client.state.getLatestValue().connectedUser;
369
+
370
+ this.state.next((currentState) => {
371
+ const newState = { ...currentState, ...event.follow.target_feed };
372
+
373
+ if (source.created_by.id === connectedUser?.id) {
374
+ newState.own_follows = newState.own_follows?.filter(
375
+ (follow) =>
376
+ follow.source_feed.fid !== event.follow.source_feed.fid,
377
+ );
378
+ }
379
+
380
+ newState.followers = newState.followers?.filter(
381
+ (follow) => follow.source_feed.fid !== event.follow.source_feed.fid,
382
+ );
383
+
384
+ return newState;
385
+ });
386
+ }
387
+ },
388
+ 'feeds.follow.updated': Feed.noop,
389
+ 'feeds.comment.reaction.added': this.handleCommentReactionEvent.bind(this),
390
+ 'feeds.comment.reaction.deleted':
391
+ this.handleCommentReactionEvent.bind(this),
392
+ 'feeds.comment.reaction.updated': Feed.noop,
393
+ 'feeds.feed_member.added': (event) => {
394
+ const { member } = event;
395
+
396
+ // do not add a member if the pagination has reached the end of the list
397
+ if (this.currentState.member_pagination?.next !== END_OF_LIST) return;
398
+
399
+ this.state.next((currentState) => {
400
+ return {
401
+ ...currentState,
402
+ // TODO: respect sort
403
+ members: currentState.members
404
+ ? currentState.members.concat(member)
405
+ : [member],
406
+ };
407
+ });
408
+ },
409
+ 'feeds.feed_member.removed': (event) => {
410
+ this.state.next((currentState) => {
411
+ return {
412
+ ...currentState,
413
+ members: currentState.members?.filter(
414
+ (member) => member.user.id !== event.user?.id,
415
+ ),
416
+ };
417
+ });
418
+ },
419
+ 'feeds.feed_member.updated': (event) => {
420
+ this.state.next((currentState) => {
421
+ const memberIndex =
422
+ currentState.members?.findIndex(
423
+ (member) => member.user.id === event.member.user.id,
424
+ ) ?? -1;
425
+
426
+ if (memberIndex !== -1) {
427
+ const newMembers = [...currentState.members!];
428
+ newMembers[memberIndex] = event.member;
429
+
430
+ return {
431
+ ...currentState,
432
+ members: newMembers,
433
+ };
434
+ }
435
+
436
+ return currentState;
437
+ });
438
+ },
439
+ // the poll events should be removed from here
440
+ 'feeds.poll.closed': Feed.noop,
441
+ 'feeds.poll.deleted': Feed.noop,
442
+ 'feeds.poll.updated': Feed.noop,
443
+ 'feeds.poll.vote_casted': Feed.noop,
444
+ 'feeds.poll.vote_changed': Feed.noop,
445
+ 'feeds.poll.vote_removed': Feed.noop,
446
+ 'feeds.activity.pinned': Feed.noop,
447
+ 'feeds.activity.unpinned': Feed.noop,
448
+ 'feeds.activity.marked': Feed.noop,
449
+ 'moderation.custom_action': Feed.noop,
450
+ 'moderation.flagged': Feed.noop,
451
+ 'moderation.mark_reviewed': Feed.noop,
452
+ 'health.check': Feed.noop,
453
+ 'app.updated': Feed.noop,
454
+ 'user.banned': Feed.noop,
455
+ 'user.deactivated': Feed.noop,
456
+ 'user.muted': Feed.noop,
457
+ 'user.reactivated': Feed.noop,
458
+ 'user.updated': Feed.noop,
459
+ };
460
+
461
+ protected eventDispatcher: EventDispatcher<WSEvent['type'], WSEvent> =
462
+ new EventDispatcher<WSEvent['type'], WSEvent>();
463
+
464
+ constructor(
465
+ client: FeedsClient,
466
+ groupId: 'user' | 'timeline' | (string & {}),
467
+ id: string,
468
+ data?: FeedResponse,
469
+ ) {
470
+ // Need this ugly cast because fileUpload endpoints :(
471
+ super(client as unknown as FeedsApi, groupId, id);
472
+ this.state = new StateStore<FeedState>({
473
+ fid: `${groupId}:${id}`,
474
+ group_id: groupId,
475
+ id,
476
+ ...(data ?? {}),
477
+ is_loading: false,
478
+ is_loading_activities: false,
479
+ comments_by_entity_id: {},
480
+ });
481
+ this.client = client;
482
+ }
483
+
484
+ private readonly client: FeedsClient;
485
+
486
+ get fid() {
487
+ return `${this.group}:${this.id}`;
488
+ }
489
+
490
+ get currentState() {
491
+ return this.state.getLatestValue();
492
+ }
493
+
494
+ private handleCommentReactionEvent(
495
+ event: (CommentReactionAddedEvent | CommentReactionDeletedEvent) & {
496
+ type: 'feeds.comment.reaction.added' | 'feeds.comment.reaction.deleted';
497
+ },
498
+ ) {
499
+ const { comment, reaction } = event;
500
+ const connectedUser = this.client.state.getLatestValue().connectedUser;
501
+
502
+ this.state.next((currentState) => {
503
+ const forId = comment.parent_id ?? comment.object_id;
504
+ const entityState = currentState.comments_by_entity_id[forId];
505
+
506
+ const commentIndex = this.getCommentIndex(comment, currentState);
507
+
508
+ if (commentIndex === -1) return currentState;
509
+
510
+ const newComments = entityState?.comments?.concat([]) ?? [];
511
+
512
+ const commentCopy: Partial<CommentResponse> = { ...comment };
513
+
514
+ delete commentCopy.own_reactions;
515
+
516
+ const newComment: CommentResponse = {
517
+ ...newComments[commentIndex],
518
+ ...commentCopy,
519
+ };
520
+
521
+ newComments[commentIndex] = newComment;
522
+
523
+ if (reaction.user.id === connectedUser?.id) {
524
+ if (event.type === 'feeds.comment.reaction.added') {
525
+ newComment.own_reactions = newComment.own_reactions.concat(
526
+ reaction,
527
+ ) ?? [reaction];
528
+ } else if (event.type === 'feeds.comment.reaction.deleted') {
529
+ newComment.own_reactions = newComment.own_reactions.filter(
530
+ (r) => r.type !== reaction.type,
531
+ );
532
+ }
533
+ }
534
+
535
+ return {
536
+ ...currentState,
537
+ comments_by_entity_id: {
538
+ ...currentState.comments_by_entity_id,
539
+ [forId]: {
540
+ ...entityState,
541
+ comments: newComments,
542
+ },
543
+ },
544
+ };
545
+ });
546
+ }
547
+
548
+ async getOrCreate(request?: GetOrCreateFeedRequest) {
549
+ if (this.currentState.is_loading_activities) {
550
+ throw new Error('Only one getOrCreate call is allowed at a time');
551
+ }
552
+
553
+ this.state.partialNext({
554
+ is_loading: !request?.next,
555
+ is_loading_activities: true,
556
+ });
557
+
558
+ // TODO: pull comments/comment_pagination from activities and comment_sort from request
559
+ // and pre-populate comments_by_entity_id (once comment_sort and comment_limit are supported)
560
+
561
+ try {
562
+ const response = await super.getOrCreate(request);
563
+ if (request?.next) {
564
+ const { activities: currentActivities = [] } = this.currentState;
565
+
566
+ const result = addActivitiesToState(
567
+ response.activities,
568
+ currentActivities,
569
+ 'end',
570
+ );
571
+
572
+ if (result.changed) {
573
+ this.state.partialNext({
574
+ activities: result.activities,
575
+ next: response.next,
576
+ prev: response.prev,
577
+ });
578
+ }
579
+ } else {
580
+ const responseCopy: Partial<
581
+ StreamResponse<GetOrCreateFeedResponse>['feed'] &
582
+ StreamResponse<GetOrCreateFeedResponse>
583
+ > = {
584
+ ...response,
585
+ ...response.feed,
586
+ };
587
+
588
+ delete responseCopy.feed;
589
+ delete responseCopy.metadata;
590
+ delete responseCopy.duration;
591
+
592
+ this.state.next((currentState) => {
593
+ const nextState: FeedState = {
594
+ ...currentState,
595
+ ...responseCopy,
596
+ };
597
+
598
+ // if there is no next cursor, set it to END_OF_LIST
599
+ // request has to have a limit set for this to work
600
+ if (
601
+ (request?.followers_pagination?.limit ?? 0) > 0 &&
602
+ typeof nextState.followers_pagination?.next === 'undefined'
603
+ ) {
604
+ nextState.followers_pagination = {
605
+ ...nextState.followers_pagination,
606
+ next: END_OF_LIST,
607
+ };
608
+ }
609
+
610
+ if (
611
+ (request?.following_pagination?.limit ?? 0) > 0 &&
612
+ typeof nextState.following_pagination?.next === 'undefined'
613
+ ) {
614
+ nextState.following_pagination = {
615
+ ...nextState.following_pagination,
616
+ next: END_OF_LIST,
617
+ };
618
+ }
619
+
620
+ if (
621
+ (request?.member_pagination?.limit ?? 0) > 0 &&
622
+ typeof nextState.member_pagination?.next === 'undefined'
623
+ ) {
624
+ nextState.member_pagination = {
625
+ ...nextState.member_pagination,
626
+ next: END_OF_LIST,
627
+ };
628
+ }
629
+
630
+ if (!request?.followers_pagination?.limit) {
631
+ delete nextState.followers;
632
+ }
633
+ if (!request?.following_pagination?.limit) {
634
+ delete nextState.following;
635
+ }
636
+
637
+ return nextState;
638
+ });
639
+ }
640
+
641
+ this.client.hydratePollCache(response.activities);
642
+
643
+ return response;
644
+ } finally {
645
+ this.state.partialNext({
646
+ is_loading: false,
647
+ is_loading_activities: false,
648
+ });
649
+ }
650
+ }
651
+
652
+ private handleBookmarkAdded(event: BookmarkAddedEvent) {
653
+ const currentActivities = this.currentState.activities;
654
+ const { connectedUser } = this.client.state.getLatestValue();
655
+ const isCurrentUser = event.bookmark.user.id === connectedUser?.id;
656
+
657
+ const result = addBookmarkToActivities(
658
+ event,
659
+ currentActivities,
660
+ isCurrentUser,
661
+ );
662
+ if (result.changed) {
663
+ this.state.partialNext({ activities: result.activities });
664
+ }
665
+ }
666
+
667
+ private handleBookmarkDeleted(event: BookmarkDeletedEvent) {
668
+ const currentActivities = this.currentState.activities;
669
+ const { connectedUser } = this.client.state.getLatestValue();
670
+ const isCurrentUser = event.bookmark.user.id === connectedUser?.id;
671
+
672
+ const result = removeBookmarkFromActivities(
673
+ event,
674
+ currentActivities,
675
+ isCurrentUser,
676
+ );
677
+ if (result.changed) {
678
+ this.state.partialNext({ activities: result.activities });
679
+ }
680
+ }
681
+
682
+ private handleBookmarkUpdated(event: BookmarkUpdatedEvent) {
683
+ const currentActivities = this.currentState.activities;
684
+ const { connectedUser } = this.client.state.getLatestValue();
685
+ const isCurrentUser = event.bookmark.user.id === connectedUser?.id;
686
+
687
+ const result = updateBookmarkInActivities(
688
+ event,
689
+ currentActivities,
690
+ isCurrentUser,
691
+ );
692
+ if (result.changed) {
693
+ this.state.partialNext({ activities: result.activities });
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Returns index of the provided comment object.
699
+ */
700
+ private getCommentIndex(
701
+ comment: Pick<CommentResponse, 'object_id' | 'parent_id' | 'id'>,
702
+ state?: FeedState,
703
+ ) {
704
+ const { comments_by_entity_id = {} } = state ?? this.currentState;
705
+
706
+ const currentComments =
707
+ comments_by_entity_id[comment.parent_id ?? comment.object_id]?.comments;
708
+
709
+ if (!currentComments?.length) {
710
+ return -1;
711
+ }
712
+
713
+ // @ts-expect-error this will just fail if the comment is not object from state
714
+ let commentIndex = currentComments.indexOf(comment);
715
+
716
+ // fast lookup failed, try slower approach
717
+ if (commentIndex === -1) {
718
+ commentIndex = currentComments.findIndex(
719
+ (comment_) => comment_.id === comment.id,
720
+ );
721
+ }
722
+
723
+ return commentIndex;
724
+ }
725
+
726
+ private getActivityIndex(activity: ActivityResponse, state?: FeedState) {
727
+ const { activities } = state ?? this.currentState;
728
+
729
+ if (!activities) {
730
+ return -1;
731
+ }
732
+
733
+ let activityIndex = activities.indexOf(activity);
734
+
735
+ // fast lookup failed, try slower approach
736
+ if (activityIndex === -1) {
737
+ activityIndex = activities.findIndex(
738
+ (activity_) => activity_.id === activity.id,
739
+ );
740
+ }
741
+
742
+ return activityIndex;
743
+ }
744
+
745
+ private updateActivityInState(
746
+ activity: ActivityResponse,
747
+ patch: Patch<FromArray<FeedState['activities']>>,
748
+ ) {
749
+ this.state.next((currentState) => {
750
+ const activityIndex = this.getActivityIndex(activity, currentState);
751
+
752
+ if (activityIndex === -1) return currentState;
753
+
754
+ const nextActivities = [...currentState.activities!];
755
+
756
+ nextActivities[activityIndex] = patch(
757
+ currentState.activities![activityIndex],
758
+ );
759
+
760
+ return {
761
+ ...currentState,
762
+ activities: nextActivities,
763
+ };
764
+ });
765
+ }
766
+
767
+ private async loadNextPageComments({
768
+ forId,
769
+ base,
770
+ sort,
771
+ parentId,
772
+ }: {
773
+ parentId?: string;
774
+ forId: string;
775
+ sort: string;
776
+ base: () => Promise<PagerResponse & { comments: CommentResponse[] }>;
777
+ }) {
778
+ try {
779
+ this.state.next((currentState) => ({
780
+ ...currentState,
781
+ comments_by_entity_id: {
782
+ ...currentState.comments_by_entity_id,
783
+ [forId]: {
784
+ ...currentState.comments_by_entity_id[forId],
785
+ pagination: {
786
+ ...currentState.comments_by_entity_id[forId]?.pagination,
787
+ loading_next_page: true,
788
+ },
789
+ },
790
+ },
791
+ }));
792
+
793
+ const { next: newNextCursor = END_OF_LIST, comments } = await base();
794
+
795
+ this.state.next((currentState) => {
796
+ const newPagination = {
797
+ ...currentState.comments_by_entity_id[forId]?.pagination,
798
+ next: newNextCursor,
799
+ };
800
+
801
+ if (typeof newPagination.sort === 'undefined') {
802
+ newPagination.sort = sort;
803
+ }
804
+
805
+ return {
806
+ ...currentState,
807
+ comments_by_entity_id: {
808
+ ...currentState.comments_by_entity_id,
809
+ [forId]: {
810
+ ...currentState.comments_by_entity_id[forId],
811
+ parent_id: parentId,
812
+ pagination: newPagination,
813
+ comments: currentState.comments_by_entity_id[forId]?.comments
814
+ ? currentState.comments_by_entity_id[forId].comments?.concat(
815
+ comments,
816
+ )
817
+ : comments,
818
+ },
819
+ },
820
+ };
821
+ });
822
+ } catch (error) {
823
+ console.error(error);
824
+ // TODO: figure out how to handle errorss
825
+ } finally {
826
+ this.state.next((currentState) => ({
827
+ ...currentState,
828
+ comments_by_entity_id: {
829
+ ...currentState.comments_by_entity_id,
830
+ [forId]: {
831
+ ...currentState.comments_by_entity_id[forId],
832
+ pagination: {
833
+ ...currentState.comments_by_entity_id[forId]?.pagination,
834
+ loading_next_page: false,
835
+ },
836
+ },
837
+ },
838
+ }));
839
+ }
840
+ }
841
+
842
+ public async loadNextPageActivityComments(
843
+ activity: ActivityResponse,
844
+ request?: Partial<
845
+ Omit<GetCommentsRequest, 'object_id' | 'object_type' | 'next'>
846
+ >,
847
+ ) {
848
+ const pagination =
849
+ this.currentState.comments_by_entity_id[activity.id]?.pagination;
850
+ const currentNextCursor = pagination?.next;
851
+ const currentSort = pagination?.sort;
852
+ const isLoading = pagination?.loading_next_page;
853
+
854
+ const sort = currentSort ?? request?.sort ?? DEFAULT_COMMENT_PAGINATION;
855
+
856
+ if (isLoading || currentNextCursor === END_OF_LIST) return;
857
+
858
+ await this.loadNextPageComments({
859
+ forId: activity.id,
860
+ base: () =>
861
+ this.client.getComments({
862
+ ...request,
863
+ sort,
864
+ object_id: activity.id,
865
+ object_type: 'activity',
866
+ next: currentNextCursor,
867
+ }),
868
+ sort,
869
+ });
870
+ }
871
+
872
+ public async loadNextPageCommentReplies(
873
+ comment: CommentResponse,
874
+ request?: Partial<Omit<GetCommentsRepliesRequest, 'comment_id' | 'next'>>,
875
+ ) {
876
+ const pagination =
877
+ this.currentState.comments_by_entity_id[comment.id]?.pagination;
878
+ const currentNextCursor = pagination?.next;
879
+ const currentSort = pagination?.sort;
880
+ const isLoading = pagination?.loading_next_page;
881
+
882
+ const sort = currentSort ?? request?.sort ?? DEFAULT_COMMENT_PAGINATION;
883
+
884
+ if (isLoading || currentNextCursor === END_OF_LIST) return;
885
+
886
+ await this.loadNextPageComments({
887
+ forId: comment.id,
888
+ base: () =>
889
+ this.client.getCommentReplies({
890
+ ...request,
891
+ comment_id: comment.id,
892
+ // use known sort first (prevents broken pagination)
893
+ sort: currentSort ?? request?.sort ?? DEFAULT_COMMENT_PAGINATION,
894
+ next: currentNextCursor,
895
+ }),
896
+ parentId: comment.parent_id ?? comment.object_id,
897
+ sort,
898
+ });
899
+ }
900
+
901
+ private async loadNextPageFollows(
902
+ type: 'followers' | 'following',
903
+ request: Pick<QueryFollowsRequest, 'limit'>,
904
+ ) {
905
+ const paginationKey = `${type}_pagination` as const;
906
+ const method = `query${capitalize(type)}` as const;
907
+
908
+ const currentNextCursor = this.currentState[paginationKey]?.next;
909
+ const isLoading = this.currentState[paginationKey]?.loading_next_page;
910
+
911
+ if (isLoading || currentNextCursor === END_OF_LIST) return;
912
+
913
+ try {
914
+ this.state.next((currentState) => {
915
+ return {
916
+ ...currentState,
917
+ [paginationKey]: {
918
+ ...currentState[paginationKey],
919
+ loading_next_page: true,
920
+ },
921
+ };
922
+ });
923
+
924
+ const { next: newNextCursor = END_OF_LIST, follows } = await this[method](
925
+ {
926
+ ...request,
927
+ next: currentNextCursor,
928
+ },
929
+ );
930
+
931
+ this.state.next((currentState) => ({
932
+ ...currentState,
933
+ [type]: currentState[type]
934
+ ? currentState[type].concat(follows)
935
+ : follows,
936
+ [paginationKey]: {
937
+ ...currentState[paginationKey],
938
+ next: newNextCursor,
939
+ },
940
+ }));
941
+ } catch (error) {
942
+ console.error(error);
943
+ // TODO: figure out how to handle errorss
944
+ } finally {
945
+ this.state.next((currentState) => {
946
+ return {
947
+ ...currentState,
948
+ [paginationKey]: {
949
+ ...currentState[paginationKey],
950
+ loading_next_page: false,
951
+ },
952
+ };
953
+ });
954
+ }
955
+ }
956
+
957
+ async loadNextPageFollowers(request: Pick<QueryFollowsRequest, 'limit'>) {
958
+ await this.loadNextPageFollows('followers', request);
959
+ }
960
+
961
+ async loadNextPageFollowing(request: Pick<QueryFollowsRequest, 'limit'>) {
962
+ await this.loadNextPageFollows('following', request);
963
+ }
964
+
965
+ /**
966
+ * Method which queries followers of this feed (feeds which target this feed).
967
+ *
968
+ * _Note: Useful only for feeds with `groupId` of `user` value._
969
+ */
970
+ async queryFollowers(request: Omit<QueryFollowsRequest, 'filter'>) {
971
+ const filter: QueryFollowsRequest['filter'] = {
972
+ target_feed: this.fid,
973
+ };
974
+
975
+ const response = await this.client.queryFollows({
976
+ filter,
977
+ ...request,
978
+ });
979
+
980
+ return response;
981
+ }
982
+
983
+ /**
984
+ * Method which queries following of this feed (target feeds of this feed).
985
+ *
986
+ * _Note: Useful only for feeds with `groupId` of `timeline` value._
987
+ */
988
+ async queryFollowing(request: Omit<QueryFollowsRequest, 'filter'>) {
989
+ const filter: QueryFollowsRequest['filter'] = {
990
+ source_feed: this.fid,
991
+ };
992
+
993
+ const response = await this.client.queryFollows({
994
+ filter,
995
+ ...request,
996
+ });
997
+
998
+ return response;
999
+ }
1000
+
1001
+ async follow(
1002
+ feedOrFid: Feed | string,
1003
+ options?: Omit<SingleFollowRequest, 'source' | 'target'>,
1004
+ ) {
1005
+ const fid = typeof feedOrFid === 'string' ? feedOrFid : feedOrFid.fid;
1006
+
1007
+ const response = await this.client.follow({
1008
+ ...options,
1009
+ source: this.fid,
1010
+ target: fid,
1011
+ });
1012
+
1013
+ return response;
1014
+ }
1015
+
1016
+ async unfollow(feedOrFid: Feed | string) {
1017
+ const fid = typeof feedOrFid === 'string' ? feedOrFid : feedOrFid.fid;
1018
+
1019
+ const response = await this.client.unfollow({
1020
+ source: this.fid,
1021
+ target: fid,
1022
+ });
1023
+
1024
+ return response;
1025
+ }
1026
+
1027
+ async getNextPage() {
1028
+ const currentState = this.currentState;
1029
+ const response = await this.getOrCreate({
1030
+ member_pagination: {
1031
+ limit: 0,
1032
+ },
1033
+ followers_pagination: {
1034
+ limit: 0,
1035
+ },
1036
+ following_pagination: {
1037
+ limit: 0,
1038
+ },
1039
+ next: currentState.next,
1040
+ });
1041
+
1042
+ return response;
1043
+ }
1044
+
1045
+ addActivity(request: Omit<ActivityRequest, 'fids'>) {
1046
+ return this.feedsApi.addActivity({
1047
+ ...request,
1048
+ fids: [this.fid],
1049
+ });
1050
+ }
1051
+
1052
+ on = this.eventDispatcher.on;
1053
+ off = this.eventDispatcher.off;
1054
+
1055
+ handleWSEvent(event: WSEvent) {
1056
+ const eventHandler = this.eventHandlers[event.type];
1057
+
1058
+ // no need to run noop function
1059
+ if (eventHandler !== Feed.noop) {
1060
+ // @ts-expect-error intersection of handler arguments results to never
1061
+ eventHandler?.(event);
1062
+ }
1063
+
1064
+ if (typeof eventHandler === 'undefined') {
1065
+ console.warn(`Received unknown event type: ${event.type}`, event);
1066
+ }
1067
+
1068
+ this.eventDispatcher.dispatch(event);
1069
+ }
1070
+ }