@stream-io/feeds-client 0.2.0 → 0.2.2

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 (83) hide show
  1. package/@react-bindings/hooks/feed-state-hooks/index.ts +4 -0
  2. package/CHANGELOG.md +16 -0
  3. package/dist/@react-bindings/hooks/feed-state-hooks/index.d.ts +4 -0
  4. package/dist/@react-bindings/hooks/feed-state-hooks/useAggregatedActivities.d.ts +11 -0
  5. package/dist/@react-bindings/hooks/feed-state-hooks/useIsAggregatedActivityRead.d.ts +6 -0
  6. package/dist/@react-bindings/hooks/feed-state-hooks/useIsAggregatedActivitySeen.d.ts +6 -0
  7. package/dist/@react-bindings/hooks/feed-state-hooks/useNotificationStatus.d.ts +13 -0
  8. package/dist/@react-bindings/wrappers/StreamFeed.d.ts +1 -1
  9. package/dist/index-react-bindings.browser.cjs +505 -222
  10. package/dist/index-react-bindings.browser.cjs.map +1 -1
  11. package/dist/index-react-bindings.browser.js +502 -223
  12. package/dist/index-react-bindings.browser.js.map +1 -1
  13. package/dist/index-react-bindings.node.cjs +505 -222
  14. package/dist/index-react-bindings.node.cjs.map +1 -1
  15. package/dist/index-react-bindings.node.js +502 -223
  16. package/dist/index-react-bindings.node.js.map +1 -1
  17. package/dist/index.browser.cjs +440 -205
  18. package/dist/index.browser.cjs.map +1 -1
  19. package/dist/index.browser.js +440 -206
  20. package/dist/index.browser.js.map +1 -1
  21. package/dist/index.node.cjs +440 -205
  22. package/dist/index.node.cjs.map +1 -1
  23. package/dist/index.node.js +440 -206
  24. package/dist/index.node.js.map +1 -1
  25. package/dist/src/feed/event-handlers/activity/handle-activity-deleted.d.ts +12 -3
  26. package/dist/src/feed/event-handlers/activity/handle-activity-marked.d.ts +11 -0
  27. package/dist/src/feed/event-handlers/activity/handle-activity-pinned.d.ts +3 -0
  28. package/dist/src/feed/event-handlers/activity/handle-activity-reaction-added.d.ts +10 -6
  29. package/dist/src/feed/event-handlers/activity/handle-activity-reaction-deleted.d.ts +10 -6
  30. package/dist/src/feed/event-handlers/activity/handle-activity-unpinned.d.ts +3 -0
  31. package/dist/src/feed/event-handlers/activity/handle-activity-updated.d.ts +7 -3
  32. package/dist/src/feed/event-handlers/activity/index.d.ts +1 -0
  33. package/dist/src/feed/event-handlers/bookmark/handle-bookmark-added.d.ts +10 -6
  34. package/dist/src/feed/event-handlers/bookmark/handle-bookmark-deleted.d.ts +10 -6
  35. package/dist/src/feed/event-handlers/bookmark/handle-bookmark-updated.d.ts +10 -6
  36. package/dist/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.d.ts +8 -1
  37. package/dist/src/feed/feed.d.ts +2 -2
  38. package/dist/src/gen/models/index.d.ts +36 -1
  39. package/dist/src/test-utils/response-generators.d.ts +66 -1
  40. package/dist/src/utils/index.d.ts +1 -0
  41. package/dist/src/utils/update-entity-in-array.d.ts +27 -0
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/feed/event-handlers/activity/activity-marked-utils.test.ts +208 -0
  45. package/src/feed/event-handlers/activity/activity-reaction-utils.test.ts +108 -96
  46. package/src/feed/event-handlers/activity/activity-utils.test.ts +84 -122
  47. package/src/feed/event-handlers/activity/handle-activity-deleted.ts +43 -10
  48. package/src/feed/event-handlers/activity/handle-activity-marked.ts +68 -0
  49. package/src/feed/event-handlers/activity/handle-activity-pinned.test.ts +60 -0
  50. package/src/feed/event-handlers/activity/handle-activity-pinned.ts +30 -0
  51. package/src/feed/event-handlers/activity/handle-activity-reaction-added.test.ts +157 -0
  52. package/src/feed/event-handlers/activity/handle-activity-reaction-added.ts +82 -40
  53. package/src/feed/event-handlers/activity/handle-activity-reaction-deleted.test.ts +200 -0
  54. package/src/feed/event-handlers/activity/handle-activity-reaction-deleted.ts +89 -51
  55. package/src/feed/event-handlers/activity/handle-activity-unpinned.test.ts +95 -0
  56. package/src/feed/event-handlers/activity/handle-activity-unpinned.ts +30 -0
  57. package/src/feed/event-handlers/activity/handle-activity-updated.test.ts +115 -0
  58. package/src/feed/event-handlers/activity/handle-activity-updated.ts +73 -35
  59. package/src/feed/event-handlers/activity/index.ts +2 -1
  60. package/src/feed/event-handlers/bookmark/bookmark-utils.test.ts +121 -109
  61. package/src/feed/event-handlers/bookmark/handle-bookmark-added.test.ts +178 -0
  62. package/src/feed/event-handlers/bookmark/handle-bookmark-added.ts +82 -39
  63. package/src/feed/event-handlers/bookmark/handle-bookmark-deleted.test.ts +188 -0
  64. package/src/feed/event-handlers/bookmark/handle-bookmark-deleted.ts +86 -48
  65. package/src/feed/event-handlers/bookmark/handle-bookmark-updated.test.ts +196 -0
  66. package/src/feed/event-handlers/bookmark/handle-bookmark-updated.ts +83 -44
  67. package/src/feed/event-handlers/comment/handle-comment-added.test.ts +147 -0
  68. package/src/feed/event-handlers/comment/handle-comment-deleted.test.ts +133 -0
  69. package/src/feed/event-handlers/comment/handle-comment-deleted.ts +24 -10
  70. package/src/feed/event-handlers/comment/handle-comment-reaction.test.ts +315 -0
  71. package/src/feed/event-handlers/comment/handle-comment-updated.test.ts +131 -0
  72. package/src/feed/event-handlers/follow/handle-follow-created.test.ts +7 -7
  73. package/src/feed/event-handlers/follow/handle-follow-deleted.test.ts +2 -2
  74. package/src/feed/event-handlers/follow/handle-follow-updated.test.ts +1 -1
  75. package/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.test.ts +120 -0
  76. package/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.ts +47 -3
  77. package/src/feed/feed.ts +4 -2
  78. package/src/gen/model-decoders/decoders.ts +14 -1
  79. package/src/gen/models/index.ts +73 -2
  80. package/src/gen/moderation/ModerationApi.ts +1 -0
  81. package/src/test-utils/response-generators.ts +383 -0
  82. package/src/utils/index.ts +1 -0
  83. package/src/utils/update-entity-in-array.ts +51 -0
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { Feed } from '../../../feed';
3
+ import { FeedsClient } from '../../../feeds-client';
4
+ import { handleBookmarkUpdated } from './handle-bookmark-updated';
5
+ import {
6
+ generateActivityPinResponse,
7
+ generateActivityResponse,
8
+ generateFeedResponse,
9
+ generateOwnUser,
10
+ getHumanId,
11
+ generateFeedReactionResponse,
12
+ generateBookmarkUpdatedEvent,
13
+ generateBookmarkResponse,
14
+ } from '../../../test-utils/response-generators';
15
+
16
+ describe(handleBookmarkUpdated.name, () => {
17
+ let feed: Feed;
18
+ let client: FeedsClient;
19
+ let currentUserId: string;
20
+
21
+ beforeEach(() => {
22
+ client = new FeedsClient('mock-api-key');
23
+ currentUserId = getHumanId();
24
+ client.state.partialNext({
25
+ connected_user: generateOwnUser({ id: currentUserId }),
26
+ });
27
+ const feedResponse = generateFeedResponse({
28
+ id: 'main',
29
+ group_id: 'user',
30
+ created_by: { id: currentUserId },
31
+ });
32
+ feed = new Feed(
33
+ client,
34
+ feedResponse.group_id,
35
+ feedResponse.id,
36
+ feedResponse,
37
+ );
38
+ });
39
+
40
+ it('updates a bookmark for the current user and updates activities', () => {
41
+ const event = generateBookmarkUpdatedEvent({
42
+ bookmark: {
43
+ activity: {
44
+ own_reactions: [],
45
+ bookmark_count: 1,
46
+ },
47
+ user: { id: currentUserId },
48
+ updated_at: new Date('2025-08-06T12:00:00Z'),
49
+ },
50
+ });
51
+ const activity = generateActivityResponse({
52
+ id: event.bookmark.activity.id,
53
+ bookmark_count: 1,
54
+ own_bookmarks: [
55
+ generateBookmarkResponse({
56
+ activity: { id: event.bookmark.activity.id },
57
+ user: { id: currentUserId },
58
+ updated_at: new Date('2025-08-05T12:00:00Z'),
59
+ }),
60
+ ],
61
+ own_reactions: [generateFeedReactionResponse()],
62
+ });
63
+ const activityPin = generateActivityPinResponse({
64
+ activity: { ...activity },
65
+ });
66
+ feed.state.partialNext({
67
+ activities: [activity],
68
+ pinned_activities: [activityPin],
69
+ });
70
+
71
+ const stateBefore = feed.currentState;
72
+ expect(stateBefore.activities![0].own_bookmarks).toHaveLength(1);
73
+ expect(
74
+ stateBefore.pinned_activities![0].activity.own_bookmarks,
75
+ ).toHaveLength(1);
76
+ expect(stateBefore.activities![0].own_bookmarks[0].updated_at).not.toBe(
77
+ event.bookmark.updated_at,
78
+ );
79
+ expect(
80
+ stateBefore.pinned_activities![0].activity.own_bookmarks[0].updated_at,
81
+ ).not.toBe(event.bookmark.updated_at);
82
+
83
+ handleBookmarkUpdated.call(feed, event);
84
+
85
+ const stateAfter = feed.currentState;
86
+ expect(stateAfter.activities![0].own_bookmarks).toHaveLength(1);
87
+ expect(stateAfter.pinned_activities![0].activity.own_bookmarks).toHaveLength(1);
88
+ expect(stateAfter.activities![0].own_bookmarks[0]).toBe(event.bookmark);
89
+ expect(stateAfter.pinned_activities![0].activity.own_bookmarks[0]).toBe(
90
+ event.bookmark,
91
+ );
92
+ expect(stateAfter.activities![0].own_reactions).toBe(
93
+ stateBefore.activities![0].own_reactions,
94
+ );
95
+ expect(stateAfter.pinned_activities![0].activity.own_reactions).toBe(
96
+ stateBefore.pinned_activities![0].activity.own_reactions,
97
+ );
98
+ expect(stateAfter.activities![0].bookmark_count).toBe(1);
99
+ expect(stateAfter.pinned_activities![0].activity.bookmark_count).toBe(1);
100
+ });
101
+
102
+ it('does not update own_bookmarks if bookmark is from another user but still updates activity', () => {
103
+ const event = generateBookmarkUpdatedEvent({
104
+ bookmark: {
105
+ activity: {
106
+ own_reactions: [],
107
+ bookmark_count: 2,
108
+ },
109
+ user: { id: 'other-user-id' },
110
+ updated_at: new Date('2025-08-06T12:00:00Z'),
111
+ },
112
+ });
113
+ const activity = generateActivityResponse({
114
+ id: event.bookmark.activity.id,
115
+ bookmark_count: 1,
116
+ own_bookmarks: [
117
+ generateBookmarkResponse({
118
+ activity: { id: event.bookmark.activity.id },
119
+ user: { id: currentUserId },
120
+ updated_at: new Date('2025-08-05T12:00:00Z'),
121
+ }),
122
+ ],
123
+ own_reactions: [generateFeedReactionResponse()],
124
+ });
125
+ const activityPin = generateActivityPinResponse({
126
+ activity: { ...activity },
127
+ });
128
+ feed.state.partialNext({
129
+ activities: [activity],
130
+ pinned_activities: [activityPin],
131
+ });
132
+
133
+ const stateBefore = feed.currentState;
134
+ expect(stateBefore.activities![0].own_bookmarks).toHaveLength(1);
135
+ expect(
136
+ stateBefore.pinned_activities![0].activity.own_bookmarks,
137
+ ).toHaveLength(1);
138
+ expect(stateBefore.activities![0].bookmark_count).toBe(1);
139
+ expect(stateBefore.pinned_activities![0].activity.bookmark_count).toBe(
140
+ 1,
141
+ );
142
+
143
+ handleBookmarkUpdated.call(feed, event);
144
+
145
+ const stateAfter = feed.currentState;
146
+ expect(stateAfter.activities![0].own_bookmarks).toBe(
147
+ stateBefore.activities![0].own_bookmarks,
148
+ );
149
+ expect(stateAfter.pinned_activities![0].activity.own_bookmarks).toBe(
150
+ stateBefore.pinned_activities![0].activity.own_bookmarks,
151
+ );
152
+ expect(stateAfter.activities![0].own_reactions).toBe(
153
+ stateBefore.activities![0].own_reactions,
154
+ );
155
+ expect(stateAfter.pinned_activities![0].activity.own_reactions).toBe(
156
+ stateBefore.pinned_activities![0].activity.own_reactions,
157
+ );
158
+ expect(stateAfter.activities![0].bookmark_count).toBe(2);
159
+ expect(stateAfter.pinned_activities![0].activity.bookmark_count).toBe(2);
160
+ });
161
+
162
+ it('does nothing if activity is not found', () => {
163
+ const event = generateBookmarkUpdatedEvent({
164
+ bookmark: {
165
+ activity: {
166
+ own_reactions: [],
167
+ bookmark_count: 1,
168
+ },
169
+ user: { id: currentUserId },
170
+ },
171
+ });
172
+ const activity = generateActivityResponse({
173
+ id: 'unrelated-activity-id',
174
+ bookmark_count: 1,
175
+ own_bookmarks: [
176
+ generateBookmarkResponse({
177
+ activity: { id: 'unrelated-activity-id' },
178
+ user: { id: currentUserId },
179
+ }),
180
+ ],
181
+ own_reactions: [generateFeedReactionResponse()],
182
+ });
183
+ const activityPin = generateActivityPinResponse({
184
+ activity: { ...activity },
185
+ });
186
+ feed.state.partialNext({
187
+ activities: [activity],
188
+ pinned_activities: [activityPin],
189
+ });
190
+
191
+ const stateBefore = feed.currentState;
192
+ handleBookmarkUpdated.call(feed, event);
193
+ const stateAfter = feed.currentState;
194
+ expect(stateAfter).toBe(stateBefore);
195
+ });
196
+ });
@@ -1,76 +1,115 @@
1
1
  import type { Feed } from '../../../feed';
