From a89bd096624a0c9712df7b121b4c880b50670f1b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Sep 2025 16:11:14 +0200 Subject: [PATCH 01/16] feat: ingest own capabilities through responsese --- .../feed-state-hooks/useOwnCapabilities.ts | 166 +++++++++++------- .../activity/handle-activity-added.ts | 17 +- packages/feeds-client/src/feed/feed.ts | 14 +- .../src/feeds-client/feeds-client.ts | 52 +++++- 4 files changed, 175 insertions(+), 74 deletions(-) diff --git a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts index debdb2dd..6ee2a9b8 100644 --- a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -1,84 +1,114 @@ import { useMemo } from 'react'; -import { type Feed, type FeedState, FeedOwnCapability } from '@self'; +import { type Feed, FeedOwnCapability, type FeedsClientState } from '@self'; import { useStateStore } from '@stream-io/state-store/react-bindings'; import { useFeedContext } from '../../contexts/StreamFeedContext'; +import { useFeedsClient } from '../../contexts/StreamFeedsContext'; +import { useStableCallback } from '../internal'; const stableEmptyArray: readonly FeedOwnCapability[] = []; -const selector = (currentState: FeedState) => ({ - oc: currentState.own_capabilities ?? stableEmptyArray, -}); - type KebabToSnakeCase = S extends `${infer T}-${infer U}` ? `${T}_${KebabToSnakeCase}` : S; export const useOwnCapabilities = (feedFromProps?: Feed) => { + const client = useFeedsClient(); const feedFromContext = useFeedContext(); const feed = feedFromProps ?? feedFromContext; - const { oc = stableEmptyArray } = useStateStore(feed?.state, selector) ?? {}; + const selector = useStableCallback((currentState: FeedsClientState) => { + const fid = feed?.feed; + + if (!fid) { + return { feedOwnCapabilities: stableEmptyArray }; + } + + return { + feedOwnCapabilities: + currentState.own_capabilities_by_fid[fid] ?? stableEmptyArray, + }; + }); + + const { feedOwnCapabilities = stableEmptyArray } = + useStateStore(client?.state, selector) ?? {}; + + console.log('GETTING CAPA: ', feedOwnCapabilities); - return useMemo( - () => { - const capabilitiesSet = new Set(oc); - return ({ - can_add_activity: capabilitiesSet.has(FeedOwnCapability.ADD_ACTIVITY), - can_add_activity_bookmark: - capabilitiesSet.has(FeedOwnCapability.ADD_ACTIVITY_BOOKMARK), - can_add_activity_reaction: - capabilitiesSet.has(FeedOwnCapability.ADD_ACTIVITY_REACTION), - can_add_comment: capabilitiesSet.has(FeedOwnCapability.ADD_COMMENT), - can_add_comment_reaction: - capabilitiesSet.has(FeedOwnCapability.ADD_COMMENT_REACTION), - can_create_feed: capabilitiesSet.has(FeedOwnCapability.CREATE_FEED), - can_delete_any_activity: - capabilitiesSet.has(FeedOwnCapability.DELETE_ANY_ACTIVITY), - can_delete_any_comment: - capabilitiesSet.has(FeedOwnCapability.DELETE_ANY_COMMENT), - can_delete_feed: capabilitiesSet.has(FeedOwnCapability.DELETE_FEED), - can_delete_own_activity: - capabilitiesSet.has(FeedOwnCapability.DELETE_OWN_ACTIVITY), - can_delete_own_activity_bookmark: - capabilitiesSet.has(FeedOwnCapability.DELETE_OWN_ACTIVITY_BOOKMARK), - can_delete_own_activity_reaction: - capabilitiesSet.has(FeedOwnCapability.DELETE_OWN_ACTIVITY_REACTION), - can_delete_own_comment: - capabilitiesSet.has(FeedOwnCapability.DELETE_OWN_COMMENT), - can_delete_own_comment_reaction: - capabilitiesSet.has(FeedOwnCapability.DELETE_OWN_COMMENT_REACTION), - can_follow: capabilitiesSet.has(FeedOwnCapability.FOLLOW), - can_pin_activity: capabilitiesSet.has(FeedOwnCapability.PIN_ACTIVITY), - can_query_feed_members: - capabilitiesSet.has(FeedOwnCapability.QUERY_FEED_MEMBERS), - can_query_follows: - capabilitiesSet.has(FeedOwnCapability.QUERY_FOLLOWS), - can_read_activities: - capabilitiesSet.has(FeedOwnCapability.READ_ACTIVITIES), - can_read_feed: capabilitiesSet.has(FeedOwnCapability.READ_FEED), - can_unfollow: capabilitiesSet.has(FeedOwnCapability.UNFOLLOW), - can_update_any_activity: - capabilitiesSet.has(FeedOwnCapability.UPDATE_ANY_ACTIVITY), - can_update_any_comment: - capabilitiesSet.has(FeedOwnCapability.UPDATE_ANY_COMMENT), - can_update_feed: capabilitiesSet.has(FeedOwnCapability.UPDATE_FEED), - can_update_feed_followers: - capabilitiesSet.has(FeedOwnCapability.UPDATE_FEED_FOLLOWERS), - can_update_feed_members: - capabilitiesSet.has(FeedOwnCapability.UPDATE_FEED_MEMBERS), - can_update_own_activity: - capabilitiesSet.has(FeedOwnCapability.UPDATE_OWN_ACTIVITY), - can_update_own_activity_bookmark: - capabilitiesSet.has(FeedOwnCapability.UPDATE_OWN_ACTIVITY_BOOKMARK), - can_update_own_comment: - capabilitiesSet.has(FeedOwnCapability.UPDATE_OWN_COMMENT), - }) satisfies Record< - `can_${KebabToSnakeCase< - (typeof FeedOwnCapability)[keyof typeof FeedOwnCapability] - >}`, - boolean - >; - }, - [oc], - ); + return useMemo(() => { + const capabilitiesSet = new Set(feedOwnCapabilities); + return { + can_add_activity: capabilitiesSet.has(FeedOwnCapability.ADD_ACTIVITY), + can_add_activity_bookmark: capabilitiesSet.has( + FeedOwnCapability.ADD_ACTIVITY_BOOKMARK, + ), + can_add_activity_reaction: capabilitiesSet.has( + FeedOwnCapability.ADD_ACTIVITY_REACTION, + ), + can_add_comment: capabilitiesSet.has(FeedOwnCapability.ADD_COMMENT), + can_add_comment_reaction: capabilitiesSet.has( + FeedOwnCapability.ADD_COMMENT_REACTION, + ), + can_create_feed: capabilitiesSet.has(FeedOwnCapability.CREATE_FEED), + can_delete_any_activity: capabilitiesSet.has( + FeedOwnCapability.DELETE_ANY_ACTIVITY, + ), + can_delete_any_comment: capabilitiesSet.has( + FeedOwnCapability.DELETE_ANY_COMMENT, + ), + can_delete_feed: capabilitiesSet.has(FeedOwnCapability.DELETE_FEED), + can_delete_own_activity: capabilitiesSet.has( + FeedOwnCapability.DELETE_OWN_ACTIVITY, + ), + can_delete_own_activity_bookmark: capabilitiesSet.has( + FeedOwnCapability.DELETE_OWN_ACTIVITY_BOOKMARK, + ), + can_delete_own_activity_reaction: capabilitiesSet.has( + FeedOwnCapability.DELETE_OWN_ACTIVITY_REACTION, + ), + can_delete_own_comment: capabilitiesSet.has( + FeedOwnCapability.DELETE_OWN_COMMENT, + ), + can_delete_own_comment_reaction: capabilitiesSet.has( + FeedOwnCapability.DELETE_OWN_COMMENT_REACTION, + ), + can_follow: capabilitiesSet.has(FeedOwnCapability.FOLLOW), + can_pin_activity: capabilitiesSet.has(FeedOwnCapability.PIN_ACTIVITY), + can_query_feed_members: capabilitiesSet.has( + FeedOwnCapability.QUERY_FEED_MEMBERS, + ), + can_query_follows: capabilitiesSet.has(FeedOwnCapability.QUERY_FOLLOWS), + can_read_activities: capabilitiesSet.has( + FeedOwnCapability.READ_ACTIVITIES, + ), + can_read_feed: capabilitiesSet.has(FeedOwnCapability.READ_FEED), + can_unfollow: capabilitiesSet.has(FeedOwnCapability.UNFOLLOW), + can_update_any_activity: capabilitiesSet.has( + FeedOwnCapability.UPDATE_ANY_ACTIVITY, + ), + can_update_any_comment: capabilitiesSet.has( + FeedOwnCapability.UPDATE_ANY_COMMENT, + ), + can_update_feed: capabilitiesSet.has(FeedOwnCapability.UPDATE_FEED), + can_update_feed_followers: capabilitiesSet.has( + FeedOwnCapability.UPDATE_FEED_FOLLOWERS, + ), + can_update_feed_members: capabilitiesSet.has( + FeedOwnCapability.UPDATE_FEED_MEMBERS, + ), + can_update_own_activity: capabilitiesSet.has( + FeedOwnCapability.UPDATE_OWN_ACTIVITY, + ), + can_update_own_activity_bookmark: capabilitiesSet.has( + FeedOwnCapability.UPDATE_OWN_ACTIVITY_BOOKMARK, + ), + can_update_own_comment: capabilitiesSet.has( + FeedOwnCapability.UPDATE_OWN_COMMENT, + ), + } satisfies Record< + `can_${KebabToSnakeCase< + (typeof FeedOwnCapability)[keyof typeof FeedOwnCapability] + >}`, + boolean + >; + }, [feedOwnCapabilities]); }; diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts index dead4ffb..3a6405bd 100644 --- a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts @@ -54,7 +54,22 @@ export function handleActivityAdded( 'start', ); if (result.changed) { - this.client.hydratePollCache([event.activity]); + const activity = event.activity; + this.client.hydratePollCache([activity]); + + const currentFeed = activity.current_feed; + + if (currentFeed) { + if (currentFeed?.own_capabilities) { + this.client.hydrateCapabilitiesCache([currentFeed]); + } else { + this.client.queryFeeds({ filter: { feed: currentFeed.feed }}).catch(error => { + // FIXME: move to bubbling local error event + console.error(error); + }) + } + } + this.state.partialNext({ activities: result.activities }); } } diff --git a/packages/feeds-client/src/feed/feed.ts b/packages/feeds-client/src/feed/feed.ts index 8feaa754..cc93bd54 100644 --- a/packages/feeds-client/src/feed/feed.ts +++ b/packages/feeds-client/src/feed/feed.ts @@ -62,7 +62,7 @@ import { checkHasAnotherPage, Constants, uniqueArrayMerge } from '../utils'; export type FeedState = Omit< Partial, - 'feed' | 'duration' + 'feed' | 'own_capabilities' | 'duration' > & { /** * True when loading state using `getOrCreate` @@ -276,6 +276,9 @@ export class Feed extends FeedApi { try { const response = await super.getOrCreate(request); + + this.client.hydrateCapabilitiesCache([response.feed]); + if (request?.next) { const { activities: currentActivities = [] } = this.currentState; @@ -837,11 +840,16 @@ export class Feed extends FeedApi { }); } - addActivity(request: Omit) { - return this.feedsApi.addActivity({ + async addActivity(request: Omit) { + const response = await this.client.addActivity({ ...request, feeds: [this.feed], }); + const currentFeed = response.activity.current_feed; + if (currentFeed) { + this.client.hydrateCapabilitiesCache([currentFeed]); + } + return response; } on = this.eventDispatcher.on; diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index e69fd17d..cadb2eae 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -16,7 +16,7 @@ import type { ImageUploadRequest, OwnUser, PollResponse, - PollVotesResponse, + PollVotesResponse, QueryActivitiesRequest, QueryFeedsRequest, QueryPollVotesRequest, UpdateActivityRequest, @@ -75,6 +75,7 @@ import { handleCommentReactionUpdated } from '../feed/event-handlers/comment/han export type FeedsClientState = { connected_user: OwnUser | undefined; is_ws_connection_healthy: boolean; + own_capabilities_by_fid: Record; }; type FID = string; @@ -110,6 +111,7 @@ export class FeedsClient extends FeedsApi { this.state = new StateStore({ connected_user: undefined, is_ws_connection_healthy: false, + own_capabilities_by_fid: {}, }); this.moderation = new ModerationClient(apiClient); this.tokenManager = tokenManager; @@ -276,6 +278,27 @@ export class FeedsClient extends FeedsApi { } } + public hydrateCapabilitiesCache(feedResponses: FeedResponse[]) { + let ownCapabilitiesCache = { + ...this.state.getLatestValue().own_capabilities_by_fid, + }; + for (const feedResponse of feedResponses) { + const { feed, own_capabilities } = feedResponse; + + if ( + !Object.prototype.hasOwnProperty.call(ownCapabilitiesCache, feed) && + own_capabilities + ) { + ownCapabilitiesCache = { + ...ownCapabilitiesCache, + [feed]: own_capabilities, + }; + } + } + + this.state.partialNext({ own_capabilities_by_fid: ownCapabilitiesCache }); + } + connectUser = async (user: UserRequest, tokenProvider: TokenOrProvider) => { if ( this.state.getLatestValue().connected_user !== undefined || @@ -516,9 +539,14 @@ export class FeedsClient extends FeedsApi { this.connectionIdManager.reset(); this.tokenManager.reset(); + + // clear all caches + this.polls_by_id.clear(); + this.state.partialNext({ connected_user: undefined, is_ws_connection_healthy: false, + own_capabilities_by_fid: {}, }); }; @@ -532,7 +560,9 @@ export class FeedsClient extends FeedsApi { async queryFeeds(request?: QueryFeedsRequest) { const response = await this._queryFeeds(request); - const feeds = response.feeds.map((feedResponse) => + const feedResponses = response.feeds; + + const feeds = feedResponses.map((feedResponse) => this.getOrCreateActiveFeed( feedResponse.group_id, feedResponse.id, @@ -541,6 +571,8 @@ export class FeedsClient extends FeedsApi { ), ); + this.hydrateCapabilitiesCache(feedResponses); + return { feeds, next: response.next, @@ -550,6 +582,22 @@ export class FeedsClient extends FeedsApi { }; } + async queryActivities(request?: QueryActivitiesRequest) { + const response = await super.queryActivities(request); + const activityCurrentFeeds = response.activities.map(activity => activity.current_feed); + const feedsToHydrateFrom = []; + + for (const feed of activityCurrentFeeds) { + if (feed) { + feedsToHydrateFrom.push(feed); + } + } + + this.hydrateCapabilitiesCache(feedsToHydrateFrom); + + return response; + } + updateNetworkConnectionStatus = ( event: { type: 'online' | 'offline' } | Event, ) => { From c7d9ff0c94cd694a3bc5742bba18a90b5d334b57 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Wed, 24 Sep 2025 16:35:44 +0200 Subject: [PATCH 02/16] fix: broken test --- .../__integration-tests__/websocket-connection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts index ef9654e5..f4370c38 100644 --- a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts +++ b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts @@ -21,7 +21,7 @@ describe('WebSocket connection', () => { client.state.subscribe(spy); expect(spy).toHaveBeenCalledWith( - { connected_user: undefined, is_ws_connection_healthy: false }, + { connected_user: undefined, is_ws_connection_healthy: false, own_capabilities_by_fid: {} }, undefined, ); From 5cf9f9c3caefdc7e5260f5cbf744a999eb3759c8 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 25 Sep 2025 14:05:44 +0200 Subject: [PATCH 03/16] feat: implement proper activity consumption mechanism and perf --- .../websocket-connection.test.ts | 6 ++- .../feed-state-hooks/useOwnCapabilities.ts | 2 +- .../activity/handle-activity-added.ts | 12 ++--- packages/feeds-client/src/feed/feed.ts | 11 +++- .../src/feeds-client/feeds-client.ts | 50 ++++++++++++++----- .../src/utils/throttling/index.ts | 1 + .../src/utils/throttling/throttle.ts | 34 +++++++++++++ .../throttled-get-batched-own-capabilities.ts | 25 ++++++++++ .../activity-composer/ActivityComposer.tsx | 34 +++++++------ 9 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 packages/feeds-client/src/utils/throttling/index.ts create mode 100644 packages/feeds-client/src/utils/throttling/throttle.ts create mode 100644 packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts diff --git a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts index f4370c38..95fc62cc 100644 --- a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts +++ b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts @@ -21,7 +21,11 @@ describe('WebSocket connection', () => { client.state.subscribe(spy); expect(spy).toHaveBeenCalledWith( - { connected_user: undefined, is_ws_connection_healthy: false, own_capabilities_by_fid: {} }, + { + connected_user: undefined, + is_ws_connection_healthy: false, + own_capabilities_by_fid: {}, + }, undefined, ); diff --git a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts index 6ee2a9b8..c5fb7d23 100644 --- a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -32,7 +32,7 @@ export const useOwnCapabilities = (feedFromProps?: Feed) => { const { feedOwnCapabilities = stableEmptyArray } = useStateStore(client?.state, selector) ?? {}; - console.log('GETTING CAPA: ', feedOwnCapabilities); + // console.log('GETTING CAPA: ', feed?.feed, feedOwnCapabilities); return useMemo(() => { const capabilitiesSet = new Set(feedOwnCapabilities); diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts index 3a6405bd..284ec84d 100644 --- a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts @@ -1,6 +1,7 @@ import type { Feed } from '../../feed'; import type { ActivityResponse } from '../../../gen/models'; import type { EventPayload, UpdateStateResult } from '../../../types-internal'; +import { queueBatchedOwnCapabilities } from '../../../utils/throttling/throttled-get-batched-own-capabilities'; export function addActivitiesToState( this: Feed, @@ -58,16 +59,9 @@ export function handleActivityAdded( this.client.hydratePollCache([activity]); const currentFeed = activity.current_feed; - + if (currentFeed) { - if (currentFeed?.own_capabilities) { - this.client.hydrateCapabilitiesCache([currentFeed]); - } else { - this.client.queryFeeds({ filter: { feed: currentFeed.feed }}).catch(error => { - // FIXME: move to bubbling local error event - console.error(error); - }) - } + this.client.hydrateCapabilitiesCache([currentFeed]); } this.state.partialNext({ activities: result.activities }); diff --git a/packages/feeds-client/src/feed/feed.ts b/packages/feeds-client/src/feed/feed.ts index cc93bd54..011d84e0 100644 --- a/packages/feeds-client/src/feed/feed.ts +++ b/packages/feeds-client/src/feed/feed.ts @@ -277,7 +277,16 @@ export class Feed extends FeedApi { try { const response = await super.getOrCreate(request); - this.client.hydrateCapabilitiesCache([response.feed]); + console.log('RESP: ', response) + + const currentActivityFeeds = []; + for (const activity of response.activities) { + if (activity.current_feed) { + currentActivityFeeds.push(activity.current_feed); + } + } + + this.client.hydrateCapabilitiesCache([response.feed, ...currentActivityFeeds]); if (request?.next) { const { activities: currentActivities = [] } = this.currentState; diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index cadb2eae..e8bf2d9a 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -16,7 +16,8 @@ import type { ImageUploadRequest, OwnUser, PollResponse, - PollVotesResponse, QueryActivitiesRequest, + PollVotesResponse, + QueryActivitiesRequest, QueryFeedsRequest, QueryPollVotesRequest, UpdateActivityRequest, @@ -71,6 +72,8 @@ import { UnhandledErrorType } from '../common/real-time/event-models'; import { updateCommentCount } from '../feed/event-handlers/comment/utils'; import { configureLoggers } from '../utils'; import { handleCommentReactionUpdated } from '../feed/event-handlers/comment/handle-comment-reaction-updated'; +import { throttle, type ThrottledCallback } from '../utils/throttling'; +import { queueBatchedOwnCapabilities } from '../utils/throttling/throttled-get-batched-own-capabilities'; export type FeedsClientState = { connected_user: OwnUser | undefined; @@ -279,23 +282,28 @@ export class FeedsClient extends FeedsApi { } public hydrateCapabilitiesCache(feedResponses: FeedResponse[]) { - let ownCapabilitiesCache = { - ...this.state.getLatestValue().own_capabilities_by_fid, - }; + let ownCapabilitiesCache = + this.state.getLatestValue().own_capabilities_by_fid; + + const capabilitiesToFetchQueue: string[] = []; + for (const feedResponse of feedResponses) { const { feed, own_capabilities } = feedResponse; - if ( - !Object.prototype.hasOwnProperty.call(ownCapabilitiesCache, feed) && - own_capabilities - ) { - ownCapabilitiesCache = { - ...ownCapabilitiesCache, - [feed]: own_capabilities, - }; + if (!Object.prototype.hasOwnProperty.call(ownCapabilitiesCache, feed)) { + if (own_capabilities) { + ownCapabilitiesCache = { + ...ownCapabilitiesCache, + [feed]: own_capabilities, + }; + } else { + capabilitiesToFetchQueue.push(feed); + } } } + queueBatchedOwnCapabilities.bind(this)({ feeds: capabilitiesToFetchQueue }); + this.state.partialNext({ own_capabilities_by_fid: ownCapabilitiesCache }); } @@ -557,6 +565,20 @@ export class FeedsClient extends FeedsApi { return this.getOrCreateActiveFeed(groupId, id); }; + protected throttledGetBatchedOwnCapabilities = throttle( + ((feeds: string[], callback: (feeds: string[]) => void | Promise) => { + this.queryFeeds({ filter: { feed: { $in: feeds } } }).catch((error) => { + // FIXME: move to bubbling local error event + console.error(error); + }); + callback(feeds); + // FIXME: use proper type + }) as ThrottledCallback, + // FIXME: use const + 2000, + { trailing: true }, + ); + async queryFeeds(request?: QueryFeedsRequest) { const response = await this._queryFeeds(request); @@ -584,7 +606,9 @@ export class FeedsClient extends FeedsApi { async queryActivities(request?: QueryActivitiesRequest) { const response = await super.queryActivities(request); - const activityCurrentFeeds = response.activities.map(activity => activity.current_feed); + const activityCurrentFeeds = response.activities.map( + (activity) => activity.current_feed, + ); const feedsToHydrateFrom = []; for (const feed of activityCurrentFeeds) { diff --git a/packages/feeds-client/src/utils/throttling/index.ts b/packages/feeds-client/src/utils/throttling/index.ts new file mode 100644 index 00000000..3ae4c112 --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/index.ts @@ -0,0 +1 @@ +export * from './throttle'; diff --git a/packages/feeds-client/src/utils/throttling/throttle.ts b/packages/feeds-client/src/utils/throttling/throttle.ts new file mode 100644 index 00000000..b0cdf6c7 --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -0,0 +1,34 @@ +export type ThrottledCallback = (...args: unknown[]) => unknown; + +// works exactly the same as lodash.throttle +export const throttle = ( + fn: T, + timeout = 200, + { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {}, +) => { + let runningTimeout: null | NodeJS.Timeout = null; + let storedArgs: Parameters | null = null; + + return (...args: Parameters) => { + if (runningTimeout) { + if (trailing) storedArgs = args; + return; + } + + if (leading) fn(...args); + + const timeoutHandler = () => { + if (storedArgs) { + fn(...storedArgs); + storedArgs = null; + runningTimeout = setTimeout(timeoutHandler, timeout); + + return; + } + + runningTimeout = null; + }; + + runningTimeout = setTimeout(timeoutHandler, timeout); + }; +}; diff --git a/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts new file mode 100644 index 00000000..c0f97a23 --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts @@ -0,0 +1,25 @@ +import type { FeedsClient } from '@self'; + +// TODO: This should be replaced with the actual type once backend implements it +export type GetBatchedOwnCapabilities = { + feeds: string[]; +}; + +const queuedFeeds: Set = new Set(); + +export function queueBatchedOwnCapabilities( + this: FeedsClient, + { feeds }: GetBatchedOwnCapabilities, +) { + for (const feed of feeds) { + queuedFeeds.add(feed); + } + + if (queuedFeeds.size > 0) { + this.throttledGetBatchedOwnCapabilities([...queuedFeeds], (feedsToClear: string[]) => { + for (const feed of feedsToClear) { + queuedFeeds.delete(feed); + } + }); + } +} diff --git a/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx b/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx index 1a095ab6..f1d16d58 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx @@ -155,21 +155,25 @@ export const ActivityComposer = () => { : {}), ...(mentionedUsers ? { mentioned_user_ids: mentionedUsers } : {}), }; - if (editingActivity) { - await client?.updateActivity({ - ...activityData, - id: editingActivity.id, - }); - } else if (hasHashtags) { - await client?.addActivity({ - ...activityData, - feeds: [ - feed.feed, - ...createdHashtagFeeds.map((hashtagFeed) => hashtagFeed.feed), - ], - }); - } else { - await feed.addActivity(activityData); + + for (let i = 1; i < 4; i++) { + activityData.text = `${i}. ${activityData.text}`; + if (editingActivity) { + await client?.updateActivity({ + ...activityData, + id: editingActivity.id, + }); + } else if (hasHashtags) { + await client?.addActivity({ + ...activityData, + feeds: [ + feed.feed, + ...createdHashtagFeeds.map((hashtagFeed) => hashtagFeed.feed), + ], + }); + } else { + await feed.addActivity(activityData); + } } setMedia([]); From 09bf57c487d7c7fa954a4ee58e131b9acaf7815c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 10:40:01 +0200 Subject: [PATCH 04/16] fix: throttling --- .../src/utils/throttling/throttle.ts | 113 +++++++++++++++--- .../components/common/Reaction.tsx | 4 +- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/packages/feeds-client/src/utils/throttling/throttle.ts b/packages/feeds-client/src/utils/throttling/throttle.ts index b0cdf6c7..f5eb5c30 100644 --- a/packages/feeds-client/src/utils/throttling/throttle.ts +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -1,34 +1,113 @@ export type ThrottledCallback = (...args: unknown[]) => unknown; -// works exactly the same as lodash.throttle +/** + * Throttle a function so it runs at most once per `timeout` ms. + * + * - single–timer approach: we keep one pending timer for the trailing edge + * - `leading`: fire immediately when the window opens + * - `trailing`: remember the latest args/this and fire once when the window closes + * - avoids boundary double-invoke: if we just led with the same args, we clear the stored ones + * - preserves `this` on trailing (uses `fn.apply(this, args)`) + * + * defaults: `{ leading: true, trailing: false }` + * (lodash’s default is `{ leading: true, trailing: true }` — enable `trailing` if you want that) + * + * notes: + * - uses `Date.now()`; if you need a monotonic clock, swap in `performance.now()` + * - make one throttled instance and reuse it; re-creating it resets internal state + * + * @typeParam T - the function type being throttled + * @param fn - function to throttle + * @param timeout - minimum time between invocations (ms) + * @param options - behavior switches + * @param options.leading - call on the leading edge (default: true) + * @param options.trailing - call once at the end of the window with the latest args (default: false) + * @returns a throttled function with the same call signature as `fn` + * + * @example + * const send = (payload: Data) => api.post('/endpoint', payload); + * const sendThrottled = throttle(send, 2000, { leading: true, trailing: true }); + * // call `sendThrottled` freely; it won’t invoke `send` more than once every 2s + */ export const throttle = ( fn: T, timeout = 200, { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {}, ) => { - let runningTimeout: null | NodeJS.Timeout = null; + let timer: NodeJS.Timeout | null = null; let storedArgs: Parameters | null = null; + let storedThis: unknown = null; + let lastInvokeTime = 0; // timestamp of last actual invocation - return (...args: Parameters) => { - if (runningTimeout) { - if (trailing) storedArgs = args; - return; - } - - if (leading) fn(...args); + const invoke = (args: Parameters, thisArg: unknown) => { + lastInvokeTime = Date.now(); + fn.apply(thisArg, args); + }; - const timeoutHandler = () => { - if (storedArgs) { - fn(...storedArgs); + const scheduleTrailing = (delay: number) => { + if (timer) return; + timer = setTimeout(() => { + timer = null; + if (trailing && storedArgs) { + invoke(storedArgs, storedThis); storedArgs = null; - runningTimeout = setTimeout(timeoutHandler, timeout); + storedThis = null; + } + }, delay); + }; + + return function (this: any, ...args: Parameters) { + const now = Date.now(); + + // if we have never invoked and `leading` is `false`, treat `lastInvokeTime` as now + if (lastInvokeTime === 0 && !leading) lastInvokeTime = now; - return; + const timeSinceLast = now - lastInvokeTime; + const remaining = timeout - timeSinceLast; + + // capture latest args for possible trailing invocation + if (trailing) { + storedArgs = args; + // eslint-disable-next-line @typescript-eslint/no-this-alias + storedThis = this; + } + + // if enough time has passed, invoke immediately + if (remaining <= 0) { + // if there's a pending timer, clear it because we're invoking now + if (timer) { + clearTimeout(timer); + timer = null; + } + + // leading: call now + if (leading) { + // if trailing is also `true`, we've already stored args above; + // make sure we don't call the same args twice + if (trailing) { + // if the `storedArgs` are exactly the args we're about to call, + // clear storedArgs to avoid double invocation by trailing (comparing + // by reference is fine because the `args` array is new each call) + if (storedArgs === args) { + storedArgs = null; + storedThis = null; + } + } + invoke(args, this); + } else { + // not leading but trailing: schedule a trailing call after `timeout` + if (trailing) scheduleTrailing(timeout); } - runningTimeout = null; - }; + return; + } - runningTimeout = setTimeout(timeoutHandler, timeout); + // not enough time passed: we're in cooldown, so if + // trailing is requested, ensure a trailing invocation + // is scheduled at the end of the remaining time + if (trailing && !timer) { + scheduleTrailing(remaining); + } + // if `trailing` is `false`, we simply drop the call (throttle) }; }; diff --git a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx index a6cc8ce1..d755067c 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx @@ -43,9 +43,9 @@ export const Reaction = ({ type: IconType; entity: ActivityResponse | CommentResponse; } & Partial) => { - const ownCapabilities = useOwnCapabilities(); - const isComment = isCommentResponse(entity); + const ownCapabilities = useOwnCapabilities(isComment ? undefined : entity.current_feed); + const hasOwnReaction = useMemo( () => !!entity.own_reactions?.find((r) => r.type === type), [entity.own_reactions, type], From b94f9a05446dc3c781591afb66b4aa7d96aab1d0 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 10:51:03 +0200 Subject: [PATCH 05/16] chore: cleanup --- .../src/common/real-time/event-models.ts | 8 ++++++- .../src/feeds-client/feeds-client.ts | 21 ++++++++++++------- .../src/utils/throttling/throttle.ts | 5 ----- .../throttled-get-batched-own-capabilities.ts | 2 ++ 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/feeds-client/src/common/real-time/event-models.ts b/packages/feeds-client/src/common/real-time/event-models.ts index 48042d35..688ff266 100644 --- a/packages/feeds-client/src/common/real-time/event-models.ts +++ b/packages/feeds-client/src/common/real-time/event-models.ts @@ -1,4 +1,5 @@ import type { OwnUser } from '../../gen/models'; +import type { StreamApiError } from '../../../dist/types/common/types'; export interface ConnectionChangedEvent { type: 'connection.changed'; @@ -39,9 +40,10 @@ export interface ConnectedEvent { export enum UnhandledErrorType { ReconnectionReconciliation = 'reconnection-reconciliation', + FetchingOwnCapabilitiesOnNewActivity = 'fetching-own-capabilities-on-new-activity', } -export type SyncFailure = { feed: string, reason: unknown }; +export type SyncFailure = { feed: string; reason: unknown }; export type UnhandledErrorEvent = { type: 'errors.unhandled'; @@ -51,4 +53,8 @@ export type UnhandledErrorEvent = { error_type: UnhandledErrorType.ReconnectionReconciliation; failures: SyncFailure[]; } + | { + error_type: UnhandledErrorType.FetchingOwnCapabilitiesOnNewActivity; + error: StreamApiError; + } ); diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index e8bf2d9a..e688095c 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -67,13 +67,18 @@ import { handleWatchStopped, } from '../feed'; import { handleUserUpdated } from './event-handlers'; -import type { SyncFailure } from '../common/real-time/event-models'; -import { UnhandledErrorType } from '../common/real-time/event-models'; +import { + type SyncFailure, + UnhandledErrorType, +} from '../common/real-time/event-models'; import { updateCommentCount } from '../feed/event-handlers/comment/utils'; import { configureLoggers } from '../utils'; import { handleCommentReactionUpdated } from '../feed/event-handlers/comment/handle-comment-reaction-updated'; import { throttle, type ThrottledCallback } from '../utils/throttling'; -import { queueBatchedOwnCapabilities } from '../utils/throttling/throttled-get-batched-own-capabilities'; +import { + QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, + queueBatchedOwnCapabilities, +} from '../utils/throttling/throttled-get-batched-own-capabilities'; export type FeedsClientState = { connected_user: OwnUser | undefined; @@ -568,14 +573,16 @@ export class FeedsClient extends FeedsApi { protected throttledGetBatchedOwnCapabilities = throttle( ((feeds: string[], callback: (feeds: string[]) => void | Promise) => { this.queryFeeds({ filter: { feed: { $in: feeds } } }).catch((error) => { - // FIXME: move to bubbling local error event + this.eventDispatcher.dispatch({ + type: 'errors.unhandled', + error_type: UnhandledErrorType.FetchingOwnCapabilitiesOnNewActivity, + error, + }); console.error(error); }); callback(feeds); - // FIXME: use proper type }) as ThrottledCallback, - // FIXME: use const - 2000, + QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, { trailing: true }, ); diff --git a/packages/feeds-client/src/utils/throttling/throttle.ts b/packages/feeds-client/src/utils/throttling/throttle.ts index f5eb5c30..ed37a68f 100644 --- a/packages/feeds-client/src/utils/throttling/throttle.ts +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -3,17 +3,12 @@ export type ThrottledCallback = (...args: unknown[]) => unknown; /** * Throttle a function so it runs at most once per `timeout` ms. * - * - single–timer approach: we keep one pending timer for the trailing edge * - `leading`: fire immediately when the window opens * - `trailing`: remember the latest args/this and fire once when the window closes - * - avoids boundary double-invoke: if we just led with the same args, we clear the stored ones - * - preserves `this` on trailing (uses `fn.apply(this, args)`) * * defaults: `{ leading: true, trailing: false }` - * (lodash’s default is `{ leading: true, trailing: true }` — enable `trailing` if you want that) * * notes: - * - uses `Date.now()`; if you need a monotonic clock, swap in `performance.now()` * - make one throttled instance and reuse it; re-creating it resets internal state * * @typeParam T - the function type being throttled diff --git a/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts index c0f97a23..d492f749 100644 --- a/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts +++ b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts @@ -5,6 +5,8 @@ export type GetBatchedOwnCapabilities = { feeds: string[]; }; +export const QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL = 2000; + const queuedFeeds: Set = new Set(); export function queueBatchedOwnCapabilities( From cf6f4ffd9b26e6be87a147654ab2873a96170482 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 11:02:38 +0200 Subject: [PATCH 06/16] chore: add todo --- packages/feeds-client/src/feeds-client/feeds-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index e688095c..3770546e 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -572,6 +572,7 @@ export class FeedsClient extends FeedsApi { protected throttledGetBatchedOwnCapabilities = throttle( ((feeds: string[], callback: (feeds: string[]) => void | Promise) => { + // TODO: Replace this with the actual getBatchCapabilities endpoint when it is ready this.queryFeeds({ filter: { feed: { $in: feeds } } }).catch((error) => { this.eventDispatcher.dispatch({ type: 'errors.unhandled', From 973d010e85c62aa02696764b79aefa80a54b8b1b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 12:19:34 +0200 Subject: [PATCH 07/16] chore: cleanup and lint fixes --- .../src/common/real-time/event-models.ts | 4 +- packages/feeds-client/src/common/types.ts | 1 + .../activity/handle-activity-added.ts | 1 - .../src/feeds-client/feeds-client.ts | 54 ++++++++++++------- .../src/utils/throttling/throttle.ts | 15 ++++-- .../throttled-get-batched-own-capabilities.ts | 24 ++++++--- 6 files changed, 66 insertions(+), 33 deletions(-) diff --git a/packages/feeds-client/src/common/real-time/event-models.ts b/packages/feeds-client/src/common/real-time/event-models.ts index 688ff266..112bfd92 100644 --- a/packages/feeds-client/src/common/real-time/event-models.ts +++ b/packages/feeds-client/src/common/real-time/event-models.ts @@ -1,5 +1,5 @@ -import type { OwnUser } from '../../gen/models'; -import type { StreamApiError } from '../../../dist/types/common/types'; +import type { OwnUser } from '@self'; +import type { StreamApiError } from '@self'; export interface ConnectionChangedEvent { type: 'connection.changed'; diff --git a/packages/feeds-client/src/common/types.ts b/packages/feeds-client/src/common/types.ts index 594633b4..b3673754 100644 --- a/packages/feeds-client/src/common/types.ts +++ b/packages/feeds-client/src/common/types.ts @@ -6,6 +6,7 @@ export type FeedsClientOptions = { base_url?: string; timeout?: number; configure_loggers_options?: ConfigureLoggersOptions; + query_batch_own_capabilties_throttling_interval?: number; }; export type RateLimit = { diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts index 284ec84d..8c53be50 100644 --- a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts @@ -1,7 +1,6 @@ import type { Feed } from '../../feed'; import type { ActivityResponse } from '../../../gen/models'; import type { EventPayload, UpdateStateResult } from '../../../types-internal'; -import { queueBatchedOwnCapabilities } from '../../../utils/throttling/throttled-get-batched-own-capabilities'; export function addActivitiesToState( this: Feed, diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index 3770546e..a292177a 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -74,10 +74,12 @@ import { import { updateCommentCount } from '../feed/event-handlers/comment/utils'; import { configureLoggers } from '../utils'; import { handleCommentReactionUpdated } from '../feed/event-handlers/comment/handle-comment-reaction-updated'; -import { throttle, type ThrottledCallback } from '../utils/throttling'; +import { throttle } from '../utils/throttling'; import { - QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, + DEFAULT_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, + type GetBatchedOwnCapabilitiesThrottledCallback, queueBatchedOwnCapabilities, + type ThrottledGetBatchedOwnCapabilities, } from '../utils/throttling/throttled-get-batched-own-capabilities'; export type FeedsClientState = { @@ -106,6 +108,8 @@ export class FeedsClient extends FeedsApi { private healthyConnectionChangedEventCount = 0; + protected throttledGetBatchOwnCapabilities!: ThrottledGetBatchedOwnCapabilities; + constructor(apiKey: string, options?: FeedsClientOptions) { const tokenManager = new TokenManager(); const connectionIdManager = new ConnectionIdManager(); @@ -126,6 +130,11 @@ export class FeedsClient extends FeedsApi { this.connectionIdManager = connectionIdManager; this.polls_by_id = new Map(); + this.setGetBatchOwnCapabilitiesThrottlingInterval( + options?.query_batch_own_capabilties_throttling_interval ?? + DEFAULT_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, + ); + configureLoggers(options?.configure_loggers_options); this.on('all', (event) => { @@ -241,6 +250,30 @@ export class FeedsClient extends FeedsApi { }); } + private setGetBatchOwnCapabilitiesThrottlingInterval = ( + throttlingMs: number, + ) => { + this.throttledGetBatchOwnCapabilities = + throttle( + (feeds, callback) => { + // TODO: Replace this with the actual getBatchCapabilities endpoint when it is ready + this.queryFeeds({ filter: { feed: { $in: feeds } } }).catch( + (error) => { + this.eventDispatcher.dispatch({ + type: 'errors.unhandled', + error_type: + UnhandledErrorType.FetchingOwnCapabilitiesOnNewActivity, + error, + }); + }, + ); + callback(feeds); + }, + throttlingMs, + { trailing: true }, + ); + }; + private recoverOnReconnect = async () => { this.healthyConnectionChangedEventCount++; @@ -570,23 +603,6 @@ export class FeedsClient extends FeedsApi { return this.getOrCreateActiveFeed(groupId, id); }; - protected throttledGetBatchedOwnCapabilities = throttle( - ((feeds: string[], callback: (feeds: string[]) => void | Promise) => { - // TODO: Replace this with the actual getBatchCapabilities endpoint when it is ready - this.queryFeeds({ filter: { feed: { $in: feeds } } }).catch((error) => { - this.eventDispatcher.dispatch({ - type: 'errors.unhandled', - error_type: UnhandledErrorType.FetchingOwnCapabilitiesOnNewActivity, - error, - }); - console.error(error); - }); - callback(feeds); - }) as ThrottledCallback, - QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, - { trailing: true }, - ); - async queryFeeds(request?: QueryFeedsRequest) { const response = await this._queryFeeds(request); diff --git a/packages/feeds-client/src/utils/throttling/throttle.ts b/packages/feeds-client/src/utils/throttling/throttle.ts index ed37a68f..9f016db9 100644 --- a/packages/feeds-client/src/utils/throttling/throttle.ts +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -1,5 +1,10 @@ export type ThrottledCallback = (...args: unknown[]) => unknown; +// export type ThrottledFunction = (this: unknown, ...args: T) => void; + +export type ThrottledFunction = + ((...args: T) => void); + /** * Throttle a function so it runs at most once per `timeout` ms. * @@ -24,17 +29,17 @@ export type ThrottledCallback = (...args: unknown[]) => unknown; * const sendThrottled = throttle(send, 2000, { leading: true, trailing: true }); * // call `sendThrottled` freely; it won’t invoke `send` more than once every 2s */ -export const throttle = ( - fn: T, +export const throttle = ( + fn: (...args: T) => void, timeout = 200, { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {}, ) => { let timer: NodeJS.Timeout | null = null; - let storedArgs: Parameters | null = null; + let storedArgs: T | null = null; let storedThis: unknown = null; let lastInvokeTime = 0; // timestamp of last actual invocation - const invoke = (args: Parameters, thisArg: unknown) => { + const invoke = (args: T, thisArg: unknown) => { lastInvokeTime = Date.now(); fn.apply(thisArg, args); }; @@ -51,7 +56,7 @@ export const throttle = ( }, delay); }; - return function (this: any, ...args: Parameters) { + return function (this: unknown, ...args: T) { const now = Date.now(); // if we have never invoked and `leading` is `false`, treat `lastInvokeTime` as now diff --git a/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts index d492f749..d12783eb 100644 --- a/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts +++ b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts @@ -1,11 +1,20 @@ import type { FeedsClient } from '@self'; +import type { ThrottledFunction } from './throttle'; // TODO: This should be replaced with the actual type once backend implements it export type GetBatchedOwnCapabilities = { feeds: string[]; }; -export const QUEUE_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL = 2000; +export type GetBatchedOwnCapabilitiesThrottledCallback = [ + feeds: string[], + callback: (feedsToClear: string[]) => void | Promise, +]; + +export type ThrottledGetBatchedOwnCapabilities = + ThrottledFunction; + +export const DEFAULT_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL = 2000; const queuedFeeds: Set = new Set(); @@ -18,10 +27,13 @@ export function queueBatchedOwnCapabilities( } if (queuedFeeds.size > 0) { - this.throttledGetBatchedOwnCapabilities([...queuedFeeds], (feedsToClear: string[]) => { - for (const feed of feedsToClear) { - queuedFeeds.delete(feed); - } - }); + this.throttledGetBatchOwnCapabilities( + [...queuedFeeds], + (feedsToClear: string[]) => { + for (const feed of feedsToClear) { + queuedFeeds.delete(feed); + } + }, + ); } } From 19a0b47675da726ae287ad950749938b12ccd7a0 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 14:16:59 +0200 Subject: [PATCH 08/16] chore: add throttling tests and revert not needed changes --- .../src/feeds-client/feeds-client.ts | 4 +- .../src/utils/throttling/index.ts | 1 + .../src/utils/throttling/throttle.test.ts | 323 ++++++++++++++++++ .../src/utils/throttling/throttle.ts | 10 +- .../activity-composer/ActivityComposer.tsx | 33 +- 5 files changed, 346 insertions(+), 25 deletions(-) create mode 100644 packages/feeds-client/src/utils/throttling/throttle.test.ts diff --git a/packages/feeds-client/src/feeds-client/feeds-client.ts b/packages/feeds-client/src/feeds-client/feeds-client.ts index a292177a..4d4687c3 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -74,13 +74,13 @@ import { import { updateCommentCount } from '../feed/event-handlers/comment/utils'; import { configureLoggers } from '../utils'; import { handleCommentReactionUpdated } from '../feed/event-handlers/comment/handle-comment-reaction-updated'; -import { throttle } from '../utils/throttling'; import { + throttle, DEFAULT_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, type GetBatchedOwnCapabilitiesThrottledCallback, queueBatchedOwnCapabilities, type ThrottledGetBatchedOwnCapabilities, -} from '../utils/throttling/throttled-get-batched-own-capabilities'; +} from '../utils/throttling'; export type FeedsClientState = { connected_user: OwnUser | undefined; diff --git a/packages/feeds-client/src/utils/throttling/index.ts b/packages/feeds-client/src/utils/throttling/index.ts index 3ae4c112..cfebc1f2 100644 --- a/packages/feeds-client/src/utils/throttling/index.ts +++ b/packages/feeds-client/src/utils/throttling/index.ts @@ -1 +1,2 @@ export * from './throttle'; +export * from './throttled-get-batched-own-capabilities' diff --git a/packages/feeds-client/src/utils/throttling/throttle.test.ts b/packages/feeds-client/src/utils/throttling/throttle.test.ts new file mode 100644 index 00000000..ef5b0b44 --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/throttle.test.ts @@ -0,0 +1,323 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ThrottledCallback } from './throttle'; +import { throttle } from './throttle'; + +const advance = (ms: number) => vi.advanceTimersByTime(ms); + +describe('throttle', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('leading:true, trailing:false (default): fires immediately, drops during window, fires again after window on next call', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200); + + t('a'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith('a'); + + t('b'); + expect(spy).toHaveBeenCalledTimes(1); + + advance(199); + t('c'); + expect(spy).toHaveBeenCalledTimes(1); + + advance(1); + t('d'); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('d'); + }); + + it('leading:true, trailing:true: first call fires immediately; subsequent calls within window schedule one trailing with latest args', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith('a'); + + advance(50); + t('b'); + advance(50); + t('c'); + advance(99); + expect(spy).toHaveBeenCalledTimes(1); + + advance(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('c'); + }); + + it('leading:true, trailing:true: no double-invoke at boundary (new leading cancels pending trailing)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + expect(spy).toHaveBeenCalledTimes(1); + + advance(190); + t('b'); + t('c'); + expect(spy).toHaveBeenCalledTimes(1); + + vi.setSystemTime(200); + t('d'); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('d'); + + // even if we advance timers now, there should be no extra trailing (canceled) + vi.runOnlyPendingTimers(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('leading:true, trailing:true: single call does not later trigger trailing (guard against double with same args)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + expect(spy).toHaveBeenCalledTimes(1); + + // wait past the window; no additional calls should happen + vi.runAllTimers(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('leading:true, trailing:true: trailing uses the latest args within the window', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(50); + t('b'); + advance(50); + t('c'); + advance(100); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('c'); + }); + + it('leading:false, trailing:true: does not call immediately; calls once at end of window with latest args', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { + leading: false, + trailing: true, + }); + + t('a'); + expect(spy).toHaveBeenCalledTimes(0); + + advance(50); + t('b'); + expect(spy).toHaveBeenCalledTimes(0); + + advance(150); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenLastCalledWith('b'); + + // next window: + advance(50); + t('c'); + expect(spy).toHaveBeenCalledTimes(1); + advance(99); + expect(spy).toHaveBeenCalledTimes(1); + advance(51); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('c'); + }); + + it('leading:false, trailing:false: never calls', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { + leading: false, + trailing: false, + }); + + t('a'); + t('b'); + advance(1000); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('preserves `this` on trailing (apply)', () => { + const seen: any[] = []; + const obj = { + x: 42, + fn(this: any, v: string) { + seen.push([this, v]); + }, + }; + const throttled = throttle(obj.fn, 200, { leading: false, trailing: true }); + + // Call as a method so `this` is obj + (obj as any).call = throttled; + (obj as any).call('hello'); // t=0 + advance(200); // trailing fires + + expect(seen.length).toBe(1); + expect(seen[0][0]).toBe(obj); + expect(seen[0][1]).toBe('hello'); + }); + + it('schedules trailing for the exact remaining time, not the full timeout', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(50); + t('b'); + + advance(149); + expect(spy).toHaveBeenCalledTimes(1); + + advance(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('b'); + }); + + it('after a trailing fires, a call inside the next window does NOT invoke immediately (still throttled)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(200); + + t('b'); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('b'); + + advance(50); + t('c'); + expect(spy).toHaveBeenCalledTimes(2); + + // trailing should kick in at t=400 with 'c' + advance(150); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenLastCalledWith('c'); + }); + + it('does not fire trailing after a new leading crosses the boundary (no boundary double)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(180); + t('b'); + // cross the boundary and call immediately, which should: + // - clear pending trailing + // - call leading just once + vi.setSystemTime(200); + t('c'); + + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenNthCalledWith(1, 'a'); + expect(spy).toHaveBeenNthCalledWith(2, 'c'); + + // even if we advance timers, no extra trailing should occur + vi.runOnlyPendingTimers(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('multiple calls in a burst within a window still produce at most one trailing (latest args)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t(1); + for (let i = 2; i <= 10; i++) t(i); + expect(spy).toHaveBeenCalledTimes(1); + + advance(200); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith(10); + }); + + it('does not accidentally schedule trailing when leading:false and trailing:false', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { + leading: false, + trailing: false, + }); + + for (let i = 0; i < 10; i++) t(i); + advance(1000); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('leading:true, trailing:true: next call well after window leads immediately, not waiting for any lingering timer', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(200); + + t('b'); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith('b'); + }); + + it('works with different timeouts (sanity check)', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 50, { trailing: true }); + + t(1); + advance(30); + t(2); + advance(19); + expect(spy).toHaveBeenCalledTimes(1); + advance(1); + expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenLastCalledWith(2); + + // next cycle: + advance(10); + t(3); + advance(39); + expect(spy).toHaveBeenCalledTimes(2); + advance(1); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenLastCalledWith(3); + }); + + it('does not leak extra invocations after long idle periods', () => { + const spy = vi.fn(); + const t = throttle(spy as ThrottledCallback, 200, { trailing: true }); + + t('a'); + advance(180); + t('b'); + + advance(20); + expect(spy).toHaveBeenCalledTimes(2); + + // wait a long time; nothing else should fire + advance(10000); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should have correct total invocations on spamming a lot of calls with leading and trailing', () => { + const spy = vi.fn(); + const calls = 200; + + const t = throttle(spy, 2000, { trailing: true }); + + for (let i = 0; i < calls; i++) { + t(i); + if (i < calls - 1) vi.advanceTimersByTime(100); + } + expect(spy).toHaveBeenCalledTimes(10); + + // one pending trailing for 20000ms is still queued; flush it + vi.runAllTimers(); + + expect(spy).toHaveBeenCalledTimes(11); + expect(spy).toHaveBeenLastCalledWith(199); + }); +}); diff --git a/packages/feeds-client/src/utils/throttling/throttle.ts b/packages/feeds-client/src/utils/throttling/throttle.ts index 9f016db9..0032c56b 100644 --- a/packages/feeds-client/src/utils/throttling/throttle.ts +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -1,7 +1,5 @@ export type ThrottledCallback = (...args: unknown[]) => unknown; -// export type ThrottledFunction = (this: unknown, ...args: T) => void; - export type ThrottledFunction = ((...args: T) => void); @@ -37,7 +35,7 @@ export const throttle = ( let timer: NodeJS.Timeout | null = null; let storedArgs: T | null = null; let storedThis: unknown = null; - let lastInvokeTime = 0; // timestamp of last actual invocation + let lastInvokeTime: number | undefined; // timestamp of last actual invocation const invoke = (args: T, thisArg: unknown) => { lastInvokeTime = Date.now(); @@ -59,10 +57,12 @@ export const throttle = ( return function (this: unknown, ...args: T) { const now = Date.now(); + const hasBeenInvoked = lastInvokeTime != null; + // if we have never invoked and `leading` is `false`, treat `lastInvokeTime` as now - if (lastInvokeTime === 0 && !leading) lastInvokeTime = now; + if (!hasBeenInvoked && !leading) lastInvokeTime = now; - const timeSinceLast = now - lastInvokeTime; + const timeSinceLast = hasBeenInvoked ? (now - lastInvokeTime!) : timeout; const remaining = timeout - timeSinceLast; // capture latest args for possible trailing invocation diff --git a/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx b/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx index f1d16d58..a5b27511 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx @@ -156,24 +156,21 @@ export const ActivityComposer = () => { ...(mentionedUsers ? { mentioned_user_ids: mentionedUsers } : {}), }; - for (let i = 1; i < 4; i++) { - activityData.text = `${i}. ${activityData.text}`; - if (editingActivity) { - await client?.updateActivity({ - ...activityData, - id: editingActivity.id, - }); - } else if (hasHashtags) { - await client?.addActivity({ - ...activityData, - feeds: [ - feed.feed, - ...createdHashtagFeeds.map((hashtagFeed) => hashtagFeed.feed), - ], - }); - } else { - await feed.addActivity(activityData); - } + if (editingActivity) { + await client?.updateActivity({ + ...activityData, + id: editingActivity.id, + }); + } else if (hasHashtags) { + await client?.addActivity({ + ...activityData, + feeds: [ + feed.feed, + ...createdHashtagFeeds.map((hashtagFeed) => hashtagFeed.feed), + ], + }); + } else { + await feed.addActivity(activityData); } setMedia([]); From e0b97739061e15f5cefbe02d88826af3e66e3a90 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 23:00:59 +0200 Subject: [PATCH 09/16] chore: deferred capability fetching integration tests --- ...eferred-own-capabilities-hydration.test.ts | 342 ++++++++++++++++++ packages/feeds-client/src/feed/feed.ts | 2 - 2 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts diff --git a/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts b/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts new file mode 100644 index 00000000..14cb7ce7 --- /dev/null +++ b/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts @@ -0,0 +1,342 @@ +import { + describe, + expect, + it, + vi, + afterEach, + beforeAll, + afterAll, +} from 'vitest'; +import type { UserRequest } from '../src/gen/models'; +import { + createTestClient, + createTestTokenGenerator, + getServerClient, + getTestUser, + waitForEvent, +} from './utils'; +import type { FeedsClient } from '../src/feeds-client'; +import type { Feed } from '../src/feed'; +import type { + ActivityResponse, + StreamClient, + StreamFeed, +} from '@stream-io/node-sdk'; + +describe('Deferred own_capabilities hydration', () => { + const feedGroup = 'timeline'; + const feedId = crypto.randomUUID(); + let clientRef: FeedsClient; + let serverClient: StreamClient; + let ownUser: UserRequest = getTestUser(); + let otherUsers: UserRequest[] = []; + let otherUsersWithExistingActivities: UserRequest[] = []; + let ownFeedRef: Feed; + const otherFeeds: StreamFeed[] = []; + const otherFeedsWithExistingActivities: StreamFeed[] = []; + const initialActivities: ActivityResponse[] = []; + + beforeAll(async () => { + ownUser = getTestUser(); + otherUsers = [getTestUser(), getTestUser(), getTestUser()]; + otherUsersWithExistingActivities = [ + getTestUser(), + getTestUser(), + getTestUser(), + ]; + clientRef = createTestClient(); + serverClient = getServerClient(); + await clientRef.connectUser(ownUser, createTestTokenGenerator(ownUser)); + await serverClient.upsertUsers([ + ...otherUsers, + ...otherUsersWithExistingActivities, + ]); + ownFeedRef = clientRef.feed(feedGroup, feedId); + await ownFeedRef.getOrCreate({ + watch: false, + member_pagination: { limit: 25 }, + limit: 25, + }); + const ownActivityResponse = await serverClient.feeds.addActivity({ + user_id: ownUser.id, + type: 'post', + feeds: [ownFeedRef.feed], + text: `Initial activity from ${ownFeedRef.feed}`, + }); + initialActivities.push(ownActivityResponse.activity); + for (let i = 0; i < otherUsers.length; i++) { + const otherUser = otherUsers[i]; + const otherFeed = serverClient.feeds.feed('user', otherUser.id); + await otherFeed.getOrCreate({ watch: false, user_id: otherUser.id }); + await ownFeedRef.follow(otherFeed.feed); + otherFeeds.push(otherFeed); + } + + for (let i = 0; i < otherUsersWithExistingActivities.length; i++) { + const otherUser = otherUsersWithExistingActivities[i]; + const otherFeed = serverClient.feeds.feed('user', otherUser.id); + await otherFeed.getOrCreate({ watch: false, user_id: otherUser.id }); + await ownFeedRef.follow(otherFeed.feed); + otherFeedsWithExistingActivities.push(otherFeed); + const activityResponse = await serverClient.feeds.addActivity({ + user_id: otherUser.id, + type: 'post', + feeds: [otherFeed.feed], + text: `Initial activity from ${otherFeed.feed}`, + }); + initialActivities.push(activityResponse.activity); + } + }); + + it('should properly populate capabilities on getOrCreate', async () => { + const client = createTestClient(); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + const ownFeed = client.feed(feedGroup, feedId); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(initialCapabilities).length).toBe(0); + + await ownFeed.getOrCreate({ watch: false }); + + // should populate from activities after getOrCreate + const newCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(newCapabilities).length).toBe(4); + expect(newCapabilities[ownFeed.feed]).toBeDefined(); + for (let i = 0; i < otherFeedsWithExistingActivities.length; i++) { + const otherFeed = otherFeedsWithExistingActivities[i]; + expect(newCapabilities[otherFeed.feed]).toBeDefined(); + } + }); + + it('should properly populate capabilities on queryFeeds', async () => { + const client = createTestClient(); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + const ownFeed = client.feed(feedGroup, feedId); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(initialCapabilities).length).toBe(0); + + const feedsToQuery = [ownFeed.feed, otherFeeds[0].feed, otherFeeds[1].feed]; + + await client.queryFeeds({ filter: { feed: { $in: feedsToQuery } } }); + + const newCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(newCapabilities).length).toBe(3); + for (const feed of feedsToQuery) { + expect(newCapabilities[feed]).toBeDefined(); + } + }); + + it('should properly populate capabilities on queryActivities', async () => { + const client = createTestClient(); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(initialCapabilities).length).toBe(0); + + await client.queryActivities({ + filter: { id: { $in: initialActivities.map((a) => a.id) } }, + }); + + // waiting in case some queried activities do not contain `current_feed.own_capabilities` + await vi.waitFor( + () => { + const newCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(newCapabilities).length).toBe( + initialActivities.length, + ); + for (const activity of initialActivities) { + if (activity.current_feed?.feed) { + expect(newCapabilities[activity.current_feed?.feed]).toBeDefined(); + } + } + }, + { timeout: 1000, interval: 50 }, + ); + }); + + it('should not add extra capabilities in the cache if they already exist', async () => { + const client = createTestClient(); + const getCapabilitiesSpy = vi.spyOn( + client as any, + 'throttledGetBatchOwnCapabilities', + ); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + const ownFeed = client.feed(feedGroup, feedId); + + await ownFeed.getOrCreate({ + watch: true, + member_pagination: { limit: 25 }, + limit: 25, + }); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + + await ownFeed.addActivity({ + type: 'post', + text: `Another activity from ${ownFeed.feed}`, + }); + + await waitForEvent(ownFeed, 'feeds.activity.added', { timeoutMs: 1000 }); + + const newCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(initialCapabilities).toBe(newCapabilities); + expect(getCapabilitiesSpy).not.toHaveBeenCalled(); + }); + + it('should hydrate with extra capabilities if they do not exist in the cache', async () => { + const client = createTestClient(); + const getCapabilitiesSpy = vi.spyOn( + client as any, + 'throttledGetBatchOwnCapabilities', + ); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + const ownFeed = client.feed(feedGroup, feedId); + + await ownFeed.getOrCreate({ + watch: true, + member_pagination: { limit: 25 }, + limit: 25, + }); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + + for (const key of Object.keys(initialCapabilities)) { + if ( + ![ + ownFeed.feed, + ...otherFeedsWithExistingActivities.map((f) => f.feed), + ].includes(key) + ) { + delete initialCapabilities[key]; + } + } + + client.state.partialNext({ own_capabilities_by_fid: initialCapabilities }); + + const otherFeed = otherFeeds[0]; + const otherUser = otherUsers[0]; + + await serverClient.feeds.addActivity({ + user_id: otherUser.id, + type: 'post', + feeds: [otherFeed.feed], + text: `Initial activity from ${otherFeed.feed}`, + }); + + await waitForEvent(ownFeed, 'feeds.activity.added', { timeoutMs: 1000 }); + + await vi.waitFor( + () => { + const finalCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(finalCapabilities).length).toBe( + Object.keys(initialCapabilities).length + 1, + ); + expect(finalCapabilities[otherFeed.feed]).toBeDefined(); + }, + { timeout: 1000, interval: 50 }, + ); + + expect(getCapabilitiesSpy).toHaveBeenCalledExactlyOnceWith( + [otherFeed.feed], + expect.any(Function), + ); + }); + + it('should throttle new capabilities hydration', async () => { + const client = createTestClient(); + const getCapabilitiesSpy = vi.spyOn(client as any, 'queryFeeds'); + await client.connectUser(ownUser, createTestTokenGenerator(ownUser)); + const ownFeed = client.feed(feedGroup, feedId); + + await ownFeed.getOrCreate({ + watch: true, + member_pagination: { limit: 25 }, + limit: 25, + }); + + const initialCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + + for (const key of Object.keys(initialCapabilities)) { + if ( + ![ + ownFeed.feed, + ...otherFeedsWithExistingActivities.map((f) => f.feed), + ].includes(key) + ) { + delete initialCapabilities[key]; + } + } + + client.state.partialNext({ own_capabilities_by_fid: initialCapabilities }); + + for (let i = 0; i < otherUsers.length; i++) { + const otherFeed = otherFeeds[i]; + const otherUser = otherUsers[i]; + await serverClient.feeds.addActivity({ + user_id: otherUser.id, + type: 'post', + feeds: [otherFeed.feed], + text: `Initial activity from ${otherFeed.feed}`, + }); + } + + await vi.waitFor( + () => { + const finalCapabilities = + client.state.getLatestValue().own_capabilities_by_fid; + expect(Object.keys(finalCapabilities).length).toBe( + Object.keys(initialCapabilities).length + 3, + ); + for (const otherFeed of otherFeeds) { + expect(finalCapabilities[otherFeed.feed]).toBeDefined(); + } + }, + // always leave enough of a timeout for the fetch to fire 3 times; + // it should of course fire only 2 and be done at most in 2000 + some + // delta ms, but just in case this behaviour gets broken + { timeout: 6000, interval: 50 }, + ); + + expect(getCapabilitiesSpy).toHaveBeenCalledTimes(2); + expect(getCapabilitiesSpy.mock.calls[0][0]).toStrictEqual({ + filter: { feed: { $in: [otherFeeds[0].feed] } }, + }); + expect(getCapabilitiesSpy.mock.calls[1][0]).toStrictEqual({ + filter: { feed: { $in: [otherFeeds[1].feed, otherFeeds[2].feed] } }, + }); + }); + + afterAll(async () => { + await ownFeedRef.delete({ hard_delete: true }); + for (let i = 0; i < otherFeeds.length; i++) { + const otherFeed = otherFeeds[i]; + await otherFeed.delete({ hard_delete: true }); + } + for (let i = 0; i < otherFeedsWithExistingActivities.length; i++) { + const otherFeed = otherFeedsWithExistingActivities[i]; + await otherFeed.delete({ hard_delete: true }); + } + await serverClient.deleteUsers({ + user_ids: [...otherUsers, ...otherUsersWithExistingActivities].map( + (u) => u.id, + ), + }); + await clientRef.disconnectUser(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); +}); diff --git a/packages/feeds-client/src/feed/feed.ts b/packages/feeds-client/src/feed/feed.ts index 011d84e0..fe4fd936 100644 --- a/packages/feeds-client/src/feed/feed.ts +++ b/packages/feeds-client/src/feed/feed.ts @@ -277,8 +277,6 @@ export class Feed extends FeedApi { try { const response = await super.getOrCreate(request); - console.log('RESP: ', response) - const currentActivityFeeds = []; for (const activity of response.activities) { if (activity.current_feed) { From c006cc10f118e9a010426c0eb896ca03e1e3fa42 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 23:12:12 +0200 Subject: [PATCH 10/16] fix: update useOwnCapabilities hook --- .../feed-state-hooks/useOwnCapabilities.ts | 82 +------------------ .../components/common/Reaction.tsx | 10 +-- 2 files changed, 6 insertions(+), 86 deletions(-) diff --git a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts index c5fb7d23..68b37372 100644 --- a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -7,10 +7,6 @@ import { useStableCallback } from '../internal'; const stableEmptyArray: readonly FeedOwnCapability[] = []; -type KebabToSnakeCase = S extends `${infer T}-${infer U}` - ? `${T}_${KebabToSnakeCase}` - : S; - export const useOwnCapabilities = (feedFromProps?: Feed) => { const client = useFeedsClient(); const feedFromContext = useFeedContext(); @@ -34,81 +30,5 @@ export const useOwnCapabilities = (feedFromProps?: Feed) => { // console.log('GETTING CAPA: ', feed?.feed, feedOwnCapabilities); - return useMemo(() => { - const capabilitiesSet = new Set(feedOwnCapabilities); - return { - can_add_activity: capabilitiesSet.has(FeedOwnCapability.ADD_ACTIVITY), - can_add_activity_bookmark: capabilitiesSet.has( - FeedOwnCapability.ADD_ACTIVITY_BOOKMARK, - ), - can_add_activity_reaction: capabilitiesSet.has( - FeedOwnCapability.ADD_ACTIVITY_REACTION, - ), - can_add_comment: capabilitiesSet.has(FeedOwnCapability.ADD_COMMENT), - can_add_comment_reaction: capabilitiesSet.has( - FeedOwnCapability.ADD_COMMENT_REACTION, - ), - can_create_feed: capabilitiesSet.has(FeedOwnCapability.CREATE_FEED), - can_delete_any_activity: capabilitiesSet.has( - FeedOwnCapability.DELETE_ANY_ACTIVITY, - ), - can_delete_any_comment: capabilitiesSet.has( - FeedOwnCapability.DELETE_ANY_COMMENT, - ), - can_delete_feed: capabilitiesSet.has(FeedOwnCapability.DELETE_FEED), - can_delete_own_activity: capabilitiesSet.has( - FeedOwnCapability.DELETE_OWN_ACTIVITY, - ), - can_delete_own_activity_bookmark: capabilitiesSet.has( - FeedOwnCapability.DELETE_OWN_ACTIVITY_BOOKMARK, - ), - can_delete_own_activity_reaction: capabilitiesSet.has( - FeedOwnCapability.DELETE_OWN_ACTIVITY_REACTION, - ), - can_delete_own_comment: capabilitiesSet.has( - FeedOwnCapability.DELETE_OWN_COMMENT, - ), - can_delete_own_comment_reaction: capabilitiesSet.has( - FeedOwnCapability.DELETE_OWN_COMMENT_REACTION, - ), - can_follow: capabilitiesSet.has(FeedOwnCapability.FOLLOW), - can_pin_activity: capabilitiesSet.has(FeedOwnCapability.PIN_ACTIVITY), - can_query_feed_members: capabilitiesSet.has( - FeedOwnCapability.QUERY_FEED_MEMBERS, - ), - can_query_follows: capabilitiesSet.has(FeedOwnCapability.QUERY_FOLLOWS), - can_read_activities: capabilitiesSet.has( - FeedOwnCapability.READ_ACTIVITIES, - ), - can_read_feed: capabilitiesSet.has(FeedOwnCapability.READ_FEED), - can_unfollow: capabilitiesSet.has(FeedOwnCapability.UNFOLLOW), - can_update_any_activity: capabilitiesSet.has( - FeedOwnCapability.UPDATE_ANY_ACTIVITY, - ), - can_update_any_comment: capabilitiesSet.has( - FeedOwnCapability.UPDATE_ANY_COMMENT, - ), - can_update_feed: capabilitiesSet.has(FeedOwnCapability.UPDATE_FEED), - can_update_feed_followers: capabilitiesSet.has( - FeedOwnCapability.UPDATE_FEED_FOLLOWERS, - ), - can_update_feed_members: capabilitiesSet.has( - FeedOwnCapability.UPDATE_FEED_MEMBERS, - ), - can_update_own_activity: capabilitiesSet.has( - FeedOwnCapability.UPDATE_OWN_ACTIVITY, - ), - can_update_own_activity_bookmark: capabilitiesSet.has( - FeedOwnCapability.UPDATE_OWN_ACTIVITY_BOOKMARK, - ), - can_update_own_comment: capabilitiesSet.has( - FeedOwnCapability.UPDATE_OWN_COMMENT, - ), - } satisfies Record< - `can_${KebabToSnakeCase< - (typeof FeedOwnCapability)[keyof typeof FeedOwnCapability] - >}`, - boolean - >; - }, [feedOwnCapabilities]); + return feedOwnCapabilities; }; diff --git a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx index d755067c..ac652d22 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx @@ -4,7 +4,7 @@ import type { import { useOwnCapabilities, isCommentResponse, - useFeedsClient, + useFeedsClient, FeedOwnCapability, } from '@stream-io/feeds-react-native-sdk'; import { useMemo } from 'react'; import { Ionicons } from '@expo/vector-icons'; @@ -52,11 +52,11 @@ export const Reaction = ({ ); const canAddReaction = isComment - ? ownCapabilities.can_add_comment_reaction - : ownCapabilities.can_add_activity_reaction; + ? ownCapabilities.includes(FeedOwnCapability.ADD_COMMENT_REACTION) + : ownCapabilities.includes(FeedOwnCapability.ADD_ACTIVITY_REACTION); const canRemoveReaction = isComment - ? ownCapabilities.can_delete_own_comment_reaction - : ownCapabilities.can_delete_own_activity_reaction; + ? ownCapabilities.includes(FeedOwnCapability.DELETE_OWN_COMMENT_REACTION) + : ownCapabilities.includes(FeedOwnCapability.DELETE_OWN_ACTIVITY_REACTION); const client = useFeedsClient(); From 86d1bd5055a409f84c03cb157d26335872398f41 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 26 Sep 2025 23:43:50 +0200 Subject: [PATCH 11/16] refactor: comment reactions and capabilities --- .../feed-state-hooks/useOwnCapabilities.ts | 3 +- .../components/activity-pager/PagerItem.tsx | 2 +- .../activity-section-list/Activity.tsx | 5 +-- .../components/comments/Comment.tsx | 19 ++++-------- .../components/comments/Comments.tsx | 31 ++++++++++--------- .../components/common/Reaction.tsx | 19 +++++++++--- .../contexts/ActivityContext.tsx | 21 +++++++++++++ .../contexts/ActivityPagerContext.tsx | 3 +- 8 files changed, 65 insertions(+), 38 deletions(-) create mode 100644 sample-apps/react-native/ExpoTikTokApp/contexts/ActivityContext.tsx diff --git a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts index 68b37372..986c7f4d 100644 --- a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -1,5 +1,4 @@ -import { useMemo } from 'react'; -import { type Feed, FeedOwnCapability, type FeedsClientState } from '@self'; +import type { FeedOwnCapability, Feed, FeedsClientState } from '@self'; import { useStateStore } from '@stream-io/state-store/react-bindings'; import { useFeedContext } from '../../contexts/StreamFeedContext'; import { useFeedsClient } from '../../contexts/StreamFeedsContext'; diff --git a/sample-apps/react-native/ExpoTikTokApp/components/activity-pager/PagerItem.tsx b/sample-apps/react-native/ExpoTikTokApp/components/activity-pager/PagerItem.tsx index 13a19c51..ade7f687 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/activity-pager/PagerItem.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/activity-pager/PagerItem.tsx @@ -66,7 +66,7 @@ const UnmemoizedPagerItem = ({ - + {activity.reaction_groups.like?.count ?? 0} diff --git a/sample-apps/react-native/ExpoTikTokApp/components/activity-section-list/Activity.tsx b/sample-apps/react-native/ExpoTikTokApp/components/activity-section-list/Activity.tsx index 440f6712..eb7c134d 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/activity-section-list/Activity.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/activity-section-list/Activity.tsx @@ -1,5 +1,6 @@ +import type { + ActivityResponse} from '@stream-io/feeds-react-native-sdk'; import { - ActivityResponse, useClientConnectedUser, useFeedContext, } from '@stream-io/feeds-react-native-sdk'; @@ -72,7 +73,7 @@ export const Activity = ({ ) : null} {feed ? ( - + ) : null} diff --git a/sample-apps/react-native/ExpoTikTokApp/components/comments/Comment.tsx b/sample-apps/react-native/ExpoTikTokApp/components/comments/Comment.tsx index 327764e8..950cc80c 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/comments/Comment.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/comments/Comment.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { - CommentResponse, - useClientConnectedUser, -} from '@stream-io/feeds-react-native-sdk'; +import type { CommentResponse } from '@stream-io/feeds-react-native-sdk'; +import { useClientConnectedUser } from '@stream-io/feeds-react-native-sdk'; import { useComments } from '@stream-io/feeds-react-native-sdk'; import { useFormatDate } from '@/hooks/useFormatDate'; import { useStableCallback } from '@/hooks/useStableCallback'; @@ -12,9 +10,7 @@ import { COMMENTS_LOADING_CONFIG } from '@/constants/stream'; import { openSheetWith } from '@/store/bottom-sheet-state-store'; import { useRouter } from 'expo-router'; import { setParent } from '@/store/comment-input-state-store'; -import { - AnnotatedText -} from '@/components/common/tokenized-text/AnnotatedText'; +import { AnnotatedText } from '@/components/common/tokenized-text/AnnotatedText'; export const Comment = ({ comment, @@ -64,10 +60,7 @@ export const Comment = ({ /> {comment.user.id} - + {formattedDate} {isFirstLevel ? ( @@ -80,11 +73,11 @@ export const Comment = ({ - + {comment.reaction_groups?.like?.count ?? 0} - + diff --git a/sample-apps/react-native/ExpoTikTokApp/components/comments/Comments.tsx b/sample-apps/react-native/ExpoTikTokApp/components/comments/Comments.tsx index a4777aa3..0d451714 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/comments/Comments.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/comments/Comments.tsx @@ -1,13 +1,14 @@ -import { +import type { ActivityResponse, CommentResponse, - useComments, } from '@stream-io/feeds-react-native-sdk'; +import { useComments } from '@stream-io/feeds-react-native-sdk'; import { FlatList, StyleSheet } from 'react-native'; import React, { useEffect } from 'react'; import { useStableCallback } from '@/hooks/useStableCallback'; import { Comment } from '@/components/comments/Comment'; import { COMMENTS_LOADING_CONFIG } from '@/constants/stream'; +import { ActivityProvider } from '../../contexts/ActivityContext'; const maintainVisibleContentPosition = { minIndexForVisible: 0, @@ -42,18 +43,20 @@ export const Comments = ({ activity }: { activity: ActivityResponse }) => { }, [activity, comments, is_loading_next_page, loadNextPage]); return ( - + + + ); }; diff --git a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx index ac652d22..648eb280 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/common/Reaction.tsx @@ -1,15 +1,18 @@ import type { ActivityResponse, - CommentResponse} from '@stream-io/feeds-react-native-sdk'; + CommentResponse, +} from '@stream-io/feeds-react-native-sdk'; import { useOwnCapabilities, isCommentResponse, - useFeedsClient, FeedOwnCapability, + useFeedsClient, + FeedOwnCapability, } from '@stream-io/feeds-react-native-sdk'; import { useMemo } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { TouchableOpacity } from 'react-native'; import { useStableCallback } from '@/hooks/useStableCallback'; +import { useActivityContext } from '@/contexts/ActivityContext'; const iconMap = { like: { @@ -36,15 +39,21 @@ type IconProps = { size: number; color: string }; export const Reaction = ({ type, - entity, + activity: actitivyFromProps, + comment, size = 20, color = 'white', }: { type: IconType; - entity: ActivityResponse | CommentResponse; + activity?: ActivityResponse; + comment?: CommentResponse; } & Partial) => { + const activityFromContext = useActivityContext(); + const activity = actitivyFromProps ?? activityFromContext; + const entity = comment ?? activity; + const isComment = isCommentResponse(entity); - const ownCapabilities = useOwnCapabilities(isComment ? undefined : entity.current_feed); + const ownCapabilities = useOwnCapabilities(activity.current_feed); const hasOwnReaction = useMemo( () => !!entity.own_reactions?.find((r) => r.type === type), diff --git a/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityContext.tsx b/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityContext.tsx new file mode 100644 index 00000000..a56dd43f --- /dev/null +++ b/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityContext.tsx @@ -0,0 +1,21 @@ +import type { PropsWithChildren } from 'react'; +import { createContext, useContext, useMemo } from 'react'; +import type { ActivityResponse } from '@stream-io/feeds-react-native-sdk'; + +type ActivityContextValue = ActivityResponse; + +const ActivityContext = createContext(undefined!); + +export const ActivityProvider = ({ + activity, + children, +}: PropsWithChildren<{ activity: ActivityContextValue }>) => { + const contextValue = useMemo(() => activity, [activity]); + return ( + + {children} + + ); +}; + +export const useActivityContext = () => useContext(ActivityContext); diff --git a/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityPagerContext.tsx b/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityPagerContext.tsx index 72328e95..754c4d9a 100644 --- a/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityPagerContext.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/contexts/ActivityPagerContext.tsx @@ -1,4 +1,5 @@ -import { createContext, PropsWithChildren, useContext, useMemo } from 'react'; +import type { PropsWithChildren} from 'react'; +import { createContext, useContext, useMemo } from 'react'; type ActivityPagerContextValue = { activeId?: string; From 339aa465ea09df08c82a79c98fb761da11e42f26 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 29 Sep 2025 17:09:46 +0200 Subject: [PATCH 12/16] fix: capabilities hook and faulty test --- .../react/hooks/feed-state-hooks/useOwnCapabilities.ts | 9 ++++----- .../feeds-client/src/utils/throttling/throttle.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts index 986c7f4d..a8226eea 100644 --- a/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/src/bindings/react/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -2,7 +2,7 @@ import type { FeedOwnCapability, Feed, FeedsClientState } from '@self'; import { useStateStore } from '@stream-io/state-store/react-bindings'; import { useFeedContext } from '../../contexts/StreamFeedContext'; import { useFeedsClient } from '../../contexts/StreamFeedsContext'; -import { useStableCallback } from '../internal'; +import { useCallback } from 'react'; const stableEmptyArray: readonly FeedOwnCapability[] = []; @@ -10,10 +10,9 @@ export const useOwnCapabilities = (feedFromProps?: Feed) => { const client = useFeedsClient(); const feedFromContext = useFeedContext(); const feed = feedFromProps ?? feedFromContext; + const fid = feed?.feed; - const selector = useStableCallback((currentState: FeedsClientState) => { - const fid = feed?.feed; - + const selector = useCallback((currentState: FeedsClientState) => { if (!fid) { return { feedOwnCapabilities: stableEmptyArray }; } @@ -22,7 +21,7 @@ export const useOwnCapabilities = (feedFromProps?: Feed) => { feedOwnCapabilities: currentState.own_capabilities_by_fid[fid] ?? stableEmptyArray, }; - }); + }, [fid]); const { feedOwnCapabilities = stableEmptyArray } = useStateStore(client?.state, selector) ?? {}; diff --git a/packages/feeds-client/src/utils/throttling/throttle.test.ts b/packages/feeds-client/src/utils/throttling/throttle.test.ts index ef5b0b44..8c6aba95 100644 --- a/packages/feeds-client/src/utils/throttling/throttle.test.ts +++ b/packages/feeds-client/src/utils/throttling/throttle.test.ts @@ -303,19 +303,19 @@ describe('throttle', () => { }); it('should have correct total invocations on spamming a lot of calls with leading and trailing', () => { - const spy = vi.fn(); + const spy = vi.fn().mockResolvedValue(1); const calls = 200; const t = throttle(spy, 2000, { trailing: true }); for (let i = 0; i < calls; i++) { t(i); - if (i < calls - 1) vi.advanceTimersByTime(100); + if (i < calls - 1) vi.setSystemTime(i * 100); } expect(spy).toHaveBeenCalledTimes(10); // one pending trailing for 20000ms is still queued; flush it - vi.runAllTimers(); + vi.runOnlyPendingTimers(); expect(spy).toHaveBeenCalledTimes(11); expect(spy).toHaveBeenLastCalledWith(199); From 9f23303ce9c82146d4cd9e0b2aa7f1aeb4748f97 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 30 Sep 2025 10:53:16 +0200 Subject: [PATCH 13/16] fix: go just over third timeout --- .../deferred-own-capabilities-hydration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts b/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts index 14cb7ce7..24ca1ebf 100644 --- a/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts +++ b/packages/feeds-client/__integration-tests__/deferred-own-capabilities-hydration.test.ts @@ -306,7 +306,7 @@ describe('Deferred own_capabilities hydration', () => { // always leave enough of a timeout for the fetch to fire 3 times; // it should of course fire only 2 and be done at most in 2000 + some // delta ms, but just in case this behaviour gets broken - { timeout: 6000, interval: 50 }, + { timeout: 6050, interval: 50 }, ); expect(getCapabilitiesSpy).toHaveBeenCalledTimes(2); From 6c3febadbd9de8c1aeea336e2ad759587db7ab51 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Tue, 7 Oct 2025 13:20:18 +0200 Subject: [PATCH 14/16] Add capabilities docs --- .../docs-snippets/feed-capabilities.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts new file mode 100644 index 00000000..a54009c1 --- /dev/null +++ b/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts @@ -0,0 +1,55 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + createTestClient, + createTestTokenGenerator, + getServerClient, + getTestUser, +} from '../utils'; +import type { FeedsClient } from '../../src/feeds-client'; +import type { Feed } from '../../src/feed'; +import type { UserRequest } from '../../src/gen/models'; + +describe('Feeds capabilities page', () => { + let client: FeedsClient; + const user: UserRequest = getTestUser(); + let feed: Feed; + + beforeAll(async () => { + client = createTestClient(); + await client.connectUser(user, createTestTokenGenerator(user)); + feed = client.feed('user', crypto.randomUUID()); + await client.upsertActivities({ + activities: new Array(10).fill(null).map((_, i) => ({ + type: 'post', + text: `Hello, Stream Feeds! ${i}`, + feeds: [feed.feed], + })), + }); + }); + + it(`Read feed capabilities`, async () => { + await feed.getOrCreate(); + + const activity = feed.state.getLatestValue().activities?.[0]!; + + // Make sure to subscribe to changes, it's not guaranteed that own capabilities are ready by the time an activity is being displayed + // Usually you do this in a lifecycle method that's called when an activity component is being created + const unsubscribe = client.state.subscribeWithSelector( + (state) => ({ + ownCapabilities: + state.own_capabilities_by_fid[activity.current_feed?.feed ?? ''], + }), + (state) => { + console.log(state.ownCapabilities); + }, + ); + + // Make sure to call unsubscribe, usually you do this in a lifecycle method that's called before the activity component is destroyed + unsubscribe(); + }); + + afterAll(async () => { + await feed.delete({ hard_delete: true }); + await client.disconnectUser(); + }); +}); From 8216e9d703fa35d41778398082319eb6bc7a3ea8 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 9 Oct 2025 11:39:44 +0200 Subject: [PATCH 15/16] Fetch own capabilities on activity.updated event as well --- .../event-handlers/activity/handle-activity-updated.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-updated.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-updated.ts index 61010a8d..8b662014 100644 --- a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-updated.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-updated.ts @@ -1,5 +1,8 @@ import type { Feed } from '../../../feed'; -import type { ActivityPinResponse, ActivityResponse } from '../../../gen/models'; +import type { + ActivityPinResponse, + ActivityResponse, +} from '../../../gen/models'; import type { EventPayload, PartializeAllBut } from '../../../types-internal'; import { getStateUpdateQueueId, @@ -100,6 +103,10 @@ export function handleActivityUpdated( if (result1?.changed || result2.changed) { this.client.hydratePollCache([payload.activity]); + if (payload.activity.current_feed) { + this.client.hydrateCapabilitiesCache([payload.activity.current_feed]); + } + this.state.partialNext({ activities: result1?.changed ? result1.entities : currentActivities, pinned_activities: result2.entities, From 749e396e29d3e2107ce297ebc31bed733bd1e1c5 Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Thu, 9 Oct 2025 11:43:10 +0200 Subject: [PATCH 16/16] Fix lint errors --- .../docs-snippets/feed-capabilities.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts index a54009c1..9efacf1d 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts @@ -1,8 +1,7 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, it } from 'vitest'; import { createTestClient, createTestTokenGenerator, - getServerClient, getTestUser, } from '../utils'; import type { FeedsClient } from '../../src/feeds-client';