@stream-io/feeds-client 0.2.2 → 0.2.3

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 (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/@react-bindings/contexts/StreamSearchContext.d.ts +1 -1
  3. package/dist/@react-bindings/contexts/StreamSearchResultsContext.d.ts +1 -1
  4. package/dist/@react-bindings/hooks/search-state-hooks/useSearchQuery.d.ts +1 -1
  5. package/dist/@react-bindings/hooks/search-state-hooks/useSearchResult.d.ts +1 -1
  6. package/dist/@react-bindings/hooks/search-state-hooks/useSearchSources.d.ts +2 -2
  7. package/dist/@react-bindings/wrappers/StreamSearch.d.ts +1 -1
  8. package/dist/@react-bindings/wrappers/StreamSearchResults.d.ts +1 -1
  9. package/dist/index-react-bindings.browser.cjs +26 -8
  10. package/dist/index-react-bindings.browser.cjs.map +1 -1
  11. package/dist/index-react-bindings.browser.js +26 -8
  12. package/dist/index-react-bindings.browser.js.map +1 -1
  13. package/dist/index-react-bindings.node.cjs +26 -8
  14. package/dist/index-react-bindings.node.cjs.map +1 -1
  15. package/dist/index-react-bindings.node.js +26 -8
  16. package/dist/index-react-bindings.node.js.map +1 -1
  17. package/dist/index.browser.cjs +242 -170
  18. package/dist/index.browser.cjs.map +1 -1
  19. package/dist/index.browser.js +242 -171
  20. package/dist/index.browser.js.map +1 -1
  21. package/dist/index.d.ts +1 -5
  22. package/dist/index.node.cjs +242 -170
  23. package/dist/index.node.cjs.map +1 -1
  24. package/dist/index.node.js +242 -171
  25. package/dist/index.node.js.map +1 -1
  26. package/dist/src/common/{ActivitySearchSource.d.ts → search/ActivitySearchSource.d.ts} +3 -3
  27. package/dist/src/common/{BaseSearchSource.d.ts → search/BaseSearchSource.d.ts} +41 -35
  28. package/dist/src/common/{FeedSearchSource.d.ts → search/FeedSearchSource.d.ts} +3 -3
  29. package/dist/src/common/{SearchController.d.ts → search/SearchController.d.ts} +1 -3
  30. package/dist/src/common/{UserSearchSource.d.ts → search/UserSearchSource.d.ts} +4 -4
  31. package/dist/src/common/search/index.d.ts +6 -0
  32. package/dist/src/common/search/types.d.ts +22 -0
  33. package/dist/src/common/types.d.ts +1 -0
  34. package/dist/src/feed/event-handlers/activity/handle-activity-deleted.d.ts +5 -12
  35. package/dist/src/gen/models/index.d.ts +58 -26
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/index.ts +1 -5
  38. package/package.json +1 -1
  39. package/src/common/{ActivitySearchSource.ts → search/ActivitySearchSource.ts} +3 -3
  40. package/src/common/{BaseSearchSource.ts → search/BaseSearchSource.ts} +137 -69
  41. package/src/common/{FeedSearchSource.ts → search/FeedSearchSource.ts} +3 -3
  42. package/src/common/{SearchController.ts → search/SearchController.ts} +2 -7
  43. package/src/common/{UserSearchSource.ts → search/UserSearchSource.ts} +3 -3
  44. package/src/common/search/index.ts +6 -0
  45. package/src/common/search/types.ts +21 -0
  46. package/src/common/types.ts +2 -0
  47. package/src/feed/event-handlers/activity/activity-utils.test.ts +2 -2
  48. package/src/feed/event-handlers/activity/handle-activity-added.test.ts +86 -0
  49. package/src/feed/event-handlers/activity/handle-activity-deleted.test.ts +117 -0
  50. package/src/feed/event-handlers/activity/handle-activity-deleted.ts +8 -4
  51. package/src/feed/event-handlers/feed-member/handle-feed-member-added.test.ts +75 -0
  52. package/src/feed/event-handlers/feed-member/handle-feed-member-removed.test.ts +82 -0
  53. package/src/feed/event-handlers/feed-member/handle-feed-member-removed.ts +19 -9
  54. package/src/feed/event-handlers/feed-member/handle-feed-member-updated.test.ts +84 -0
  55. package/src/gen/feeds/FeedsApi.ts +6 -0
  56. package/src/gen/model-decoders/decoders.ts +13 -1
  57. package/src/gen/models/index.ts +90 -34
  58. package/src/test-utils/response-generators.ts +107 -0
  59. package/dist/src/test-utils/index.d.ts +0 -1
  60. package/dist/src/test-utils/response-generators.d.ts +0 -74
package/index.ts CHANGED
@@ -4,10 +4,6 @@ export * from './src/gen/models';
4
4
  export * from './src/types';
5
5
  export * from './src/common/types';
6
6
  export * from './src/common/StateStore';
7
- export * from './src/common/BaseSearchSource';
8
- export * from './src/common/SearchController';
9
- export * from './src/common/ActivitySearchSource';
10
- export * from './src/common/UserSearchSource';
11
- export * from './src/common/FeedSearchSource';
7
+ export * from './src/common/search';
12
8
  export * from './src/common/Poll';
13
9
  export * from './src/utils';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/feeds-client",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "packageManager": "yarn@3.2.4",
5
5
  "main": "./dist/index.node.js",
6
6
  "exports": {
@@ -1,8 +1,8 @@
1
1
  import { BaseSearchSource } from './BaseSearchSource';
2
- import type { SearchSourceOptions } from './BaseSearchSource';
2
+ import type { SearchSourceOptions } from './types';
3
3
 
4
- import { FeedsClient } from '../feeds-client';
5
- import { ActivityResponse } from '../gen/models';
4
+ import { FeedsClient } from '../../feeds-client';
5
+ import { ActivityResponse } from '../../gen/models';
6
6
 
7
7
  export class ActivitySearchSource extends BaseSearchSource<ActivityResponse> {
8
8
  readonly type = 'activity' as const;
@@ -1,22 +1,20 @@
1
- import { StateStore } from './StateStore';
2
- import { debounce, type DebouncedFunc } from './utils';
3
-
4
- export type SearchSourceType = 'activity' | 'user' | 'feed' | (string & {});
5
-
6
- export type QueryReturnValue<T> = { items: T[]; next?: string | null };
1
+ import { StateStore } from '../StateStore';
2
+ import { debounce, type DebouncedFunc } from '../utils';
3
+ import type {
4
+ QueryReturnValue,
5
+ SearchSourceOptions,
6
+ SearchSourceState,
7
+ SearchSourceType,
8
+ } from './types';
7
9
 
8
10
  export type DebounceOptions = {
9
11
  debounceMs: number;
10
12
  };
11
- type DebouncedExecQueryFunction = DebouncedFunc<
12
- (searchString?: string) => Promise<void>
13
- >;
13
+ type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise<void>>;
14
14
 
15
- export interface SearchSource<T = any> {
15
+ interface ISearchSource<T = any> {
16
16
  activate(): void;
17
17
 
18
- cancelScheduledQuery(): void;
19
-
20
18
  canExecuteQuery(newSearchString?: string): boolean;
21
19
 
22
20
  deactivate(): void;
@@ -33,54 +31,44 @@ export interface SearchSource<T = any> {
33
31
 
34
32
  resetState(): void;
35
33
 
36
- search(text?: string): Promise<void> | undefined;
37
-
38
34
  readonly searchQuery: string;
39
35
 
40
- setDebounceOptions(options: DebounceOptions): void;
41
-
42
36
  readonly state: StateStore<SearchSourceState<T>>;
43
37
  readonly type: SearchSourceType;
44
38
  }
45
39
 
46
- export type SearchSourceState<T = any> = {
47
- hasNext: boolean;
48
- isActive: boolean;
49
- isLoading: boolean;
50
- items: T[] | undefined;
51
- searchQuery: string;
52
- lastQueryError?: Error;
53
- next?: string | null;
54
- offset?: number;
55
- };
56
- export type SearchSourceOptions = {
57
- /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
58
- debounceMs?: number;
59
- pageSize?: number;
60
- allowEmptySearchString?: boolean;
61
- };
40
+ export interface SearchSource<T = any> extends ISearchSource<T> {
41
+ cancelScheduledQuery(): void;
42
+ setDebounceOptions(options: DebounceOptions): void;
43
+ search(text?: string): Promise<void> | undefined;
44
+ }
45
+
46
+ export interface SearchSourceSync<T = any> extends ISearchSource<T> {
47
+ cancelScheduledQuery(): void;
48
+ setDebounceOptions(options: DebounceOptions): void;
49
+ search(text?: string): void;
50
+ }
51
+
62
52
  const DEFAULT_SEARCH_SOURCE_OPTIONS: Required<SearchSourceOptions> = {
63
53
  debounceMs: 300,
64
54
  pageSize: 10,
65
55
  allowEmptySearchString: false,
56
+ resetOnNewSearchQuery: true,
66
57
  } as const;
67
58
 
68
- export abstract class BaseSearchSource<T> implements SearchSource<T> {
59
+ abstract class BaseSearchSourceBase<T> implements ISearchSource<T> {
69
60
  state: StateStore<SearchSourceState<T>>;
70
- protected pageSize: number;
61
+ pageSize: number;
71
62
  protected allowEmptySearchString: boolean;
63
+ protected resetOnNewSearchQuery: boolean;
72
64
  abstract readonly type: SearchSourceType;
73
- protected searchDebounced!: DebouncedExecQueryFunction;
74
65
 
75
66
  protected constructor(options?: SearchSourceOptions) {
76
- const { debounceMs, pageSize, allowEmptySearchString } = {
77
- ...DEFAULT_SEARCH_SOURCE_OPTIONS,
78
- ...options,
79
- };
67
+ const { pageSize, allowEmptySearchString, resetOnNewSearchQuery } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
80
68
  this.pageSize = pageSize;
81
69
  this.allowEmptySearchString = allowEmptySearchString;
70
+ this.resetOnNewSearchQuery = resetOnNewSearchQuery;
82
71
  this.state = new StateStore<SearchSourceState<T>>(this.initialState);
83
- this.setDebounceOptions({ debounceMs });
84
72
  }
85
73
 
86
74
  get lastQueryError() {
@@ -132,14 +120,6 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
132
120
  return this.state.getLatestValue().searchQuery;
133
121
  }
134
122
 
135
- protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
136
-
137
- protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
138
-
139
- setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
140
- this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
141
- };
142
-
143
123
  activate = () => {
144
124
  if (this.isActive) return;
145
125
  this.state.partialNext({ isActive: true });
@@ -161,13 +141,16 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
161
141
  );
162
142
  };
163
143
 
164
- protected getStateBeforeFirstQuery(
165
- newSearchString: string,
166
- ): SearchSourceState<T> {
144
+ protected getStateBeforeFirstQuery(newSearchString: string): SearchSourceState<T> {
145
+ const initialState = this.initialState;
146
+ const oldItems = this.items;
147
+
148
+ const items = this.resetOnNewSearchQuery ? initialState.items : oldItems;
167
149
  return {
168
150
  ...this.initialState,
151
+ items,
169
152
  isActive: this.isActive,
170
- isLoading: true,
153
+ isLoading: this.resetOnNewSearchQuery ? true : !oldItems,
171
154
  searchQuery: newSearchString,
172
155
  };
173
156
  }
@@ -184,12 +167,11 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
184
167
  isLoading: false,
185
168
  items: isFirstPage
186
169
  ? stateUpdate.items
187
- : [...(this.items ?? []), ...(stateUpdate.items ?? [])],
170
+ : [...(this.items ?? []), ...(stateUpdate.items || [])],
188
171
  };
189
172
  }
190
173
 
191
- async executeQuery(newSearchString?: string) {
192
- if (!this.canExecuteQuery(newSearchString)) return;
174
+ protected prepareStateForQuery(newSearchString?: string) {
193
175
  const hasNewSearchQuery = typeof newSearchString !== 'undefined';
194
176
  const searchString = newSearchString ?? this.searchQuery;
195
177
 
@@ -199,20 +181,67 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
199
181
  this.state.partialNext({ isLoading: true });
200
182
  }
201
183
 
184
+ return { searchString, hasNewSearchQuery };
185
+ }
186
+
187
+ protected updatePaginationStateFromQuery(result: QueryReturnValue<T>) {
188
+ const { items, next } = result;
189
+
202
190
  const stateUpdate: Partial<SearchSourceState<T>> = {};
191
+ if (next || next === null) {
192
+ stateUpdate.next = next;
193
+ stateUpdate.hasNext = !!next;
194
+ } else {
195
+ stateUpdate.offset = (this.offset ?? 0) + items.length;
196
+ stateUpdate.hasNext = items.length === this.pageSize;
197
+ }
198
+
199
+ return stateUpdate;
200
+ }
201
+
202
+ resetState() {
203
+ this.state.next(this.initialState);
204
+ }
205
+
206
+ resetStateAndActivate() {
207
+ this.resetState();
208
+ this.activate();
209
+ }
210
+ }
211
+
212
+ export abstract class BaseSearchSource<T>
213
+ extends BaseSearchSourceBase<T>
214
+ implements SearchSource<T>
215
+ {
216
+ protected searchDebounced!: DebouncedExecQueryFunction;
217
+
218
+ constructor(options?: SearchSourceOptions) {
219
+ const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
220
+ super(options);
221
+ this.setDebounceOptions({ debounceMs });
222
+ }
223
+
224
+ protected abstract query(searchQuery: string): Promise<QueryReturnValue<T>>;
225
+
226
+ protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
227
+
228
+ setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
229
+ this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
230
+ };
231
+
232
+ async executeQuery(newSearchString?: string) {
233
+ if (!this.canExecuteQuery(newSearchString)) return;
234
+
235
+ const { hasNewSearchQuery, searchString } =
236
+ this.prepareStateForQuery(newSearchString);
237
+
238
+ let stateUpdate: Partial<SearchSourceState<T>> = {};
203
239
  try {
204
240
  const results = await this.query(searchString);
205
241
  if (!results) return;
206
- const { items, next } = results;
207
-
208
- if (typeof next === 'string' || next === null) {
209
- stateUpdate.next = next;
210
- stateUpdate.hasNext = !!next;
211
- } else {
212
- stateUpdate.offset = (this.offset ?? 0) + items.length;
213
- stateUpdate.hasNext = items.length === this.pageSize;
214
- }
215
242
 
243
+ const { items } = results;
244
+ stateUpdate = this.updatePaginationStateFromQuery(results);
216
245
  stateUpdate.items = await this.filterQueryResults(items);
217
246
  } catch (e) {
218
247
  stateUpdate.lastQueryError = e as Error;
@@ -226,13 +255,52 @@ export abstract class BaseSearchSource<T> implements SearchSource<T> {
226
255
  cancelScheduledQuery() {
227
256
  this.searchDebounced.cancel();
228
257
  }
258
+ }
229
259
 
230
- resetState() {
231
- this.state.next(this.initialState);
260
+ export abstract class BaseSearchSourceSync<T>
261
+ extends BaseSearchSourceBase<T>
262
+ implements SearchSourceSync<T>
263
+ {
264
+ protected searchDebounced!: DebouncedExecQueryFunction;
265
+
266
+ constructor(options?: SearchSourceOptions) {
267
+ const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options };
268
+ super(options);
269
+ this.setDebounceOptions({ debounceMs });
232
270
  }
233
271
 
234
- resetStateAndActivate() {
235
- this.resetState();
236
- this.activate();
272
+ protected abstract query(searchQuery: string): QueryReturnValue<T>;
273
+
274
+ protected abstract filterQueryResults(items: T[]): T[];
275
+
276
+ setDebounceOptions = ({ debounceMs }: DebounceOptions) => {
277
+ this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs);
278
+ };
279
+
280
+ executeQuery(newSearchString?: string) {
281
+ if (!this.canExecuteQuery(newSearchString)) return;
282
+
283
+ const { hasNewSearchQuery, searchString } =
284
+ this.prepareStateForQuery(newSearchString);
285
+
286
+ let stateUpdate: Partial<SearchSourceState<T>> = {};
287
+ try {
288
+ const results = this.query(searchString);
289
+ if (!results) return;
290
+
291
+ const { items } = results;
292
+ stateUpdate = this.updatePaginationStateFromQuery(results);
293
+ stateUpdate.items = this.filterQueryResults(items);
294
+ } catch (e) {
295
+ stateUpdate.lastQueryError = e as Error;
296
+ } finally {
297
+ this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery));
298
+ }
299
+ }
300
+
301
+ search = (searchQuery?: string) => this.searchDebounced(searchQuery);
302
+
303
+ cancelScheduledQuery() {
304
+ this.searchDebounced.cancel();
237
305
  }
238
306
  }
@@ -1,8 +1,8 @@
1
1
  import { BaseSearchSource } from './BaseSearchSource';
2
- import type { SearchSourceOptions } from './BaseSearchSource';
2
+ import type { SearchSourceOptions } from './types';
3
3
 
4
- import { FeedsClient } from '../feeds-client';
5
- import { Feed } from '../feed';
4
+ import { FeedsClient } from '../../feeds-client';
5
+ import { Feed } from '../../feed';
6
6
 
7
7
  export type FeedSearchSourceOptions = SearchSourceOptions & {
8
8
  groupId?: string;
@@ -1,4 +1,4 @@
1
- import { StateStore } from './StateStore';
1
+ import { StateStore } from '../StateStore';
2
2
  import type { SearchSource } from './BaseSearchSource';
3
3
 
4
4
  export type SearchControllerState = {
@@ -23,8 +23,6 @@ export class SearchController {
23
23
  /**
24
24
  * Not intended for direct use by integrators, might be removed without notice resulting in
25
25
  * broken integrations.
26
- *
27
- * @internal
28
26
  */
29
27
  _internalState: StateStore<InternalSearchControllerState>;
30
28
  state: StateStore<SearchControllerState>;
@@ -39,7 +37,6 @@ export class SearchController {
39
37
  this._internalState = new StateStore<InternalSearchControllerState>({});
40
38
  this.config = { keepSingleActiveSource: true, ...config };
41
39
  }
42
-
43
40
  get hasNext() {
44
41
  return this.sources.some((source) => source.hasNext);
45
42
  }
@@ -117,9 +114,7 @@ export class SearchController {
117
114
  this.state.partialNext({
118
115
  searchQuery,
119
116
  });
120
- await Promise.all(
121
- searchedSources.map((source) => source.search(searchQuery)),
122
- );
117
+ await Promise.all(searchedSources.map((source) => source.search(searchQuery)));
123
118
  };
124
119
 
125
120
  cancelSearchQueries = () => {
@@ -1,8 +1,8 @@
1
1
  import { BaseSearchSource } from './BaseSearchSource';
2
- import type { SearchSourceOptions } from './BaseSearchSource';
2
+ import type { SearchSourceOptions } from './types';
3
3
 
4
- import { FeedsClient } from '../feeds-client';
5
- import { UserResponse } from '../gen/models';
4
+ import { FeedsClient } from '../../feeds-client';
5
+ import { UserResponse } from '../../gen/models';
6
6
 
7
7
  export class UserSearchSource extends BaseSearchSource<UserResponse> {
8
8
  readonly type = 'user' as const;
@@ -0,0 +1,6 @@
1
+ export * from './SearchController';
2
+ export * from './BaseSearchSource';
3
+ export * from './ActivitySearchSource';
4
+ export * from './FeedSearchSource';
5
+ export * from './UserSearchSource';
6
+ export * from './types';
@@ -0,0 +1,21 @@
1
+ export type SearchSourceState<T = any> = {
2
+ hasNext: boolean;
3
+ isActive: boolean;
4
+ isLoading: boolean;
5
+ items: T[] | undefined;
6
+ searchQuery: string;
7
+ lastQueryError?: Error;
8
+ next?: string | null;
9
+ offset?: number;
10
+ };
11
+
12
+ export type SearchSourceOptions = {
13
+ /** The number of milliseconds to debounce the search query. The default interval is 300ms. */
14
+ debounceMs?: number;
15
+ pageSize?: number;
16
+ allowEmptySearchString?: boolean;
17
+ resetOnNewSearchQuery?: boolean;
18
+ };
19
+
20
+ export type SearchSourceType = 'activity' | 'user' | 'feed' | (string & {});
21
+ export type QueryReturnValue<T> = { items: T[]; next?: string | null };
@@ -1,3 +1,5 @@
1
+ export * from './search/types';
2
+
1
3
  export type FeedsClientOptions = {
2
4
  base_url?: string;
3
5
  timeout?: number;
@@ -191,7 +191,7 @@ describe('activity-utils', () => {
191
191
 
192
192
  expect(result.changed).toBe(true);
193
193
  expect(result.activities).toHaveLength(1);
194
- expect(result.activities[0].id).toBe('activity2');
194
+ expect(result.activities![0].id).toBe('activity2');
195
195
  // Make sure we create a new array
196
196
  expect(activities === result.activities).toBe(false);
197
197
  });
@@ -205,7 +205,7 @@ describe('activity-utils', () => {
205
205
 
206
206
  expect(result.changed).toBe(false);
207
207
  expect(result.activities).toHaveLength(1);
208
- expect(result.activities[0].id).toBe('activity1');
208
+ expect(result.activities![0].id).toBe('activity1');
209
209
  });
210
210
 
211
211
  it('should handle empty activities array', () => {
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { Feed } from '../../../feed';
3
+ import { FeedsClient } from '../../../feeds-client';
4
+ import { handleActivityAdded } from './handle-activity-added';
5
+ import {
6
+ generateActivityAddedEvent,
7
+ generateActivityResponse,
8
+ generateFeedResponse,
9
+ generateOwnUser,
10
+ getHumanId,
11
+ } from '../../../test-utils/response-generators';
12
+
13
+ describe(handleActivityAdded.name, () => {
14
+ let feed: Feed;
15
+ let client: FeedsClient;
16
+ let currentUserId: string;
17
+
18
+ beforeEach(() => {
19
+ client = new FeedsClient('mock-api-key');
20
+ currentUserId = getHumanId();
21
+ client.state.partialNext({
22
+ connected_user: generateOwnUser({ id: currentUserId }),
23
+ });
24
+
25
+ const feedResponse = generateFeedResponse({
26
+ id: 'main',
27
+ group_id: 'user',
28
+ created_by: { id: currentUserId },
29
+ });
30
+
31
+ feed = new Feed(
32
+ client,
33
+ feedResponse.group_id,
34
+ feedResponse.id,
35
+ feedResponse,
36
+ );
37
+ });
38
+
39
+ it('initializes activities when state is empty and adds new activity to the start', () => {
40
+ const event = generateActivityAddedEvent();
41
+
42
+ const stateBefore = feed.currentState;
43
+ expect(stateBefore.activities).toBeUndefined();
44
+
45
+ const hydrateSpy = vi.spyOn(client, 'hydratePollCache');
46
+
47
+ handleActivityAdded.call(feed, event);
48
+
49
+ const stateAfter = feed.currentState;
50
+ expect(stateAfter.activities).toBeDefined();
51
+ expect(stateAfter.activities).toHaveLength(1);
52
+ expect(stateAfter.activities?.[0]).toBe(event.activity);
53
+ expect(hydrateSpy).toHaveBeenCalledWith([event.activity]);
54
+ });
55
+
56
+ it('prepends new activity when activities already exist', () => {
57
+ const existing = generateActivityResponse();
58
+ feed.state.partialNext({ activities: [existing] });
59
+
60
+ const event = generateActivityAddedEvent();
61
+
62
+ handleActivityAdded.call(feed, event);
63
+
64
+ const stateAfter = feed.currentState;
65
+ expect(stateAfter.activities).toHaveLength(2);
66
+ expect(stateAfter.activities?.[0]).toBe(event.activity);
67
+ expect(stateAfter.activities?.[1]).toBe(existing);
68
+ });
69
+
70
+ it('does not duplicate if activity already exists', () => {
71
+ const existing = generateActivityResponse();
72
+ feed.state.partialNext({ activities: [existing] });
73
+
74
+ const event = generateActivityAddedEvent({
75
+ activity: { id: existing.id },
76
+ });
77
+
78
+ const stateBefore = feed.currentState;
79
+ handleActivityAdded.call(feed, event);
80
+ const stateAfter = feed.currentState;
81
+
82
+ expect(stateAfter).toBe(stateBefore);
83
+ expect(stateAfter.activities).toHaveLength(1);
84
+ expect(stateAfter.activities?.[0]).toBe(existing);
85
+ });
86
+ });
@@ -0,0 +1,117 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { Feed } from '../../../feed';
3
+ import { FeedsClient } from '../../../feeds-client';
4
+ import { handleActivityDeleted } from './handle-activity-deleted';
5
+ import {
6
+ generateActivityDeletedEvent,
7
+ generateActivityPinResponse,
8
+ generateActivityResponse,
9
+ generateFeedResponse,
10
+ generateOwnUser,
11
+ getHumanId,
12
+ } from '../../../test-utils/response-generators';
13
+
14
+ describe(handleActivityDeleted.name, () => {
15
+ let feed: Feed;
16
+ let client: FeedsClient;
17
+ let currentUserId: 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
+
26
+ const feedResponse = generateFeedResponse({
27
+ id: 'main',
28
+ group_id: 'user',
29
+ created_by: { id: currentUserId },
30
+ });
31
+
32
+ feed = new Feed(
33
+ client,
34
+ feedResponse.group_id,
35
+ feedResponse.id,
36
+ feedResponse,
37
+ );
38
+ });
39
+
40
+ it('removes the activity from activities array when present', () => {
41
+ const activity1 = generateActivityResponse();
42
+ const activity2 = generateActivityResponse();
43
+ feed.state.partialNext({ activities: [activity1, activity2] });
44
+
45
+ const event = generateActivityDeletedEvent({
46
+ activity: { id: activity1.id },
47
+ });
48
+
49
+ const stateBefore = feed.currentState;
50
+ expect(stateBefore.activities).toHaveLength(2);
51
+
52
+ handleActivityDeleted.call(feed, event);
53
+
54
+ const stateAfter = feed.currentState;
55
+ expect(stateAfter.activities).toHaveLength(1);
56
+ expect(stateAfter.activities?.[0].id).toBe(activity2.id);
57
+ });
58
+
59
+ it('removes the activity from pinned_activities when present', () => {
60
+ const pin1 = generateActivityPinResponse();
61
+ const pin2 = generateActivityPinResponse();
62
+ feed.state.partialNext({ pinned_activities: [pin1, pin2] });
63
+
64
+ const event = generateActivityDeletedEvent({
65
+ activity: { id: pin1.activity.id },
66
+ });
67
+
68
+ const stateBefore = feed.currentState;
69
+ expect(stateBefore.pinned_activities).toHaveLength(2);
70
+
71
+ handleActivityDeleted.call(feed, event);
72
+
73
+ const stateAfter = feed.currentState;
74
+
75
+ expect(stateAfter.pinned_activities).toHaveLength(1);
76
+ expect(stateAfter.pinned_activities?.[0]).toBe(pin2);
77
+ });
78
+
79
+ it('updates both arrays when the activity exists in both activities and pinned_activities', () => {
80
+ const sharedId = getHumanId();
81
+ const activity = generateActivityResponse({ id: sharedId });
82
+ const pinnedActivity = generateActivityPinResponse({
83
+ activity: { id: sharedId },
84
+ });
85
+ feed.state.partialNext({
86
+ activities: [activity],
87
+ pinned_activities: [pinnedActivity],
88
+ });
89
+
90
+ const event = generateActivityDeletedEvent({
91
+ activity: { id: sharedId },
92
+ });
93
+
94
+ handleActivityDeleted.call(feed, event);
95
+
96
+ const stateAfter = feed.currentState;
97
+ expect(stateAfter.activities).toHaveLength(0);
98
+ expect(stateAfter.pinned_activities).toHaveLength(0);
99
+ });
100
+
101
+ it('does nothing if the activity is not found in either list', () => {
102
+ const activity = generateActivityResponse();
103
+ const pinnedActivity = generateActivityPinResponse();
104
+ feed.state.partialNext({
105
+ activities: [activity],
106
+ pinned_activities: [pinnedActivity],
107
+ });
108
+
109
+ const event = generateActivityDeletedEvent({ activity: { id: 'unknown' } });
110
+
111
+ const stateBefore = feed.currentState;
112
+ handleActivityDeleted.call(feed, event);
113
+ const stateAfter = feed.currentState;
114
+
115
+ expect(stateAfter).toBe(stateBefore);
116
+ });
117
+ });
@@ -3,12 +3,14 @@ import type {
3
3
  ActivityPinResponse,
4
4
  ActivityResponse,
5
5
  } from '../../../gen/models';
6
- import type { EventPayload } from '../../../types-internal';
6
+ import type { EventPayload, UpdateStateResult } from '../../../types-internal';
7
7
 
8
8
  export const removeActivityFromState = (
9
9
  activityResponse: ActivityResponse,
10
10
  activities: ActivityResponse[] | undefined,
11
- ) => {
11
+ ): UpdateStateResult<{
12
+ activities: ActivityResponse[] | undefined;
13
+ }> => {
12
14
  const index =
13
15
  activities?.findIndex((activity) => activity.id === activityResponse.id) ??
14
16
  -1;
@@ -25,7 +27,9 @@ export const removeActivityFromState = (
25
27
  export const removePinnedActivityFromState = (
26
28
  activityResponse: ActivityResponse,
27
29
  pinnedActivities: ActivityPinResponse[] | undefined,
28
- ) => {
30
+ ): UpdateStateResult<{
31
+ pinned_activities: ActivityPinResponse[] | undefined;
32
+ }> => {
29
33
  const index =
30
34
  pinnedActivities?.findIndex(
31
35
  (pinnedActivity) => pinnedActivity.activity.id === activityResponse.id,
@@ -34,7 +38,7 @@ export const removePinnedActivityFromState = (
34
38
  if (index !== -1) {
35
39
  const newActivities = [...pinnedActivities!];
36
40
  newActivities.splice(index, 1);
37
- return { changed: true, activities: newActivities };
41
+ return { changed: true, pinned_activities: newActivities };
38
42
  } else {
39
43
  return { changed: false, pinned_activities: pinnedActivities };
40
44
  }