2
2
  import type {
3
+ ActivityPinResponse,
3
4
  ActivityResponse,
4
5
  BookmarkUpdatedEvent,
5
6
  } from '../../../gen/models';
6
- import type { EventPayload, UpdateStateResult } from '../../../types-internal';
7
+ import type { EventPayload } from '../../../types-internal';
8
+ import { updateEntityInArray } from '../../../utils';
7
9
 
8
- import { updateActivityInState } from '../activity';
9
10
  import { isSameBookmark } from './handle-bookmark-deleted';
10
11
 
11
- export const updateBookmarkInActivity = (
12
- event: BookmarkUpdatedEvent,
13
- activity: ActivityResponse,
14
- isCurrentUser: boolean,
15
- ): UpdateStateResult<ActivityResponse> => {
16
- // Update own_bookmarks if the bookmark is from the current user
17
- let ownBookmarks = activity.own_bookmarks || [];
18
- if (isCurrentUser) {
19
- const bookmarkIndex = ownBookmarks.findIndex((bookmark) =>
12
+ const sharedUpdateActivity = ({
13
+ currentActivity,
14
+ event,
15
+ eventBelongsToCurrentUser,
16
+ }: {
17
+ currentActivity: ActivityResponse;
18
+ event: BookmarkUpdatedEvent;
19
+ eventBelongsToCurrentUser: boolean;
20
+ }): ActivityResponse => {
21
+ let newOwnBookmarks = currentActivity.own_bookmarks;
22
+
23
+ if (eventBelongsToCurrentUser) {
24
+ const bookmarkIndex = newOwnBookmarks.findIndex((bookmark) =>
20
25
  isSameBookmark(bookmark, event.bookmark),
21
26
  );
27
+
22
28
  if (bookmarkIndex !== -1) {
23
- ownBookmarks = [...ownBookmarks];
24
- ownBookmarks[bookmarkIndex] = event.bookmark;
29
+ newOwnBookmarks = [...newOwnBookmarks];
30
+ newOwnBookmarks[bookmarkIndex] = event.bookmark;
25
31
  }
26
32
  }
27
33
 
28
34
  return {
29
- ...activity,
30
- own_bookmarks: ownBookmarks,
31
- changed: true,
35
+ ...event.bookmark.activity,
36
+ own_bookmarks: newOwnBookmarks,
37
+ own_reactions: currentActivity.own_reactions,
32
38
  };
33
39
  };
34
40
 
35
41
  export const updateBookmarkInActivities = (
36
42
  event: BookmarkUpdatedEvent,
37
43
  activities: ActivityResponse[] | undefined,
38
- isCurrentUser: boolean,
39
- ): UpdateStateResult<{ activities: ActivityResponse[] }> => {
40
- if (!activities) {
41
- return { changed: false, activities: [] };
42
- }
44
+ eventBelongsToCurrentUser: boolean,
45
+ ) =>
46
+ updateEntityInArray({
47
+ entities: activities,
48
+ matcher: (activity) => activity.id === event.bookmark.activity.id,
49
+ updater: (matchedActivity) =>
50
+ sharedUpdateActivity({
51
+ currentActivity: matchedActivity,
52
+ event,
53
+ eventBelongsToCurrentUser,
54
+ }),
55
+ });
43
56
 
44
- const activityIndex = activities.findIndex(
45
- (a) => a.id === event.bookmark.activity.id,
46
- );
47
- if (activityIndex === -1) {
48
- return { changed: false, activities };
49
- }
57
+ export const updateBookmarkInPinnedActivities = (
58
+ event: BookmarkUpdatedEvent,
59
+ pinnedActivities: ActivityPinResponse[] | undefined,
60
+ eventBelongsToCurrentUser: boolean,
61
+ ) =>
62
+ updateEntityInArray({
63
+ entities: pinnedActivities,
64
+ matcher: (pinnedActivity) =>
65
+ pinnedActivity.activity.id === event.bookmark.activity.id,
66
+ updater: (matchedPinnedActivity) => {
67
+ const newActivity = sharedUpdateActivity({
68
+ currentActivity: matchedPinnedActivity.activity,
69
+ event,
70
+ eventBelongsToCurrentUser,
71
+ });
50
72
 
51
- const activity = activities[activityIndex];
52
- const updatedActivity = updateBookmarkInActivity(
53
- event,
54
- activity,
55
- isCurrentUser,
56
- );
57
- return updateActivityInState(updatedActivity, activities, true);
58
- };
73
+ if (newActivity === matchedPinnedActivity.activity) {
74
+ return matchedPinnedActivity;
75
+ }
76
+
77
+ return {
78
+ ...matchedPinnedActivity,
79
+ activity: newActivity,
80
+ };
81
+ },
82
+ });
59
83
 
60
84
  export function handleBookmarkUpdated(
61
85
  this: Feed,
62
86
  event: EventPayload<'feeds.bookmark.updated'>,
63
87
  ) {
64
- const currentActivities = this.currentState.activities;
88
+ const {
89
+ activities: currentActivities,
90
+ pinned_activities: currentPinnedActivities,
91
+ } = this.currentState;
65
92
  const { connected_user: connectedUser } = this.client.state.getLatestValue();
66
- const isCurrentUser = event.bookmark.user.id === connectedUser?.id;
93
+ const eventBelongsToCurrentUser =
94
+ event.bookmark.user.id === connectedUser?.id;
95
+
96
+ const [result1, result2] = [
97
+ updateBookmarkInActivities(
98
+ event,
99
+ currentActivities,
100
+ eventBelongsToCurrentUser,
101
+ ),
102
+ updateBookmarkInPinnedActivities(
103
+ event,
104
+ currentPinnedActivities,
105
+ eventBelongsToCurrentUser,
106
+ ),
107
+ ];
67
108
 
68
- const result = updateBookmarkInActivities(
69
- event,
70
- currentActivities,
71
- isCurrentUser,
72
- );
73
- if (result.changed) {
74
- this.state.partialNext({ activities: result.activities });
109
+ if (result1.changed || result2.changed) {
110
+ this.state.partialNext({
111
+ activities: result1.entities,
112
+ pinned_activities: result2.entities,
113
+ });
75
114
  }
76
115
  }
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { Feed } from '../../../feed';
3
+ import { FeedsClient } from '../../../feeds-client';
4
+ import { handleCommentAdded } from './handle-comment-added';
5
+ import {
6
+ generateFeedResponse,
7
+ generateOwnUser,
8
+ getHumanId,
9
+ generateCommentAddedEvent,
10
+ generateCommentResponse,
11
+ } from '../../../test-utils/response-generators';
12
+
13
+ describe(handleCommentAdded.name, () => {
14
+ let feed: Feed;
15
+ let client: FeedsClient;
16
+ let currentUserId: string;
17
+ let activityId: string;
18
+
19
+ beforeEach(() => {
20
+ client = new FeedsClient('mock-api-key');
21
+ currentUserId = getHumanId();
22
+ client.state.partialNext({
23
+ connected_user: generateOwnUser({ id: currentUserId }),
24
+ });
25
+ const feedResponse = generateFeedResponse({
26
+ id: 'main',
27
+ group_id: 'user',
28
+ created_by: { id: currentUserId },
29
+ });
30
+ feed = new Feed(
31
+ client,
32
+ feedResponse.group_id,
33
+ feedResponse.id,
34
+ feedResponse,
35
+ );
36
+ activityId = `activity-${getHumanId()}`;
37
+ });
38
+
39
+ it('appends a new comment when pagination.sort is not "last"', () => {
40
+ const existingComment = generateCommentResponse({
41
+ object_id: activityId,
42
+ });
43
+
44
+ feed.state.partialNext({
45
+ comments_by_entity_id: {
46
+ [activityId]: {
47
+ comments: [existingComment],
48
+ pagination: { sort: 'first' },
49
+ },
50
+ },
51
+ });
52
+
53
+ const event = generateCommentAddedEvent({
54
+ comment: { object_id: activityId },
55
+ });
56
+
57
+ const stateBefore = feed.currentState;
58
+ handleCommentAdded.call(feed, event);
59
+ const stateAfter = feed.currentState;
60
+
61
+ expect(stateAfter.comments_by_entity_id[activityId]!.comments).not.toBe(
62
+ stateBefore.comments_by_entity_id[activityId]!.comments,
63
+ );
64
+ expect(stateAfter.comments_by_entity_id[activityId]!.comments).toHaveLength(
65
+ 2,
66
+ );
67
+ expect(
68
+ stateAfter.comments_by_entity_id[activityId]!.comments!.at(-1),
69
+ ).toBe(event.comment);
70
+ });
71
+
72
+ it('prepends a new comment when pagination.sort is "last"', () => {
73
+ const existingComment = generateCommentResponse({
74
+ object_id: activityId,
75
+ });
76
+
77
+ feed.state.partialNext({
78
+ comments_by_entity_id: {
79
+ [activityId]: {
80
+ comments: [existingComment],
81
+ pagination: { sort: 'last' },
82
+ },
83
+ },
84
+ });
85
+
86
+ const event = generateCommentAddedEvent({
87
+ comment: { object_id: activityId },
88
+ });
89
+
90
+ const stateBefore = feed.currentState;
91
+ handleCommentAdded.call(feed, event);
92
+ const stateAfter = feed.currentState;
93
+
94
+ expect(stateAfter.comments_by_entity_id[activityId]!.comments).not.toBe(
95
+ stateBefore.comments_by_entity_id[activityId]!.comments,
96
+ );
97
+ expect(stateAfter.comments_by_entity_id[activityId]!.comments).toHaveLength(
98
+ 2,
99
+ );
100
+ expect(
101
+ stateAfter.comments_by_entity_id[activityId]!.comments!.at(0),
102
+ ).toBe(event.comment);
103
+ });
104
+
105
+ it('stores the comment in the correct parent entity state (prefers parent_id)', () => {
106
+ const parentId = `comment-${getHumanId()}`;
107
+ const existingComment = generateCommentResponse({
108
+ object_id: activityId,
109
+ parent_id: parentId,
110
+ });
111
+
112
+ feed.state.partialNext({
113
+ comments_by_entity_id: {
114
+ [parentId]: {
115
+ comments: [existingComment],
116
+ pagination: { sort: 'best' },
117
+ },
118
+ },
119
+ });
120
+
121
+ const event = generateCommentAddedEvent({
122
+ comment: { parent_id: parentId, object_id: activityId },
123
+ });
124
+
125
+ const prevState = feed.currentState;
126
+ handleCommentAdded.call(feed, event);
127
+ const nextState = feed.currentState;
128
+
129
+ expect(nextState.comments_by_entity_id[parentId]!.comments).not.toBe(
130
+ prevState.comments_by_entity_id[parentId]!.comments,
131
+ );
132
+ expect(nextState.comments_by_entity_id[parentId]!.comments).toHaveLength(2);
133
+ expect(nextState.comments_by_entity_id[parentId]!.comments!.at(-1)).toBe(
134
+ event.comment,
135
+ );
136
+ });
137
+
138
+ it('does nothing if entity state does not exist (comments have not been loaded yet)', () => {
139
+ const event = generateCommentAddedEvent({
140
+ comment: { object_id: activityId },
141
+ });
142
+ const stateBefore = feed.currentState;
143
+ handleCommentAdded.call(feed, event);
144
+ const stateAfter = feed.currentState;
145
+ expect(stateAfter).toBe(stateBefore);
146
+ });
147
+ });
@@ -0,0 +1,133 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { Feed } from '../../../feed';
3
+ import { FeedsClient } from '../../../feeds-client';
4
+ import { handleCommentDeleted } from './handle-comment-deleted';
5
+ import {
6
+ generateCommentDeletedEvent,
7
+ generateCommentResponse,
8
+ generateFeedResponse,
9
+ generateOwnUser,
10
+ getHumanId,
11
+ } from '../../../test-utils/response-generators';
12
+
13
+ describe(handleCommentDeleted.name, () => {
14
+ let feed: Feed;
15
+ let client: FeedsClient;
16
+ let currentUserId: string;
17
+ let activityId: string;
18
+
19
+ beforeEach(() => {
20
+ client = new FeedsClient('mock-api-key');
21
+ currentUserId = getHumanId();
22
+ client.state.partialNext({
23
+ connected_user: generateOwnUser({ id: currentUserId }),
24
+ });
25
+ const feedResponse = generateFeedResponse({
26
+ id: 'main',
27
+ group_id: 'user',
28
+ created_by: { id: currentUserId },
29
+ });
30
+ feed = new Feed(
31
+ client,
32
+ feedResponse.group_id,
33
+ feedResponse.id,
34
+ feedResponse,
35
+ );
36
+ activityId = `activity-${getHumanId()}`;
37
+ });
38
+
39
+ it('removes the comment from the entity state (activity level)', () => {
40
+ const comment = generateCommentResponse({ object_id: activityId });
41
+
42
+ feed.state.partialNext({
43
+ comments_by_entity_id: {
44
+ [activityId]: {
45
+ comments: [comment],
46
+ pagination: { sort: 'first' },
47
+ },
48
+ // should be removed by id cleanup
49
+ [comment.id]: {
50
+ comments: [],
51
+ pagination: { sort: 'first' },
52
+ },
53
+ },
54
+ });
55
+
56
+ const event = generateCommentDeletedEvent({
57
+ comment: { id: comment.id, object_id: activityId },
58
+ });
59
+
60
+ const stateBefore = feed.currentState;
61
+
62
+ handleCommentDeleted.call(feed, event);
63
+
64
+ const stateAfter = feed.currentState;
65
+
66
+ expect(stateAfter.comments_by_entity_id[activityId]?.comments).not.toBe(
67
+ stateBefore.comments_by_entity_id[activityId]?.comments,
68
+ );
69
+ expect(stateAfter.comments_by_entity_id[activityId]?.comments).toHaveLength(
70
+ 0,
71
+ );
72
+ expect(stateAfter.comments_by_entity_id).not.toHaveProperty(comment.id);
73
+ });
74
+
75
+ it('removes the comment from the correct parent entity (comment reply)', () => {
76
+ const parentComment = generateCommentResponse({ object_id: activityId });
77
+ const reply = generateCommentResponse({
78
+ object_id: activityId,
79
+ parent_id: parentComment.id,
80
+ });
81
+
82
+ feed.state.partialNext({
83
+ comments_by_entity_id: {
84
+ [activityId]: {
85
+ comments: [parentComment],
86
+ pagination: { sort: 'first' },
87
+ },
88
+ [parentComment.id]: {
89
+ comments: [reply],
90
+ pagination: { sort: 'first' },
91
+ },
92
+ [reply.id]: { comments: [], pagination: { sort: 'first' } },
93
+ },
94
+ });
95
+
96
+ const event = generateCommentDeletedEvent({
97
+ comment: {
98
+ id: reply.id,
99
+ object_id: activityId,
100
+ parent_id: parentComment.id,
101
+ },
102
+ });
103
+
104
+ const stateBefore = feed.currentState;
105
+
106
+ handleCommentDeleted.call(feed, event);
107
+
108
+ const stateAfter = feed.currentState;
109
+ expect(stateAfter).not.toBe(stateBefore);
110
+ expect(
111
+ stateAfter.comments_by_entity_id[parentComment.id]?.comments,
112
+ ).not.toBe(
113
+ stateBefore.comments_by_entity_id[parentComment.id]?.comments,
114
+ );
115
+ expect(
116
+ stateAfter.comments_by_entity_id[parentComment.id]?.comments,
117
+ ).toHaveLength(0);
118
+ expect(stateAfter.comments_by_entity_id).not.toHaveProperty(reply.id);
119
+ expect(stateAfter.comments_by_entity_id).toHaveProperty(activityId);
120
+ });
121
+
122
+ it('does not change the state if the deleted comment is not in state', () => {
123
+ const event = generateCommentDeletedEvent({
124
+ comment: { object_id: activityId },
125
+ });
126
+
127
+ const stateBefore = feed.currentState;
128
+ handleCommentDeleted.call(feed, event);
129
+ const stateAfter = feed.currentState;
130
+
131
+ expect(stateAfter).toBe(stateBefore);
132
+ });
133
+ });
@@ -8,24 +8,38 @@ export function handleCommentDeleted(
8
8
  const entityId = comment.parent_id ?? comment.object_id;
9
9
 
10
10
  this.state.next((currentState) => {
11
- const newCommentsByEntityId = {
12
- ...currentState.comments_by_entity_id,
13
- [entityId]: {
14
- ...currentState.comments_by_entity_id[entityId],
15
- },
16
- };
11
+ let newCommentsByEntityId:
12
+ | typeof currentState.comments_by_entity_id
13
+ | undefined;
17
14
 
18
15
  const index = this.getCommentIndex(comment, currentState);
19
16
 
20
- if (newCommentsByEntityId?.[entityId]?.comments?.length && index !== -1) {
21
- newCommentsByEntityId[entityId].comments = [
22
- ...newCommentsByEntityId[entityId].comments,
17
+ if (index !== -1) {
18
+ newCommentsByEntityId ??= {
19
+ ...currentState.comments_by_entity_id,
20
+ [entityId]: {
21
+ ...currentState.comments_by_entity_id[entityId],
22
+ },
23
+ };
24
+
25
+ newCommentsByEntityId[entityId]!.comments = [
26
+ ...newCommentsByEntityId[entityId]!.comments!,
23
27
  ];
24
28
 
25
29
  newCommentsByEntityId[entityId]?.comments?.splice(index, 1);
26
30
  }
27
31
 
28
- delete newCommentsByEntityId[comment.id];
32
+ if (typeof currentState.comments_by_entity_id[comment.id] !== 'undefined') {
33
+ newCommentsByEntityId ??= {
34
+ ...currentState.comments_by_entity_id,
35
+ };
36
+
37
+ delete newCommentsByEntityId[comment.id];
38
+ }
39
+
40
+ if (!newCommentsByEntityId) {
41
+ return currentState;
42
+ }
29
43
 
30
44
  return {
31
45
  ...currentState,