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..24ca1ebf --- /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: 6050, 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/__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..9efacf1d --- /dev/null +++ b/packages/feeds-client/__integration-tests__/docs-snippets/feed-capabilities.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, it } from 'vitest'; +import { + createTestClient, + createTestTokenGenerator, + 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(); + }); +}); diff --git a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts index ef9654e5..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 }, + { + 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 debdb2dd..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 @@ -1,84 +1,32 @@ -import { useMemo } from 'react'; -import { type Feed, type FeedState, FeedOwnCapability } 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'; +import { useCallback } from 'react'; 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 fid = feed?.feed; + + const selector = useCallback((currentState: FeedsClientState) => { + if (!fid) { + return { feedOwnCapabilities: stableEmptyArray }; + } + + return { + feedOwnCapabilities: + currentState.own_capabilities_by_fid[fid] ?? stableEmptyArray, + }; + }, [fid]); + + const { feedOwnCapabilities = stableEmptyArray } = + useStateStore(client?.state, selector) ?? {}; - const { oc = stableEmptyArray } = useStateStore(feed?.state, selector) ?? {}; + // console.log('GETTING CAPA: ', feed?.feed, 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 feedOwnCapabilities; }; 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..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,4 +1,5 @@ -import type { OwnUser } from '../../gen/models'; +import type { OwnUser } from '@self'; +import type { StreamApiError } from '@self'; 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/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 dead4ffb..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 @@ -54,7 +54,15 @@ 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) { + this.client.hydrateCapabilitiesCache([currentFeed]); + } + this.state.partialNext({ activities: result.activities }); } } 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, diff --git a/packages/feeds-client/src/feed/feed.ts b/packages/feeds-client/src/feed/feed.ts index 8feaa754..fe4fd936 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,16 @@ export class Feed extends FeedApi { try { const response = await super.getOrCreate(request); + + 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; @@ -837,11 +847,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..4d4687c3 100644 --- a/packages/feeds-client/src/feeds-client/feeds-client.ts +++ b/packages/feeds-client/src/feeds-client/feeds-client.ts @@ -17,6 +17,7 @@ import type { OwnUser, PollResponse, PollVotesResponse, + QueryActivitiesRequest, QueryFeedsRequest, QueryPollVotesRequest, UpdateActivityRequest, @@ -66,15 +67,25 @@ 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, + DEFAULT_BATCH_OWN_CAPABILITIES_THROTTLING_INTERVAL, + type GetBatchedOwnCapabilitiesThrottledCallback, + queueBatchedOwnCapabilities, + type ThrottledGetBatchedOwnCapabilities, +} from '../utils/throttling'; export type FeedsClientState = { connected_user: OwnUser | undefined; is_ws_connection_healthy: boolean; + own_capabilities_by_fid: Record; }; type FID = string; @@ -97,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(); @@ -110,12 +123,18 @@ 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; 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) => { @@ -231,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++; @@ -276,6 +319,32 @@ export class FeedsClient extends FeedsApi { } } + public hydrateCapabilitiesCache(feedResponses: FeedResponse[]) { + 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)) { + 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 }); + } + connectUser = async (user: UserRequest, tokenProvider: TokenOrProvider) => { if ( this.state.getLatestValue().connected_user !== undefined || @@ -516,9 +585,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 +606,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 +617,8 @@ export class FeedsClient extends FeedsApi { ), ); + this.hydrateCapabilitiesCache(feedResponses); + return { feeds, next: response.next, @@ -550,6 +628,24 @@ 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, ) => { 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..cfebc1f2 --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/index.ts @@ -0,0 +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..8c6aba95 --- /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().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.setSystemTime(i * 100); + } + expect(spy).toHaveBeenCalledTimes(10); + + // one pending trailing for 20000ms is still queued; flush it + vi.runOnlyPendingTimers(); + + 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 new file mode 100644 index 00000000..0032c56b --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/throttle.ts @@ -0,0 +1,113 @@ +export type ThrottledCallback = (...args: unknown[]) => unknown; + +export type ThrottledFunction = + ((...args: T) => void); + +/** + * Throttle a function so it runs at most once per `timeout` ms. + * + * - `leading`: fire immediately when the window opens + * - `trailing`: remember the latest args/this and fire once when the window closes + * + * defaults: `{ leading: true, trailing: false }` + * + * notes: + * - 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: (...args: T) => void, + timeout = 200, + { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {}, +) => { + let timer: NodeJS.Timeout | null = null; + let storedArgs: T | null = null; + let storedThis: unknown = null; + let lastInvokeTime: number | undefined; // timestamp of last actual invocation + + const invoke = (args: T, thisArg: unknown) => { + lastInvokeTime = Date.now(); + fn.apply(thisArg, args); + }; + + const scheduleTrailing = (delay: number) => { + if (timer) return; + timer = setTimeout(() => { + timer = null; + if (trailing && storedArgs) { + invoke(storedArgs, storedThis); + storedArgs = null; + storedThis = null; + } + }, delay); + }; + + 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 (!hasBeenInvoked && !leading) lastInvokeTime = now; + + const timeSinceLast = hasBeenInvoked ? (now - lastInvokeTime!) : timeout; + 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); + } + + return; + } + + // 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/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..d12783eb --- /dev/null +++ b/packages/feeds-client/src/utils/throttling/throttled-get-batched-own-capabilities.ts @@ -0,0 +1,39 @@ +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 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(); + +export function queueBatchedOwnCapabilities( + this: FeedsClient, + { feeds }: GetBatchedOwnCapabilities, +) { + for (const feed of feeds) { + queuedFeeds.add(feed); + } + + if (queuedFeeds.size > 0) { + this.throttledGetBatchOwnCapabilities( + [...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..a5b27511 100644 --- a/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx +++ b/sample-apps/react-native/ExpoTikTokApp/components/activity-composer/ActivityComposer.tsx @@ -155,6 +155,7 @@ export const ActivityComposer = () => { : {}), ...(mentionedUsers ? { mentioned_user_ids: mentionedUsers } : {}), }; + if (editingActivity) { await client?.updateActivity({ ...activityData, 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 a6cc8ce1..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, } 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,27 +39,33 @@ 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 ownCapabilities = useOwnCapabilities(); + const activityFromContext = useActivityContext(); + const activity = actitivyFromProps ?? activityFromContext; + const entity = comment ?? activity; const isComment = isCommentResponse(entity); + const ownCapabilities = useOwnCapabilities(activity.current_feed); + const hasOwnReaction = useMemo( () => !!entity.own_reactions?.find((r) => r.type === type), [entity.own_reactions, type], ); 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(); 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;