diff --git a/packages/feeds-client/@react-bindings/contexts/StreamFeedContext.tsx b/packages/feeds-client/@react-bindings/contexts/StreamFeedContext.tsx index 4ea04461..77c1b858 100644 --- a/packages/feeds-client/@react-bindings/contexts/StreamFeedContext.tsx +++ b/packages/feeds-client/@react-bindings/contexts/StreamFeedContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import type { Feed } from '../../src/Feed'; +import type { Feed } from '../../src/feed'; export const StreamFeedContext = createContext(undefined); diff --git a/packages/feeds-client/@react-bindings/contexts/StreamFeedsContext.tsx b/packages/feeds-client/@react-bindings/contexts/StreamFeedsContext.tsx index 5d151dce..a8ec4a21 100644 --- a/packages/feeds-client/@react-bindings/contexts/StreamFeedsContext.tsx +++ b/packages/feeds-client/@react-bindings/contexts/StreamFeedsContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import type { FeedsClient } from '../../src/FeedsClient'; +import type { FeedsClient } from '../../src/feeds-client'; export const StreamFeedsContext = createContext(undefined); diff --git a/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useClientConnectedUser.ts b/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useClientConnectedUser.ts index 792d425a..c7965e02 100644 --- a/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useClientConnectedUser.ts +++ b/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useClientConnectedUser.ts @@ -1,4 +1,4 @@ -import type { FeedsClientState } from '../../../src/FeedsClient'; +import type { FeedsClientState } from '../../../src/feeds-client'; import { useStateStore } from '../useStateStore'; import { useFeedsClient } from '../../contexts/StreamFeedsContext'; diff --git a/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useWsConnectionState.ts b/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useWsConnectionState.ts index 16299e21..32575cb9 100644 --- a/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useWsConnectionState.ts +++ b/packages/feeds-client/@react-bindings/hooks/client-state-hooks/useWsConnectionState.ts @@ -1,4 +1,4 @@ -import type { FeedsClientState } from '../../../src/FeedsClient'; +import type { FeedsClientState } from '../../../src/feeds-client'; import { useStateStore } from '../useStateStore'; import { useFeedsClient } from '../../contexts/StreamFeedsContext'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useComments.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useComments.ts index 5723b0c0..a82359c2 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useComments.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useComments.ts @@ -4,11 +4,10 @@ import type { CommentResponse, } from '../../../src/gen/models'; import type { CommentParent } from '../../../src/types'; -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { useStateStore } from '../useStateStore'; import { useFeedContext } from '../../contexts/StreamFeedContext'; -import { isCommentResponse } from '../../../src/utils'; -import { checkHasAnotherPage } from '../../../src/utils'; +import { checkHasAnotherPage, isCommentResponse } from '../../../src/utils'; type UseCommentsReturnType = { comments: NonNullable< diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedActivities.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedActivities.ts index fb7b00a7..48a193c5 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedActivities.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedActivities.ts @@ -1,5 +1,5 @@ import { useFeedContext } from '../../contexts/StreamFeedContext'; -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { useStateStore } from '../useStateStore'; import { useMemo } from 'react'; import { useStableCallback } from '../internal'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedMetadata.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedMetadata.ts index e079d8b0..0996b991 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedMetadata.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFeedMetadata.ts @@ -1,4 +1,4 @@ -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { useFeedContext } from '../../contexts/StreamFeedContext'; import { useStateStore } from '../useStateStore'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowers.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowers.ts index 27f2bdbb..7a5ef8a1 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowers.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowers.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { useStateStore } from '../useStateStore'; import { checkHasAnotherPage } from '../../../src/utils'; import { useFeedContext } from '../../contexts/StreamFeedContext'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowing.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowing.ts index bf7ec16d..886df362 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowing.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useFollowing.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { checkHasAnotherPage } from '../../../src/utils'; import { useStateStore } from '../useStateStore'; import { useFeedContext } from '../../contexts/StreamFeedContext'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnCapabilities.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnCapabilities.ts index d4933d1b..e4c8fee8 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnCapabilities.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnCapabilities.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { FeedOwnCapability } from '../../../src/gen/models'; import { useStateStore } from '../useStateStore'; import { useFeedContext } from '../../contexts/StreamFeedContext'; diff --git a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnFollows.ts b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnFollows.ts index 91cda860..99a1823e 100644 --- a/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnFollows.ts +++ b/packages/feeds-client/@react-bindings/hooks/feed-state-hooks/useOwnFollows.ts @@ -1,4 +1,4 @@ -import { Feed, FeedState } from '../../../src/Feed'; +import { Feed, FeedState } from '../../../src/feed'; import { useFeedContext } from '../../contexts/StreamFeedContext'; import { useStateStore } from '../useStateStore'; diff --git a/packages/feeds-client/@react-bindings/hooks/useCreateFeedsClient.ts b/packages/feeds-client/@react-bindings/hooks/useCreateFeedsClient.ts index d5c8e205..7e24dd9d 100644 --- a/packages/feeds-client/@react-bindings/hooks/useCreateFeedsClient.ts +++ b/packages/feeds-client/@react-bindings/hooks/useCreateFeedsClient.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { FeedsClient } from '../../src/FeedsClient'; +import { FeedsClient } from '../../src/feeds-client'; import type { UserRequest } from '../../src/gen/models'; import type { TokenOrProvider } from '../../src/types'; import type { FeedsClientOptions } from '../../src/common/types'; diff --git a/packages/feeds-client/@react-bindings/hooks/util/useReactionActions.ts b/packages/feeds-client/@react-bindings/hooks/util/useReactionActions.ts index feb81eb5..864bc620 100644 --- a/packages/feeds-client/@react-bindings/hooks/util/useReactionActions.ts +++ b/packages/feeds-client/@react-bindings/hooks/util/useReactionActions.ts @@ -1,8 +1,8 @@ import { useMemo } from 'react'; import { useFeedsClient } from '../../contexts/StreamFeedsContext'; import { CommentParent } from '../../../src/types'; -import { isCommentResponse } from '../../../src/utils'; import { useStableCallback } from '../internal'; +import { isCommentResponse } from '../../../src/utils'; /** * A utility hook that takes in an entity and a reaction type, and creates reaction actions diff --git a/packages/feeds-client/@react-bindings/wrappers/StreamFeed.tsx b/packages/feeds-client/@react-bindings/wrappers/StreamFeed.tsx index 1473da26..9ebfe546 100644 --- a/packages/feeds-client/@react-bindings/wrappers/StreamFeed.tsx +++ b/packages/feeds-client/@react-bindings/wrappers/StreamFeed.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; import { StreamFeedContext } from '../contexts/StreamFeedContext'; -import type { Feed } from '../../src/Feed'; +import type { Feed } from '../../src/feed'; /** * The props for the StreamFeed component. It accepts a `Feed` instance. diff --git a/packages/feeds-client/__integration-tests__/activity-websocket-events.test.ts b/packages/feeds-client/__integration-tests__/activity-websocket-events.test.ts index cd0eb0a3..4b329ea4 100644 --- a/packages/feeds-client/__integration-tests__/activity-websocket-events.test.ts +++ b/packages/feeds-client/__integration-tests__/activity-websocket-events.test.ts @@ -6,13 +6,13 @@ import { getTestUser, waitForEvent, } from './utils'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; import { ActivityAddedEvent, ActivityUpdatedEvent, ActivityDeletedEvent, } from '../src/gen/models'; -import { Feed } from '../src/Feed'; +import { Feed } from '../src/feed'; describe('Activity state updates via WebSocket events', () => { let client: FeedsClient; diff --git a/packages/feeds-client/__integration-tests__/api-requests-and-errors.test.ts b/packages/feeds-client/__integration-tests__/api-requests-and-errors.test.ts index 667b010e..d57b01bf 100644 --- a/packages/feeds-client/__integration-tests__/api-requests-and-errors.test.ts +++ b/packages/feeds-client/__integration-tests__/api-requests-and-errors.test.ts @@ -5,7 +5,7 @@ import { getTestUser, } from './utils'; import { sleep } from '../src/common/utils'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; import { UserRequest } from '../src/gen/models'; describe('API requests and error handling', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/activities.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/activities.test.ts index c30401cb..225009db 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/activities.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/activities.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; describe('Activities page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/bookmarks.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/bookmarks.test.ts index 12de825d..2a46d5b5 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/bookmarks.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/bookmarks.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { ActivityResponse, UserRequest } from '../../src/gen/models'; describe('Bookmarks page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/comments.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/comments.test.ts index 535cddbc..c9532fda 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/comments.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/comments.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { ActivityResponse, CommentResponse, diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/feed.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/feed.test.ts index ed507f1e..9b6b5fef 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/feed.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/feed.test.ts @@ -5,8 +5,8 @@ import { getServerClient, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; describe('Feeds page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/file-uploads.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/file-uploads.test.ts index 26ce23f4..e3141313 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/file-uploads.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/file-uploads.test.ts @@ -4,12 +4,12 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; +import { isImageFile } from '../../src/utils'; import fs from 'fs'; import path from 'path'; -import { isImageFile } from '../../src/utils'; describe('File uploads page', () => { let client: FeedsClient; diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/follows.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/follows.test.ts index 0b897adf..af903cde 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/follows.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/follows.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; describe('Follows page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/pin-unpin.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/pin-unpin.test.ts index 8c4b2f6c..199e8d7f 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/pin-unpin.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/pin-unpin.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { ActivityResponse, UserRequest } from '../../src/gen/models'; describe('Pin and unpin page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/poll.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/poll.test.ts index 8be56607..4563d313 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/poll.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/poll.test.ts @@ -5,8 +5,8 @@ import { getTestUser, waitForEvent, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { ActivityResponse, UserRequest } from '../../src/gen/models'; describe('Polls page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/query-activities.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/query-activities.test.ts index 0c3593ef..7fe8ce88 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/query-activities.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/query-activities.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; import { randomId } from '../../../feeds-client/src/common/utils'; diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/quick-start.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/quick-start.test.ts index 44f97694..e5955f17 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/quick-start.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/quick-start.test.ts @@ -5,8 +5,8 @@ import { getServerClient, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; describe('Quick start page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/reactions.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/reactions.test.ts index 63895383..65866200 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/reactions.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/reactions.test.ts @@ -4,8 +4,8 @@ import { createTestTokenGenerator, getTestUser, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { ActivityResponse, UserRequest } from '../../src/gen/models'; describe('Reactions page', () => { diff --git a/packages/feeds-client/__integration-tests__/docs-snippets/state-layer.test.ts b/packages/feeds-client/__integration-tests__/docs-snippets/state-layer.test.ts index 886b8565..a3a69085 100644 --- a/packages/feeds-client/__integration-tests__/docs-snippets/state-layer.test.ts +++ b/packages/feeds-client/__integration-tests__/docs-snippets/state-layer.test.ts @@ -5,8 +5,8 @@ import { getTestUser, waitForEvent, } from '../utils'; -import { FeedsClient } from '../../src/FeedsClient'; -import { Feed } from '../../src/Feed'; +import { FeedsClient } from '../../src/feeds-client'; +import { Feed } from '../../src/feed'; import { UserRequest } from '../../src/gen/models'; describe('State layer page', () => { diff --git a/packages/feeds-client/__integration-tests__/feed-follow-unfollow.test.ts b/packages/feeds-client/__integration-tests__/feed-follow-unfollow.test.ts index 54014761..02c01f1c 100644 --- a/packages/feeds-client/__integration-tests__/feed-follow-unfollow.test.ts +++ b/packages/feeds-client/__integration-tests__/feed-follow-unfollow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; import { createTestClient, createTestTokenGenerator, diff --git a/packages/feeds-client/__integration-tests__/feed-pagination.test.ts b/packages/feeds-client/__integration-tests__/feed-pagination.test.ts index 0217919b..0e209061 100644 --- a/packages/feeds-client/__integration-tests__/feed-pagination.test.ts +++ b/packages/feeds-client/__integration-tests__/feed-pagination.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; import { createTestClient, createTestTokenGenerator, diff --git a/packages/feeds-client/__integration-tests__/feed-watch-unwatch.test.ts b/packages/feeds-client/__integration-tests__/feed-watch-unwatch.test.ts index 1638ac91..6dfd2904 100644 --- a/packages/feeds-client/__integration-tests__/feed-watch-unwatch.test.ts +++ b/packages/feeds-client/__integration-tests__/feed-watch-unwatch.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; import { createTestClient, createTestTokenGenerator, diff --git a/packages/feeds-client/__integration-tests__/feed-websocket-events.test.ts b/packages/feeds-client/__integration-tests__/feed-websocket-events.test.ts index 8e5beff9..09d085b5 100644 --- a/packages/feeds-client/__integration-tests__/feed-websocket-events.test.ts +++ b/packages/feeds-client/__integration-tests__/feed-websocket-events.test.ts @@ -6,7 +6,7 @@ import { getTestUser, waitForEvent, } from './utils'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; describe('Feed state updates via WebSocket events', () => { let client: FeedsClient; diff --git a/packages/feeds-client/__integration-tests__/feeds.test.ts b/packages/feeds-client/__integration-tests__/feeds.test.ts index f7839db7..d80ce061 100644 --- a/packages/feeds-client/__integration-tests__/feeds.test.ts +++ b/packages/feeds-client/__integration-tests__/feeds.test.ts @@ -5,8 +5,8 @@ import { createTestTokenGenerator, getTestUser, } from './utils'; -import { FeedsClient } from '../src/FeedsClient'; -import { Feed } from '../src/Feed'; +import { FeedsClient } from '../src/feeds-client'; +import { Feed } from '../src/feed'; describe('Feeds API basic test', () => { let client: FeedsClient; diff --git a/packages/feeds-client/__integration-tests__/utils.ts b/packages/feeds-client/__integration-tests__/utils.ts index 18a9cc80..de4fcd45 100644 --- a/packages/feeds-client/__integration-tests__/utils.ts +++ b/packages/feeds-client/__integration-tests__/utils.ts @@ -1,5 +1,5 @@ -import { FeedsClient } from '../src/FeedsClient'; -import { Feed } from '../src/Feed'; +import { FeedsClient } from '../src/feeds-client'; +import { Feed } from '../src/feed'; import { UserRequest } from '../src/gen/models'; import { FeedsClientOptions } from '../src/common/types'; import { WSEvent } from '../src/gen/models'; diff --git a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts index d2ec58a6..2678cbe8 100644 --- a/packages/feeds-client/__integration-tests__/websocket-connection.test.ts +++ b/packages/feeds-client/__integration-tests__/websocket-connection.test.ts @@ -7,7 +7,7 @@ import { waitForEvent, } from './utils'; import { ConnectedEvent } from '../src/common/real-time/event-models'; -import { FeedsClient } from '../src/FeedsClient'; +import { FeedsClient } from '../src/feeds-client'; describe('WebSocket connection', () => { let client: FeedsClient; const user: UserRequest = getTestUser(); diff --git a/packages/feeds-client/index.ts b/packages/feeds-client/index.ts index 498a31d8..73e1bbd9 100644 --- a/packages/feeds-client/index.ts +++ b/packages/feeds-client/index.ts @@ -1,5 +1,5 @@ -export * from './src/FeedsClient'; -export * from './src/Feed'; +export * from './src/feeds-client'; +export * from './src/feed/feed'; export * from './src/gen/models'; export * from './src/types'; export * from './src/common/types'; diff --git a/packages/feeds-client/package.json b/packages/feeds-client/package.json index 10796fcb..dff19541 100644 --- a/packages/feeds-client/package.json +++ b/packages/feeds-client/package.json @@ -67,6 +67,7 @@ "@types/react": "^19.1.8", "@vitest/coverage-v8": "3.2.4", "dotenv": "^16.4.5", + "human-id": "^4.1.1", "react": "19.0.0", "rimraf": "^6.0.1", "rollup": "^4.24.0", diff --git a/packages/feeds-client/src/common/ActivitySearchSource.ts b/packages/feeds-client/src/common/ActivitySearchSource.ts index 50f89902..79a0d6cb 100644 --- a/packages/feeds-client/src/common/ActivitySearchSource.ts +++ b/packages/feeds-client/src/common/ActivitySearchSource.ts @@ -1,7 +1,7 @@ import { BaseSearchSource } from './BaseSearchSource'; import type { SearchSourceOptions } from './BaseSearchSource'; -import { FeedsClient } from '../FeedsClient'; +import { FeedsClient } from '../feeds-client'; import { ActivityResponse } from '../gen/models'; export class ActivitySearchSource extends BaseSearchSource { diff --git a/packages/feeds-client/src/common/FeedSearchSource.ts b/packages/feeds-client/src/common/FeedSearchSource.ts index 41139705..6e7f0ba4 100644 --- a/packages/feeds-client/src/common/FeedSearchSource.ts +++ b/packages/feeds-client/src/common/FeedSearchSource.ts @@ -1,8 +1,8 @@ import { BaseSearchSource } from './BaseSearchSource'; import type { SearchSourceOptions } from './BaseSearchSource'; -import { FeedsClient } from '../FeedsClient'; -import { Feed } from '../Feed'; +import { FeedsClient } from '../feeds-client'; +import { Feed } from '../feed'; export type FeedSearchSourceOptions = SearchSourceOptions & { groupId?: string; diff --git a/packages/feeds-client/src/common/Poll.ts b/packages/feeds-client/src/common/Poll.ts index d64c2d4a..b852a971 100644 --- a/packages/feeds-client/src/common/Poll.ts +++ b/packages/feeds-client/src/common/Poll.ts @@ -1,5 +1,5 @@ import { StateStore } from './StateStore'; -import type { FeedsClient } from '../FeedsClient'; +import type { FeedsClient } from '../feeds-client'; import type { PollVote, QueryPollVotesRequest, diff --git a/packages/feeds-client/src/common/UserSearchSource.ts b/packages/feeds-client/src/common/UserSearchSource.ts index fb3aed4a..8b044815 100644 --- a/packages/feeds-client/src/common/UserSearchSource.ts +++ b/packages/feeds-client/src/common/UserSearchSource.ts @@ -1,7 +1,7 @@ import { BaseSearchSource } from './BaseSearchSource'; import type { SearchSourceOptions } from './BaseSearchSource'; -import { FeedsClient } from '../FeedsClient'; +import { FeedsClient } from '../feeds-client'; import { UserResponse } from '../gen/models'; export class UserSearchSource extends BaseSearchSource { diff --git a/packages/feeds-client/src/state-updates/activity-reaction-utils.test.ts b/packages/feeds-client/src/feed/event-handlers/activity/activity-reaction-utils.test.ts similarity index 97% rename from packages/feeds-client/src/state-updates/activity-reaction-utils.test.ts rename to packages/feeds-client/src/feed/event-handlers/activity/activity-reaction-utils.test.ts index b7b540cb..ef6ce85f 100644 --- a/packages/feeds-client/src/state-updates/activity-reaction-utils.test.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/activity-reaction-utils.test.ts @@ -4,13 +4,13 @@ import { ActivityReactionDeletedEvent, ActivityResponse, FeedsReactionResponse, -} from '../gen/models'; +} from '../../../gen/models'; import { addReactionToActivity, removeReactionFromActivity, addReactionToActivities, removeReactionFromActivities, -} from './activity-reaction-utils'; +} from './'; const createMockActivity = (id: string): ActivityResponse => ({ id, @@ -109,6 +109,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; @@ -123,6 +124,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }); }); @@ -136,6 +138,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -150,6 +153,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }); }); }); @@ -165,6 +169,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -196,6 +201,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -230,6 +236,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -253,6 +260,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -274,6 +282,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); @@ -296,6 +305,7 @@ describe('activity-reaction-utils', () => { count: 1, first_reaction_at: reaction.created_at, last_reaction_at: reaction.created_at, + sum_scores: 0, }, }; const event = createMockAddedEvent(reaction, eventActivity); diff --git a/packages/feeds-client/src/state-updates/activity-utils.test.ts b/packages/feeds-client/src/feed/event-handlers/activity/activity-utils.test.ts similarity index 98% rename from packages/feeds-client/src/state-updates/activity-utils.test.ts rename to packages/feeds-client/src/feed/event-handlers/activity/activity-utils.test.ts index 7bca5aa2..8df56a02 100644 --- a/packages/feeds-client/src/state-updates/activity-utils.test.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/activity-utils.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { ActivityResponse, FeedsReactionResponse } from '../gen/models'; +import { ActivityResponse, FeedsReactionResponse } from '../../../gen/models'; import { addActivitiesToState, updateActivityInState, removeActivityFromState, -} from './activity-utils'; +} from './'; const createMockActivity = (id: string, text?: string): ActivityResponse => ({ @@ -181,6 +181,7 @@ describe('activity-utils', () => { ]; originalActivity.reaction_groups = { like: { + sum_scores: 0, count: 1, first_reaction_at: new Date(), last_reaction_at: new Date(), diff --git a/packages/feeds-client/src/state-updates/activity-utils.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts similarity index 50% rename from packages/feeds-client/src/state-updates/activity-utils.ts rename to packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts index 9aa303cb..2ddd95a2 100644 --- a/packages/feeds-client/src/state-updates/activity-utils.ts +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-added.ts @@ -1,5 +1,6 @@ -import { ActivityResponse } from '../gen/models'; -import { UpdateStateResult } from '../types-internal'; +import { Feed } from '../../../feed'; +import { ActivityResponse } from '../../../gen/models'; +import { EventPayload, UpdateStateResult } from '../../../types-internal'; export const addActivitiesToState = ( newActivities: ActivityResponse[], @@ -42,39 +43,18 @@ export const addActivitiesToState = ( return result; }; -export const updateActivityInState = ( - updatedActivityResponse: ActivityResponse, - activities: ActivityResponse[], -) => { - const index = activities.findIndex( - (a) => a.id === updatedActivityResponse.id, +export function handleActivityAdded( + this: Feed, + event: EventPayload<'feeds.activity.added'>, +) { + const currentActivities = this.currentState.activities; + const result = addActivitiesToState( + [event.activity], + currentActivities, + 'start', ); - if (index !== -1) { - const newActivities = [...activities]; - const activity = activities[index]; - newActivities[index] = { - ...updatedActivityResponse, - own_reactions: activity.own_reactions, - own_bookmarks: activity.own_bookmarks, - latest_reactions: activity.latest_reactions, - reaction_groups: activity.reaction_groups, - }; - return { changed: true, activities: newActivities }; - } else { - return { changed: false, activities }; + if (result.changed) { + this.client.hydratePollCache([event.activity]); + this.state.partialNext({ activities: result.activities }); } -}; - -export const removeActivityFromState = ( - activityResponse: ActivityResponse, - activities: ActivityResponse[], -) => { - const index = activities.findIndex((a) => a.id === activityResponse.id); - if (index !== -1) { - const newActivities = [...activities]; - newActivities.splice(index, 1); - return { changed: true, activities: newActivities }; - } else { - return { changed: false, activities }; - } -}; +} diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-deleted.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-deleted.ts new file mode 100644 index 00000000..7b08dbb4 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-deleted.ts @@ -0,0 +1,30 @@ +import type { Feed } from '../../../feed'; +import type { ActivityResponse } from '../../../gen/models'; +import type { EventPayload } from '../../../types-internal'; + +export const removeActivityFromState = ( + activityResponse: ActivityResponse, + activities: ActivityResponse[], +) => { + const index = activities.findIndex((a) => a.id === activityResponse.id); + if (index !== -1) { + const newActivities = [...activities]; + newActivities.splice(index, 1); + return { changed: true, activities: newActivities }; + } else { + return { changed: false, activities }; + } +}; + +export function handleActivityDeleted( + this: Feed, + event: EventPayload<'feeds.activity.deleted'>, +) { + const currentActivities = this.currentState.activities; + if (currentActivities) { + const result = removeActivityFromState(event.activity, currentActivities); + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-added.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-added.ts new file mode 100644 index 00000000..26ff23b9 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-added.ts @@ -0,0 +1,67 @@ +import type { Feed } from '../../../feed'; +import type { + ActivityReactionAddedEvent, + ActivityResponse, +} from '../../../gen/models'; +import type { EventPayload, UpdateStateResult } from '../../../types-internal'; + +import { updateActivityInState } from './handle-activity-updated'; + +export const addReactionToActivity = ( + event: ActivityReactionAddedEvent, + activity: ActivityResponse, + isCurrentUser: boolean, +): UpdateStateResult => { + // Update own_reactions if the reaction is from the current user + const ownReactions = [...(activity.own_reactions || [])]; + if (isCurrentUser) { + ownReactions.push(event.reaction); + } + + return { + ...activity, + own_reactions: ownReactions, + latest_reactions: event.activity.latest_reactions, + reaction_groups: event.activity.reaction_groups, + changed: true, + }; +}; + +export const addReactionToActivities = ( + event: ActivityReactionAddedEvent, + activities: ActivityResponse[] | undefined, + isCurrentUser: boolean, +): UpdateStateResult<{ activities: ActivityResponse[] }> => { + if (!activities) { + return { changed: false, activities: [] }; + } + + const activityIndex = activities.findIndex((a) => a.id === event.activity.id); + if (activityIndex === -1) { + return { changed: false, activities }; + } + + const activity = activities[activityIndex]; + const updatedActivity = addReactionToActivity(event, activity, isCurrentUser); + return updateActivityInState(updatedActivity, activities, true); +}; + +export function handleActivityReactionAdded( + this: Feed, + event: EventPayload<'feeds.activity.reaction.added'>, +) { + const currentActivities = this.currentState.activities; + const connectedUser = this.client.state.getLatestValue().connected_user; + const isCurrentUser = Boolean( + connectedUser && event.reaction.user.id === connectedUser.id, + ); + + const result = addReactionToActivities( + event, + currentActivities, + isCurrentUser, + ); + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-deleted.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-deleted.ts new file mode 100644 index 00000000..a45e4730 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-reaction-deleted.ts @@ -0,0 +1,75 @@ +import type { Feed } from '../../../feed'; +import type { + ActivityReactionDeletedEvent, + ActivityResponse, +} from '../../../gen/models'; +import type { EventPayload, UpdateStateResult } from '../../../types-internal'; + +import { updateActivityInState } from './handle-activity-updated'; +export const removeReactionFromActivity = ( + event: ActivityReactionDeletedEvent, + activity: ActivityResponse, + isCurrentUser: boolean, +): UpdateStateResult => { + // Update own_reactions if the reaction is from the current user + const ownReactions = isCurrentUser + ? (activity.own_reactions || []).filter( + (r) => + !( + r.type === event.reaction.type && + r.user.id === event.reaction.user.id + ), + ) + : activity.own_reactions; + + return { + ...activity, + own_reactions: ownReactions, + latest_reactions: event.activity.latest_reactions, + reaction_groups: event.activity.reaction_groups, + changed: true, + }; +}; + +export const removeReactionFromActivities = ( + event: ActivityReactionDeletedEvent, + activities: ActivityResponse[] | undefined, + isCurrentUser: boolean, +): UpdateStateResult<{ activities: ActivityResponse[] }> => { + if (!activities) { + return { changed: false, activities: [] }; + } + + const activityIndex = activities.findIndex((a) => a.id === event.activity.id); + if (activityIndex === -1) { + return { changed: false, activities }; + } + + const activity = activities[activityIndex]; + const updatedActivity = removeReactionFromActivity( + event, + activity, + isCurrentUser, + ); + return updateActivityInState(updatedActivity, activities, true); +}; + +export function handleActivityReactionDeleted( + this: Feed, + event: EventPayload<'feeds.activity.reaction.deleted'>, +) { + const currentActivities = this.currentState.activities; + const connectedUser = this.client.state.getLatestValue().connected_user; + const isCurrentUser = Boolean( + connectedUser && event.reaction.user.id === connectedUser.id, + ); + + const result = removeReactionFromActivities( + event, + currentActivities, + isCurrentUser, + ); + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-removed-from-feed.ts b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-removed-from-feed.ts new file mode 100644 index 00000000..a9cee3f7 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-removed-from-feed.ts @@ -0,0 +1,16 @@ +import { Feed } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; +import { removeActivityFromState } from './handle-activity-deleted'; + +export function handleActivityRemovedFromFeed( + this: Feed, + event: EventPayload<'feeds.activity.removed_from_feed'>, +) { + const currentActivities = this.currentState.activities; + if (currentActivities) { + const result = removeActivityFromState(event.activity, currentActivities); + if (result.changed) { + 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 new file mode 100644 index 00000000..b6ec8b81 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/handle-activity-updated.ts @@ -0,0 +1,47 @@ +import { Feed } from '../../../feed'; +import { ActivityResponse } from '../../../gen/models'; +import { EventPayload } from '../../../types-internal'; + +export const updateActivityInState = ( + updatedActivityResponse: ActivityResponse, + activities: ActivityResponse[], + replaceCompletely: boolean = false, +) => { + const index = activities.findIndex( + (a) => a.id === updatedActivityResponse.id, + ); + if (index !== -1) { + const newActivities = [...activities]; + const activity = activities[index]; + + if (replaceCompletely) { + newActivities[index] = updatedActivityResponse; + } else { + newActivities[index] = { + ...updatedActivityResponse, + own_reactions: activity.own_reactions, + own_bookmarks: activity.own_bookmarks, + latest_reactions: activity.latest_reactions, + reaction_groups: activity.reaction_groups, + }; + } + + return { changed: true, activities: newActivities }; + } else { + return { changed: false, activities }; + } +}; + +export function handleActivityUpdated( + this: Feed, + event: EventPayload<'feeds.activity.updated'>, +) { + const currentActivities = this.currentState.activities; + if (currentActivities) { + const result = updateActivityInState(event.activity, currentActivities); + if (result.changed) { + this.client.hydratePollCache([event.activity]); + this.state.partialNext({ activities: result.activities }); + } + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/activity/index.ts b/packages/feeds-client/src/feed/event-handlers/activity/index.ts new file mode 100644 index 00000000..1249c945 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/activity/index.ts @@ -0,0 +1,6 @@ +export * from './handle-activity-added'; +export * from './handle-activity-deleted'; +export * from './handle-activity-removed-from-feed'; +export * from './handle-activity-updated'; +export * from './handle-activity-reaction-added'; +export * from './handle-activity-reaction-deleted'; \ No newline at end of file diff --git a/packages/feeds-client/src/state-updates/bookmark-utils.test.ts b/packages/feeds-client/src/feed/event-handlers/bookmark/bookmark-utils.test.ts similarity index 99% rename from packages/feeds-client/src/state-updates/bookmark-utils.test.ts rename to packages/feeds-client/src/feed/event-handlers/bookmark/bookmark-utils.test.ts index da94e06d..8a8ca7fb 100644 --- a/packages/feeds-client/src/state-updates/bookmark-utils.test.ts +++ b/packages/feeds-client/src/feed/event-handlers/bookmark/bookmark-utils.test.ts @@ -6,7 +6,7 @@ import { ActivityResponse, BookmarkResponse, UserResponse, -} from '../gen/models'; +} from '../../../gen/models'; import { addBookmarkToActivity, removeBookmarkFromActivity, @@ -14,7 +14,7 @@ import { addBookmarkToActivities, removeBookmarkFromActivities, updateBookmarkInActivities, -} from './bookmark-utils'; +} from './'; const createMockUser = (id: string): UserResponse => ({ id, diff --git a/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-added.ts b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-added.ts new file mode 100644 index 00000000..4ce733e2 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-added.ts @@ -0,0 +1,63 @@ +import type { Feed } from '../../../feed'; +import type { ActivityResponse, BookmarkAddedEvent } from '../../../gen/models'; +import type { EventPayload, UpdateStateResult } from '../../../types-internal'; + +import { updateActivityInState } from '../activity'; + +export const addBookmarkToActivity = ( + event: BookmarkAddedEvent, + activity: ActivityResponse, + isCurrentUser: boolean, +): UpdateStateResult => { + // Update own_bookmarks if the bookmark is from the current user + const ownBookmarks = [...(activity.own_bookmarks || [])]; + if (isCurrentUser) { + ownBookmarks.push(event.bookmark); + } + + return { + ...activity, + own_bookmarks: ownBookmarks, + changed: true, + }; +}; + +export const addBookmarkToActivities = ( + event: BookmarkAddedEvent, + activities: ActivityResponse[] | undefined, + isCurrentUser: boolean, +): UpdateStateResult<{ activities: ActivityResponse[] }> => { + if (!activities) { + return { changed: false, activities: [] }; + } + + const activityIndex = activities.findIndex( + (a) => a.id === event.bookmark.activity.id, + ); + if (activityIndex === -1) { + return { changed: false, activities }; + } + + const activity = activities[activityIndex]; + const updatedActivity = addBookmarkToActivity(event, activity, isCurrentUser); + return updateActivityInState(updatedActivity, activities, true); +}; + +export function handleBookmarkAdded( + this: Feed, + event: EventPayload<'feeds.bookmark.added'>, +) { + const currentActivities = this.currentState.activities; + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + const isCurrentUser = event.bookmark.user.id === connectedUser?.id; + + const result = addBookmarkToActivities( + event, + currentActivities, + isCurrentUser, + ); + + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-deleted.ts b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-deleted.ts new file mode 100644 index 00000000..4ef63f9d --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-deleted.ts @@ -0,0 +1,84 @@ +import type { Feed } from '../../../feed'; +import type { + ActivityResponse, + BookmarkDeletedEvent, + BookmarkResponse, +} from '../../../gen/models'; +import type { EventPayload, UpdateStateResult } from '../../../types-internal'; + +import { updateActivityInState } from '../activity'; + +// Helper function to check if two bookmarks are the same +// A bookmark is identified by activity_id + folder_id + user_id +export const isSameBookmark = ( + bookmark1: BookmarkResponse, + bookmark2: BookmarkResponse, +): boolean => { + return ( + bookmark1.user.id === bookmark2.user.id && + bookmark1.activity.id === bookmark2.activity.id && + bookmark1.folder?.id === bookmark2.folder?.id + ); +}; + +export const removeBookmarkFromActivities = ( + event: BookmarkDeletedEvent, + activities: ActivityResponse[] | undefined, + isCurrentUser: boolean, +): UpdateStateResult<{ activities: ActivityResponse[] }> => { + if (!activities) { + return { changed: false, activities: [] }; + } + + const activityIndex = activities.findIndex( + (a) => a.id === event.bookmark.activity.id, + ); + if (activityIndex === -1) { + return { changed: false, activities }; + } + + const activity = activities[activityIndex]; + const updatedActivity = removeBookmarkFromActivity( + event, + activity, + isCurrentUser, + ); + return updateActivityInState(updatedActivity, activities, true); +}; + +export const removeBookmarkFromActivity = ( + event: BookmarkDeletedEvent, + activity: ActivityResponse, + isCurrentUser: boolean, +): UpdateStateResult => { + // Update own_bookmarks if the bookmark is from the current user + const ownBookmarks = isCurrentUser + ? (activity.own_bookmarks || []).filter( + (bookmark) => !isSameBookmark(bookmark, event.bookmark), + ) + : activity.own_bookmarks; + + return { + ...activity, + own_bookmarks: ownBookmarks, + changed: true, + }; +}; + +export function handleBookmarkDeleted( + this: Feed, + event: EventPayload<'feeds.bookmark.deleted'>, +) { + const currentActivities = this.currentState.activities; + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + const isCurrentUser = event.bookmark.user.id === connectedUser?.id; + + const result = removeBookmarkFromActivities( + event, + currentActivities, + isCurrentUser, + ); + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-updated.ts b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-updated.ts new file mode 100644 index 00000000..45437512 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/bookmark/handle-bookmark-updated.ts @@ -0,0 +1,76 @@ +import type { Feed } from '../../../feed'; +import type { + ActivityResponse, + BookmarkUpdatedEvent, +} from '../../../gen/models'; +import type { EventPayload, UpdateStateResult } from '../../../types-internal'; + +import { updateActivityInState } from '../activity'; +import { isSameBookmark } from './handle-bookmark-deleted'; + +export const updateBookmarkInActivity = ( + event: BookmarkUpdatedEvent, + activity: ActivityResponse, + isCurrentUser: boolean, +): UpdateStateResult => { + // Update own_bookmarks if the bookmark is from the current user + let ownBookmarks = activity.own_bookmarks || []; + if (isCurrentUser) { + const bookmarkIndex = ownBookmarks.findIndex((bookmark) => + isSameBookmark(bookmark, event.bookmark), + ); + if (bookmarkIndex !== -1) { + ownBookmarks = [...ownBookmarks]; + ownBookmarks[bookmarkIndex] = event.bookmark; + } + } + + return { + ...activity, + own_bookmarks: ownBookmarks, + changed: true, + }; +}; + +export const updateBookmarkInActivities = ( + event: BookmarkUpdatedEvent, + activities: ActivityResponse[] | undefined, + isCurrentUser: boolean, +): UpdateStateResult<{ activities: ActivityResponse[] }> => { + if (!activities) { + return { changed: false, activities: [] }; + } + + const activityIndex = activities.findIndex( + (a) => a.id === event.bookmark.activity.id, + ); + if (activityIndex === -1) { + return { changed: false, activities }; + } + + const activity = activities[activityIndex]; + const updatedActivity = updateBookmarkInActivity( + event, + activity, + isCurrentUser, + ); + return updateActivityInState(updatedActivity, activities, true); +}; + +export function handleBookmarkUpdated( + this: Feed, + event: EventPayload<'feeds.bookmark.updated'>, +) { + const currentActivities = this.currentState.activities; + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + const isCurrentUser = event.bookmark.user.id === connectedUser?.id; + + const result = updateBookmarkInActivities( + event, + currentActivities, + isCurrentUser, + ); + if (result.changed) { + this.state.partialNext({ activities: result.activities }); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/bookmark/index.ts b/packages/feeds-client/src/feed/event-handlers/bookmark/index.ts new file mode 100644 index 00000000..2f7e3227 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/bookmark/index.ts @@ -0,0 +1,3 @@ +export * from './handle-bookmark-added'; +export * from './handle-bookmark-deleted'; +export * from './handle-bookmark-updated'; diff --git a/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-added.ts b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-added.ts new file mode 100644 index 00000000..b8f9ecb7 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-added.ts @@ -0,0 +1,38 @@ +import type { Feed } from '../../../feed'; +import type { EventPayload } from '../../../types-internal'; + +export function handleCommentAdded( + this: Feed, + event: EventPayload<'feeds.comment.added'>, +) { + const { comment } = event; + const entityId = comment.parent_id ?? comment.object_id; + + this.state.next((currentState) => { + const entityState = currentState.comments_by_entity_id[entityId]; + + if (typeof entityState?.comments === 'undefined') { + return currentState; + } + + const newComments = entityState?.comments ? [...entityState.comments] : []; + + if (entityState.pagination?.sort === 'last') { + newComments.unshift(comment); + } else { + // 'first' and other sort options + newComments.push(comment); + } + + return { + ...currentState, + comments_by_entity_id: { + ...currentState.comments_by_entity_id, + [entityId]: { + ...currentState.comments_by_entity_id[entityId], + comments: newComments, + }, + }, + }; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-deleted.ts b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-deleted.ts new file mode 100644 index 00000000..aeef4e92 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-deleted.ts @@ -0,0 +1,35 @@ +import { Feed } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleCommentDeleted( + this: Feed, + { comment }: EventPayload<'feeds.comment.deleted'>, +) { + const entityId = comment.parent_id ?? comment.object_id; + + this.state.next((currentState) => { + const newCommentsByEntityId = { + ...currentState.comments_by_entity_id, + [entityId]: { + ...currentState.comments_by_entity_id[entityId], + }, + }; + + const index = this.getCommentIndex(comment, currentState); + + if (newCommentsByEntityId?.[entityId]?.comments?.length && index !== -1) { + newCommentsByEntityId[entityId].comments = [ + ...newCommentsByEntityId[entityId].comments, + ]; + + newCommentsByEntityId[entityId]?.comments?.splice(index, 1); + } + + delete newCommentsByEntityId[comment.id]; + + return { + ...currentState, + comments_by_entity_id: newCommentsByEntityId, + }; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-reaction.ts b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-reaction.ts new file mode 100644 index 00000000..ccc11845 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-reaction.ts @@ -0,0 +1,61 @@ +import { Feed } from '../../../feed'; +import { CommentResponse } from '../../../gen/models'; +import { EventPayload } from '../../../types-internal'; + +export function handleCommentReaction( + this: Feed, + event: EventPayload< + 'feeds.comment.reaction.added' | 'feeds.comment.reaction.deleted' + >, +) { + const { comment, reaction } = event; + const connectedUser = this.client.state.getLatestValue().connected_user; + + this.state.next((currentState) => { + const forId = comment.parent_id ?? comment.object_id; + const entityState = currentState.comments_by_entity_id[forId]; + + const commentIndex = this.getCommentIndex(comment, currentState); + + if (commentIndex === -1) return currentState; + + const newComments = entityState?.comments?.concat([]) ?? []; + + const commentCopy: Partial = { ...comment }; + + delete commentCopy.own_reactions; + + const newComment: CommentResponse = { + ...newComments[commentIndex], + ...commentCopy, + // TODO: FIXME this should be handled by the backend + latest_reactions: commentCopy.latest_reactions ?? [], + reaction_groups: commentCopy.reaction_groups ?? {}, + }; + + newComments[commentIndex] = newComment; + + if (reaction.user.id === connectedUser?.id) { + if (event.type === 'feeds.comment.reaction.added') { + newComment.own_reactions = newComment.own_reactions.concat( + reaction, + ) ?? [reaction]; + } else if (event.type === 'feeds.comment.reaction.deleted') { + newComment.own_reactions = newComment.own_reactions.filter( + (r) => r.type !== reaction.type, + ); + } + } + + return { + ...currentState, + comments_by_entity_id: { + ...currentState.comments_by_entity_id, + [forId]: { + ...entityState, + comments: newComments, + }, + }, + }; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-updated.ts b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-updated.ts new file mode 100644 index 00000000..8f099ef4 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/comment/handle-comment-updated.ts @@ -0,0 +1,35 @@ +import { Feed } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleCommentUpdated( + this: Feed, + event: EventPayload<'feeds.comment.updated'>, +) { + const { comment } = event; + const entityId = comment.parent_id ?? comment.object_id; + + this.state.next((currentState) => { + const entityState = currentState.comments_by_entity_id[entityId]; + + if (!entityState?.comments?.length) return currentState; + + const index = this.getCommentIndex(comment, currentState); + + if (index === -1) return currentState; + + const newComments = [...entityState.comments]; + + newComments[index] = comment; + + return { + ...currentState, + comments_by_entity_id: { + ...currentState.comments_by_entity_id, + [entityId]: { + ...currentState.comments_by_entity_id[entityId], + comments: newComments, + }, + }, + }; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/comment/index.ts b/packages/feeds-client/src/feed/event-handlers/comment/index.ts new file mode 100644 index 00000000..bc855cba --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/comment/index.ts @@ -0,0 +1,4 @@ +export * from './handle-comment-added'; +export * from './handle-comment-deleted'; +export * from './handle-comment-updated'; +export * from './handle-comment-reaction'; \ No newline at end of file diff --git a/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-added.ts b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-added.ts new file mode 100644 index 00000000..40f8877b --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-added.ts @@ -0,0 +1,31 @@ +import { Feed, FeedState } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleFeedMemberAdded( + this: Feed, + event: EventPayload<'feeds.feed_member.added'>, +) { + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + + this.state.next((currentState) => { + let newState: FeedState | undefined; + + if (typeof currentState.members !== 'undefined') { + newState ??= { + ...currentState, + }; + + newState.members = [event.member, ...currentState.members]; + } + + if (connectedUser?.id === event.member.user.id) { + newState ??= { + ...currentState, + }; + + newState.own_membership = event.member; + } + + return newState ?? currentState; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-removed.ts b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-removed.ts new file mode 100644 index 00000000..f5d14207 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-removed.ts @@ -0,0 +1,24 @@ +import { Feed } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleFeedMemberRemoved( + this: Feed, + event: EventPayload<'feeds.feed_member.removed'>, +) { + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + + this.state.next((currentState) => { + const newState = { + ...currentState, + members: currentState.members?.filter( + (member) => member.user.id !== event.user?.id, + ), + }; + + if (connectedUser?.id === event.member_id) { + delete newState.own_membership; + } + + return newState; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-updated.ts b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-updated.ts new file mode 100644 index 00000000..4755bbb7 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed-member/handle-feed-member-updated.ts @@ -0,0 +1,40 @@ +import { Feed, FeedState } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleFeedMemberUpdated( + this: Feed, + event: EventPayload<'feeds.feed_member.updated'>, +) { + const { connected_user: connectedUser } = this.client.state.getLatestValue(); + + this.state.next((currentState) => { + const memberIndex = + currentState.members?.findIndex( + (member) => member.user.id === event.member.user.id, + ) ?? -1; + + let newState: FeedState | undefined; + + if (memberIndex !== -1) { + // if there's an index, there's a member to update + const newMembers = [...currentState.members!]; + newMembers[memberIndex] = event.member; + + newState ??= { + ...currentState, + }; + + newState.members = newMembers; + } + + if (connectedUser?.id === event.member.user.id) { + newState ??= { + ...currentState, + }; + + newState.own_membership = event.member; + } + + return newState ?? currentState; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/feed-member/index.ts b/packages/feeds-client/src/feed/event-handlers/feed-member/index.ts new file mode 100644 index 00000000..fe0e0887 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed-member/index.ts @@ -0,0 +1,3 @@ +export * from './handle-feed-member-added'; +export * from './handle-feed-member-updated'; +export * from './handle-feed-member-removed'; diff --git a/packages/feeds-client/src/feed/event-handlers/feed/handle-feed-updated.ts b/packages/feeds-client/src/feed/event-handlers/feed/handle-feed-updated.ts new file mode 100644 index 00000000..326067a5 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed/handle-feed-updated.ts @@ -0,0 +1,9 @@ +import { Feed } from '../../../feed'; +import { EventPayload } from '../../../types-internal'; + +export function handleFeedUpdated( + this: Feed, + event: EventPayload<'feeds.feed.updated'>, +) { + this.state.partialNext({ ...event.feed }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/feed/index.ts b/packages/feeds-client/src/feed/event-handlers/feed/index.ts new file mode 100644 index 00000000..3f13fd0a --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/feed/index.ts @@ -0,0 +1 @@ +export * from './handle-feed-updated'; diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.test.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.test.ts new file mode 100644 index 00000000..8df9cdea --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.test.ts @@ -0,0 +1,246 @@ +import { FeedResponse, FollowResponse, UserResponse } from '../../../gen/models'; +import { generateFollowResponse } from '../../../test-utils'; +import { updateStateFollowCreated } from './handle-follow-created'; + +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('handle-follow-created', () => { + describe(updateStateFollowCreated.name, () => { + let mockFollow: FollowResponse; + let mockFeed: FeedResponse; + let mockUser: UserResponse; + + beforeEach(() => { + mockFollow = generateFollowResponse(); + mockFeed = mockFollow.source_feed; + mockUser = mockFeed.created_by; + }); + + it('should return unchanged state for non-accepted follows', () => { + const follow: FollowResponse = { + ...mockFollow, + status: 'pending', + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [], + following: [], + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(false); + }); + + it('should handle when this feed follows someone', () => { + const follow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'feed-x', + fid: 'user:feed-x', + created_by: { + ...mockUser, + id: 'user-x', + }, + following_count: 1, + }, + target_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: mockUser, + }, + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + following: [], + following_count: 0, + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-x', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.following).toHaveLength(1); + expect(result.data.following?.[0]).toEqual(follow); + expect(result.data).toMatchObject(follow.source_feed); + expect(result.data.own_follows).toBeUndefined(); + expect(result.data.following_count).toEqual(1); + }); + + it('should handle when someone follows this feed', () => { + const follow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: { + ...mockUser, + id: 'other-user', + }, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + follower_count: 1, + }, + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [], + follower_count: 0, + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toHaveLength(1); + expect(result.data.followers?.[0]).toEqual(follow); + expect(result.data).toMatchObject(follow.target_feed); + expect(result.data.own_follows).toBeUndefined(); + expect(result.data.follower_count).toEqual(1); + }); + + it('should add to own_follows when connected user is the source', () => { + const follow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: { ...mockUser, id: 'user-1' }, + }, + target_feed: { + ...mockFeed, + id: 'feed-x', + fid: 'user:feed-x', + created_by: { + ...mockUser, + id: 'user-x', + }, + }, + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [], + own_follows: [], + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-x', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.own_follows).toHaveLength(1); + expect(result.data.own_follows?.[0]).toEqual(follow); + }); + + it('should not update followers/following when they are undefined', () => { + const follow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: undefined, + following: undefined, + own_follows: undefined, + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toBeUndefined(); + expect(result.data).toMatchObject(follow.target_feed); + }); + + it('should add new followers to the top of existing arrays', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'existing-feed', + fid: 'user:existing-feed', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [existingFollow], + following: undefined, + own_follows: undefined, + }; + + const result = updateStateFollowCreated( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toHaveLength(2); + expect(result.data.followers?.[0]).toEqual(follow); + expect(result.data.followers?.[1]).toEqual(existingFollow); + }); + }); +}); diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.ts new file mode 100644 index 00000000..f5f1de47 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-created.ts @@ -0,0 +1,93 @@ +import type { Feed, FeedState } from '../../../feed'; +import type { FollowResponse } from '../../../gen/models'; +import type { + EventPayload, + PartializeAllBut, + UpdateStateResult, +} from '../../../types-internal'; +import { + getStateUpdateQueueId, + shouldUpdateState, +} from '../../../utils'; + +export const updateStateFollowCreated = ( + follow: FollowResponse, + currentState: FeedState, + currentFeedId: string, + connectedUserId?: string, +): UpdateStateResult<{ data: FeedState }> => { + // filter non-accepted follows (the way getOrCreate does by default) + if (follow.status !== 'accepted') { + return { changed: false, data: currentState }; + } + + let newState: FeedState = { ...currentState }; + + // this feed followed someone + if (follow.source_feed.fid === currentFeedId) { + newState = { + ...newState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.source_feed, + }; + + // Only update if following array already exists + if (currentState.following !== undefined) { + newState.following = [follow, ...currentState.following]; + } + } else if ( + // someone followed this feed + follow.target_feed.fid === currentFeedId + ) { + const source = follow.source_feed; + + newState = { + ...newState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.target_feed, + }; + + if (source.created_by.id === connectedUserId) { + newState.own_follows = currentState.own_follows + ? currentState.own_follows.concat(follow) + : [follow]; + } + + // Only update if followers array already exists + if (currentState.followers !== undefined) { + newState.followers = [follow, ...currentState.followers]; + } + } + + return { changed: true, data: newState }; +}; + +export function handleFollowCreated( + this: Feed, + eventOrResponse: PartializeAllBut< + EventPayload<'feeds.follow.created'>, + 'follow' + >, +) { + const follow = eventOrResponse.follow; + + if ( + !shouldUpdateState({ + stateUpdateQueueId: getStateUpdateQueueId(follow, 'created'), + stateUpdateQueue: this.stateUpdateQueue, + watch: this.currentState.watch, + }) + ) { + return; + } + const connectedUser = this.client.state.getLatestValue().connected_user; + const result = updateStateFollowCreated( + follow, + this.currentState, + this.fid, + connectedUser?.id, + ); + if (result.changed) { + this.state.next(result.data); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.test.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.test.ts new file mode 100644 index 00000000..bd9a5736 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.test.ts @@ -0,0 +1,264 @@ +import { FollowResponse, FeedResponse, UserResponse } from '../../../gen/models'; +import { generateFollowResponse } from '../../../test-utils'; +import { updateStateFollowDeleted } from './handle-follow-deleted'; + +import { describe, it, expect, beforeEach } from 'vitest'; + +describe('handle-follow-deleted', () => { + describe(updateStateFollowDeleted.name, () => { + let mockFollow: FollowResponse; + let mockFeed: FeedResponse; + let mockUser: UserResponse; + + beforeEach(() => { + mockFollow = generateFollowResponse(); + mockFeed = mockFollow.source_feed; + mockUser = mockFeed.created_by; + }); + + it('should handle when this feed unfollows someone', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = existingFollow; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + following: [existingFollow], + following_count: 1, + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.following).toHaveLength(0); + expect(result.data).toMatchObject(follow.source_feed); + }); + + it('should handle when someone unfollows this feed', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: { + ...mockUser, + id: 'other-user', + }, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = existingFollow; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [existingFollow], + own_follows: [existingFollow], + following_count: 1, + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toHaveLength(0); + expect(result.data.own_follows).toEqual(currentState.own_follows); + expect(result.data).toMatchObject(follow.target_feed); + }); + + it('should only remove own_follows when connected user is the source', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: { ...mockUser, id: 'user-1' }, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = existingFollow; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [existingFollow], + own_follows: [existingFollow], + following_count: 1, + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toHaveLength(0); + expect(result.data.own_follows).toHaveLength(0); + }); + + it('should not remove own_follows when connected user is not the source', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: { ...mockUser, id: 'other-user' }, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = existingFollow; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: [existingFollow], + own_follows: [existingFollow], + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toHaveLength(0); + expect(result.data.own_follows).toHaveLength(1); // Should remain unchanged + }); + + it('should not update followers/following when they are undefined in delete', () => { + const existingFollow: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'other-feed', + fid: 'user:other-feed', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = existingFollow; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + followers: undefined, + own_follows: undefined, + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.followers).toBeUndefined(); + expect(result.data.own_follows).toBeUndefined(); + expect(result.data).toMatchObject(follow.target_feed); + }); + + it('should filter out the correct follow by target feed id', () => { + const followToRemove: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'target-to-remove', + fid: 'user:target-to-remove', + created_by: mockUser, + }, + }; + + const followToKeep: FollowResponse = { + ...mockFollow, + source_feed: { + ...mockFeed, + id: 'feed-1', + fid: 'user:feed-1', + created_by: mockUser, + }, + target_feed: { + ...mockFeed, + id: 'target-to-keep', + fid: 'user:target-to-keep', + created_by: mockUser, + }, + }; + + const follow: FollowResponse = followToRemove; + + // @ts-expect-error - we're not testing the full state here + const currentState: FeedState = { + following: [followToRemove, followToKeep], + following_count: 2, + }; + + const result = updateStateFollowDeleted( + follow, + currentState, + 'user:feed-1', + 'user-1', + ); + + expect(result.changed).toBe(true); + expect(result.data.following).toHaveLength(1); + expect(result.data.following?.[0]).toEqual(followToKeep); + }); + }); +}); diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.ts new file mode 100644 index 00000000..80a88450 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-deleted.ts @@ -0,0 +1,95 @@ +import type { Feed, FeedState } from '../../../feed'; +import type { FollowResponse } from '../../../gen/models'; +import type { + EventPayload, + PartializeAllBut, + UpdateStateResult, +} from '../../../types-internal'; + +import { getStateUpdateQueueId, shouldUpdateState } from '../../../utils'; + +export const updateStateFollowDeleted = ( + follow: FollowResponse, + currentState: FeedState, + currentFeedId: string, + connectedUserId?: string, +): UpdateStateResult<{ data: FeedState }> => { + let newState: FeedState = { ...currentState }; + + // this feed unfollowed someone + if (follow.source_feed.fid === currentFeedId) { + newState = { + ...newState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.source_feed, + }; + + // Only update if following array already exists + if (currentState.following !== undefined) { + newState.following = currentState.following.filter( + (followItem) => followItem.target_feed.fid !== follow.target_feed.fid, + ); + } + } else if ( + // someone unfollowed this feed + follow.target_feed.fid === currentFeedId + ) { + const source = follow.source_feed; + + newState = { + ...newState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.target_feed, + }; + + if ( + source.created_by.id === connectedUserId && + currentState.own_follows !== undefined + ) { + newState.own_follows = currentState.own_follows.filter( + (followItem) => followItem.source_feed.fid !== follow.source_feed.fid, + ); + } + + // Only update if followers array already exists + if (currentState.followers !== undefined) { + newState.followers = currentState.followers.filter( + (followItem) => followItem.source_feed.fid !== follow.source_feed.fid, + ); + } + } + + return { changed: true, data: newState }; +}; + +export function handleFollowDeleted( + this: Feed, + eventOrResponse: PartializeAllBut< + EventPayload<'feeds.follow.deleted'>, + 'follow' + >, +) { + const follow = eventOrResponse.follow; + + if ( + !shouldUpdateState({ + stateUpdateQueueId: getStateUpdateQueueId(follow, 'deleted'), + stateUpdateQueue: this.stateUpdateQueue, + watch: this.currentState.watch, + }) + ) { + return; + } + + const connectedUser = this.client.state.getLatestValue().connected_user; + const result = updateStateFollowDeleted( + follow, + this.currentState, + this.fid, + connectedUser?.id, + ); + + if (result.changed) { + this.state.next(result.data); + } +} diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.test.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.test.ts new file mode 100644 index 00000000..5847b4f2 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Feed } from '../../../feed'; +import { FeedsClient } from '../../../feeds-client'; +import { handleFollowUpdated } from './handle-follow-updated'; +import { + generateFollowResponse, + generateFeedResponse, + getHumanId, + generateOwnUser, +} from '../../../test-utils/response-generators'; +import { FollowResponse } from '../../../gen/models'; +import { shouldUpdateState } from '../../../utils'; + +describe(handleFollowUpdated.name, () => { + let feed: Feed; + let client: FeedsClient; + let follow: FollowResponse; + let otherFollow: FollowResponse; + let ownFollow: FollowResponse; + let userId: string; + + beforeEach(() => { + userId = getHumanId(); + client = new FeedsClient('mock-api-key'); + + client.state.partialNext({ + connected_user: generateOwnUser({ id: userId }), + }); + + const feedResponse = generateFeedResponse({ + id: 'main', + group_id: 'user', + created_by: { id: userId }, + }); + feed = new Feed(client, 'user', 'main', feedResponse); + // Setup follows + follow = generateFollowResponse({ + source_feed: generateFeedResponse({ + id: 'main', + group_id: 'user', + created_by: { id: userId }, + }), + target_feed: generateFeedResponse({ + id: 'target', + group_id: 'user', + }), + }); + + otherFollow = generateFollowResponse({ + source_feed: generateFeedResponse({ + id: 'other', + group_id: 'user', + created_by: { id: getHumanId() }, + }), + target_feed: generateFeedResponse({ + id: 'main', + group_id: 'user', + }), + }); + + ownFollow = generateFollowResponse({ + source_feed: generateFeedResponse({ + id: 'other', + group_id: 'user', + created_by: { id: userId }, + }), + target_feed: generateFeedResponse({ + id: 'main', + group_id: 'user', + }), + }); + // Set up initial state + feed.state.next((currentState) => ({ + ...currentState, + following: [follow], + followers: [otherFollow], + own_follows: [ownFollow], + })); + }); + + it('updates a follow in following when this feed is the source', () => { + const updatedFollow: FollowResponse = { ...follow, status: 'pending' }; + + handleFollowUpdated.call(feed, { follow: updatedFollow }); + + const [updatedFollowAfter] = feed.currentState.following!; + + expect(updatedFollowAfter).toBe(updatedFollow); + }); + + it('updates a follow in followers when this feed is the target', () => { + const updatedOtherFollow: FollowResponse = { + ...otherFollow, + status: 'rejected', + }; + + handleFollowUpdated.call(feed, { follow: updatedOtherFollow }); + + const [updatedOtherFollowAfter] = feed.currentState.followers!; + + expect(updatedOtherFollowAfter).toBe(updatedOtherFollow); + }); + + it('updates a follow in own_follows when connected user is the creator', () => { + const updatedOwnFollow: FollowResponse = { + ...ownFollow, + status: 'pending', + }; + + handleFollowUpdated.call(feed, { follow: updatedOwnFollow }); + + const [ownFollowAfter] = feed.currentState.own_follows!; + + expect(ownFollowAfter).toBe(updatedOwnFollow); + }); + + it('does not update if follow is not found', () => { + const unrelatedFollow = generateFollowResponse(); + + const stateBefore = feed.currentState; + + handleFollowUpdated.call(feed, { follow: unrelatedFollow }); + + const stateAfter = feed.currentState; + + expect(stateAfter.own_follows).toBe(stateBefore.own_follows); + expect(stateAfter.followers).toBe(stateBefore.followers); + expect(stateAfter.follower_count).toBe(stateBefore.follower_count); + expect(stateAfter.following).toBe(stateBefore.following); + expect(stateAfter.following_count).toBe(stateBefore.following_count); + }); + + describe(`${shouldUpdateState.name} integration`, () => { + it(`skips update if ${shouldUpdateState.name} returns false`, () => { + // Prepare feed, set as watched + feed.state.partialNext({ watch: true }); + + const updatedFollow: FollowResponse = { ...follow, status: 'pending' }; + + // Call once to populate queue + handleFollowUpdated.call(feed, { follow: updatedFollow }); + + // Call again, should be skipped + const stateBefore = feed.currentState; + handleFollowUpdated.call(feed, { + follow: { ...updatedFollow, status: 'accepted' }, + }); + + // State should not change + const stateAfter = feed.currentState; + expect(stateAfter).toEqual(stateBefore); + }); + + it('allows update again after clearing stateUpdateQueue', () => { + const updatedFollow: FollowResponse = { ...follow, status: 'pending' }; + + handleFollowUpdated.call(feed, { follow: updatedFollow }); + + // Clear the queue + (feed as any).stateUpdateQueue.clear(); + + // Now update should be allowed + handleFollowUpdated.call(feed, { + follow: { ...updatedFollow, status: 'accepted' }, + }); + + const [updatedFollowAfter] = feed.currentState.following!; + + expect(updatedFollowAfter).toMatchObject({ + status: 'accepted', + }); + }); + }); +}); diff --git a/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.ts b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.ts new file mode 100644 index 00000000..1c8483d2 --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/handle-follow-updated.ts @@ -0,0 +1,88 @@ +import type { Feed, FeedState } from '../../../feed'; +import { + getStateUpdateQueueId, + shouldUpdateState, +} from '../../../utils'; +import { EventPayload, PartializeAllBut } from '../../../types-internal'; + +export function handleFollowUpdated( + this: Feed, + eventOrResponse: PartializeAllBut< + EventPayload<'feeds.follow.updated'>, + 'follow' + >, +) { + const follow = eventOrResponse.follow; + const connectedUserId = this.client.state.getLatestValue().connected_user?.id; + const currentFeedId = this.fid; + + if ( + !shouldUpdateState({ + stateUpdateQueueId: getStateUpdateQueueId(follow, 'updated'), + stateUpdateQueue: this.stateUpdateQueue, + watch: this.currentState.watch, + }) + ) { + return; + } + + this.state.next((currentState) => { + let newState: FeedState | undefined; + + // this feed followed someone + if (follow.source_feed.fid === currentFeedId) { + newState ??= { + ...currentState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.source_feed, + }; + + const index = + currentState.following?.findIndex( + (f) => f.target_feed.fid === follow.target_feed.fid, + ) ?? -1; + + if (index >= 0) { + newState.following = [...newState.following!]; + newState.following[index] = follow; + } + } else if ( + // someone followed this feed + follow.target_feed.fid === currentFeedId + ) { + const source = follow.source_feed; + + newState ??= { + ...currentState, + // Update FeedResponse fields, that has the new follower/following count + ...follow.target_feed, + }; + + if ( + source.created_by.id === connectedUserId && + currentState.own_follows + ) { + const index = currentState.own_follows.findIndex( + (f) => f.source_feed.fid === follow.source_feed.fid, + ); + + if (index >= 0) { + newState.own_follows = [...currentState.own_follows]; + newState.own_follows[index] = follow; + } + } + + const index = + currentState.followers?.findIndex( + (f) => f.source_feed.fid === follow.source_feed.fid, + ) ?? -1; + + if (index >= 0) { + newState.followers = [...newState.followers!]; + newState.followers[index] = follow; + } + } + + return newState ?? currentState; + }); +} diff --git a/packages/feeds-client/src/feed/event-handlers/follow/index.ts b/packages/feeds-client/src/feed/event-handlers/follow/index.ts new file mode 100644 index 00000000..ee6f5ccc --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/follow/index.ts @@ -0,0 +1,3 @@ +export * from './handle-follow-created'; +export * from './handle-follow-deleted'; +export * from './handle-follow-updated'; \ No newline at end of file diff --git a/packages/feeds-client/src/feed/event-handlers/index.ts b/packages/feeds-client/src/feed/event-handlers/index.ts new file mode 100644 index 00000000..a376fd4d --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/index.ts @@ -0,0 +1,7 @@ +export * from './follow'; +export * from './comment'; +export * from './feed-member'; +export * from './bookmark'; +export * from './activity'; +export * from './feed'; +export * from './notification-feed'; \ No newline at end of file diff --git a/packages/feeds-client/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.ts b/packages/feeds-client/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.ts new file mode 100644 index 00000000..846fcf2a --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/notification-feed/handle-notification-feed-updated.ts @@ -0,0 +1,10 @@ +import type { Feed } from '../../../feed'; +import type { EventPayload } from '../../../types-internal'; + +export function handleNotificationFeedUpdated( + this: Feed, + event: EventPayload<'feeds.notification_feed.updated'>, +) { + console.info('notification feed updated', event); + // TODO: handle notification feed updates +} diff --git a/packages/feeds-client/src/feed/event-handlers/notification-feed/index.ts b/packages/feeds-client/src/feed/event-handlers/notification-feed/index.ts new file mode 100644 index 00000000..486c813e --- /dev/null +++ b/packages/feeds-client/src/feed/event-handlers/notification-feed/index.ts @@ -0,0 +1 @@ +export * from './handle-notification-feed-updated' \ No newline at end of file diff --git a/packages/feeds-client/src/Feed.ts b/packages/feeds-client/src/feed/feed.ts similarity index 59% rename from packages/feeds-client/src/Feed.ts rename to packages/feeds-client/src/feed/feed.ts index 5d574342..6e8c425f 100644 --- a/packages/feeds-client/src/Feed.ts +++ b/packages/feeds-client/src/feed/feed.ts @@ -9,54 +9,48 @@ import { CommentResponse, PagerResponse, SingleFollowRequest, - CommentReactionAddedEvent, - CommentReactionDeletedEvent, - BookmarkAddedEvent, - BookmarkDeletedEvent, - BookmarkUpdatedEvent, QueryFeedMembersRequest, SortParamRequest, - FollowResponse, ThreadedCommentResponse, -} from './gen/models'; -import { StateStore } from './common/StateStore'; -import { EventDispatcher } from './common/EventDispatcher'; -import { FeedApi } from './gen/feeds/FeedApi'; -import { FeedsClient } from './FeedsClient'; -import { - addActivitiesToState, - updateActivityInState, - removeActivityFromState, -} from './state-updates/activity-utils'; -import { - addReactionToActivities, - removeReactionFromActivities, -} from './state-updates/activity-reaction-utils'; -import { - addBookmarkToActivities, - removeBookmarkFromActivities, - updateBookmarkInActivities, -} from './state-updates/bookmark-utils'; +} from '../gen/models'; +import { StreamResponse } from '../gen-imports'; +import { StateStore } from '../common/StateStore'; +import { EventDispatcher } from '../common/EventDispatcher'; +import { FeedApi } from '../gen/feeds/FeedApi'; +import { FeedsClient } from '../feeds-client'; import { + handleFollowUpdated, handleFollowCreated, handleFollowDeleted, - handleFollowUpdated, -} from './state-updates/follow-utils'; -import { StreamResponse } from './gen-imports'; -import { capitalize } from './common/utils'; + handleCommentAdded, + handleCommentDeleted, + handleCommentUpdated, + handleBookmarkDeleted, + handleBookmarkUpdated, + handleActivityAdded, + addActivitiesToState, + handleActivityUpdated, + handleFeedMemberAdded, + handleFeedMemberRemoved, + handleFeedMemberUpdated, + handleCommentReaction, + handleBookmarkAdded, + handleActivityDeleted, + handleActivityRemovedFromFeed, + handleActivityReactionDeleted, + handleActivityReactionAdded, + handleFeedUpdated, + handleNotificationFeedUpdated, +} from './event-handlers'; +import { capitalize } from '../common/utils'; import type { ActivityIdOrCommentId, GetCommentsRepliesRequest, GetCommentsRequest, LoadingStates, PagerResponseWithLoadingStates, -} from './types'; -import { checkHasAnotherPage, Constants, uniqueArrayMerge } from './utils'; -import { - getStateUpdateQueueIdForFollow, - getStateUpdateQueueIdForUnfollow, - shouldUpdateState, -} from './state-updates/state-update-queue'; +} from '../types'; +import { checkHasAnotherPage, Constants, uniqueArrayMerge } from '../utils'; export type FeedState = Omit< Partial, @@ -143,295 +137,40 @@ type EventHandlerByEventType = { export class Feed extends FeedApi { readonly state: StateStore; private static readonly noop = () => {}; - private readonly stateUpdateQueue: Set = new Set(); + protected readonly stateUpdateQueue: Set = new Set(); private readonly eventHandlers: EventHandlerByEventType = { - 'feeds.activity.added': (event) => { - const currentActivities = this.currentState.activities; - const result = addActivitiesToState( - [event.activity], - currentActivities, - 'start', - ); - if (result.changed) { - this.client.hydratePollCache([event.activity]); - this.state.partialNext({ activities: result.activities }); - } - }, - 'feeds.activity.deleted': (event) => { - const currentActivities = this.currentState.activities; - if (currentActivities) { - const result = removeActivityFromState( - event.activity, - currentActivities, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - } - }, - 'feeds.activity.reaction.added': (event) => { - const currentActivities = this.currentState.activities; - const connectedUser = this.client.state.getLatestValue().connected_user; - const isCurrentUser = Boolean( - connectedUser && event.reaction.user.id === connectedUser.id, - ); - - const result = addReactionToActivities( - event, - currentActivities, - isCurrentUser, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - }, - 'feeds.activity.reaction.deleted': (event) => { - const currentActivities = this.currentState.activities; - const connectedUser = this.client.state.getLatestValue().connected_user; - const isCurrentUser = Boolean( - connectedUser && event.reaction.user.id === connectedUser.id, - ); - - const result = removeReactionFromActivities( - event, - currentActivities, - isCurrentUser, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - }, + 'feeds.activity.added': handleActivityAdded.bind(this), + 'feeds.activity.deleted': handleActivityDeleted.bind(this), + 'feeds.activity.reaction.added': handleActivityReactionAdded.bind(this), + 'feeds.activity.reaction.deleted': handleActivityReactionDeleted.bind(this), 'feeds.activity.reaction.updated': Feed.noop, - 'feeds.activity.removed_from_feed': (event) => { - const currentActivities = this.currentState.activities; - if (currentActivities) { - const result = removeActivityFromState( - event.activity, - currentActivities, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - } - }, - 'feeds.activity.updated': (event) => { - const currentActivities = this.currentState.activities; - if (currentActivities) { - const result = updateActivityInState(event.activity, currentActivities); - if (result.changed) { - this.client.hydratePollCache([event.activity]); - this.state.partialNext({ activities: result.activities }); - } - } - }, - 'feeds.bookmark.added': this.handleBookmarkAdded.bind(this), - 'feeds.bookmark.deleted': this.handleBookmarkDeleted.bind(this), - 'feeds.bookmark.updated': this.handleBookmarkUpdated.bind(this), + 'feeds.activity.removed_from_feed': + handleActivityRemovedFromFeed.bind(this), + 'feeds.activity.updated': handleActivityUpdated.bind(this), + 'feeds.bookmark.added': handleBookmarkAdded.bind(this), + 'feeds.bookmark.deleted': handleBookmarkDeleted.bind(this), + 'feeds.bookmark.updated': handleBookmarkUpdated.bind(this), 'feeds.bookmark_folder.deleted': Feed.noop, 'feeds.bookmark_folder.updated': Feed.noop, - 'feeds.comment.added': (event) => { - const { comment } = event; - const entityId = comment.parent_id ?? comment.object_id; - - this.state.next((currentState) => { - const entityState = currentState.comments_by_entity_id[entityId]; - - if (typeof entityState?.comments === 'undefined') { - return currentState; - } - - const newComments = entityState?.comments - ? [...entityState.comments] - : []; - - if (entityState.pagination?.sort === 'last') { - newComments.unshift(comment); - } else { - // 'first' and other sort options - newComments.push(comment); - } - - return { - ...currentState, - comments_by_entity_id: { - ...currentState.comments_by_entity_id, - [entityId]: { - ...currentState.comments_by_entity_id[entityId], - comments: newComments, - }, - }, - }; - }); - }, - 'feeds.comment.deleted': ({ comment }) => { - const entityId = comment.parent_id ?? comment.object_id; - - this.state.next((currentState) => { - const newCommentsByEntityId = { - ...currentState.comments_by_entity_id, - [entityId]: { - ...currentState.comments_by_entity_id[entityId], - }, - }; - - const index = this.getCommentIndex(comment, currentState); - - if ( - newCommentsByEntityId?.[entityId]?.comments?.length && - index !== -1 - ) { - newCommentsByEntityId[entityId].comments = [ - ...newCommentsByEntityId[entityId].comments, - ]; - - newCommentsByEntityId[entityId]?.comments?.splice(index, 1); - } - - delete newCommentsByEntityId[comment.id]; - - return { - ...currentState, - comments_by_entity_id: newCommentsByEntityId, - }; - }); - }, - 'feeds.comment.updated': (event) => { - const { comment } = event; - const entityId = comment.parent_id ?? comment.object_id; - - this.state.next((currentState) => { - const entityState = currentState.comments_by_entity_id[entityId]; - - if (!entityState?.comments?.length) return currentState; - - const index = this.getCommentIndex(comment, currentState); - - if (index === -1) return currentState; - - const newComments = [...entityState.comments]; - - newComments[index] = comment; - - return { - ...currentState, - comments_by_entity_id: { - ...currentState.comments_by_entity_id, - [entityId]: { - ...currentState.comments_by_entity_id[entityId], - comments: newComments, - }, - }, - }; - }); - }, + 'feeds.comment.added': handleCommentAdded.bind(this), + 'feeds.comment.deleted': handleCommentDeleted.bind(this), + 'feeds.comment.updated': handleCommentUpdated.bind(this), 'feeds.feed.created': Feed.noop, 'feeds.feed.deleted': Feed.noop, - 'feeds.feed.updated': (event) => { - this.state.partialNext({ ...event.feed }); - }, + 'feeds.feed.updated': handleFeedUpdated.bind(this), 'feeds.feed_group.changed': Feed.noop, 'feeds.feed_group.deleted': Feed.noop, - 'feeds.follow.created': (event) => { - this.handleFollowCreated(event.follow); - }, - 'feeds.follow.deleted': (event) => { - this.handleFollowDeleted(event.follow); - }, - 'feeds.follow.updated': (_event) => { - const result = handleFollowUpdated(this.currentState); - if (result.changed) { - this.state.next(result.data); - } - }, - 'feeds.comment.reaction.added': this.handleCommentReactionEvent.bind(this), - 'feeds.comment.reaction.deleted': - this.handleCommentReactionEvent.bind(this), + 'feeds.follow.created': handleFollowCreated.bind(this), + 'feeds.follow.deleted': handleFollowDeleted.bind(this), + 'feeds.follow.updated': handleFollowUpdated.bind(this), + 'feeds.comment.reaction.added': handleCommentReaction.bind(this), + 'feeds.comment.reaction.deleted': handleCommentReaction.bind(this), 'feeds.comment.reaction.updated': Feed.noop, - 'feeds.feed_member.added': (event) => { - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - - this.state.next((currentState) => { - let newState: FeedState | undefined; - - if (typeof currentState.members !== 'undefined') { - newState ??= { - ...currentState, - }; - - newState.members = [event.member, ...currentState.members]; - } - - if (connectedUser?.id === event.member.user.id) { - newState ??= { - ...currentState, - }; - - newState.own_membership = event.member; - } - - return newState ?? currentState; - }); - }, - 'feeds.feed_member.removed': (event) => { - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - - this.state.next((currentState) => { - const newState = { - ...currentState, - members: currentState.members?.filter( - (member) => member.user.id !== event.user?.id, - ), - }; - - if (connectedUser?.id === event.member_id) { - delete newState.own_membership; - } - - return newState; - }); - }, - 'feeds.feed_member.updated': (event) => { - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - - this.state.next((currentState) => { - const memberIndex = - currentState.members?.findIndex( - (member) => member.user.id === event.member.user.id, - ) ?? -1; - - let newState: FeedState | undefined; - - if (memberIndex !== -1) { - // if there's an index, there's a member to update - const newMembers = [...currentState.members!]; - newMembers[memberIndex] = event.member; - - newState ??= { - ...currentState, - }; - - newState.members = newMembers; - } - - if (connectedUser?.id === event.member.user.id) { - newState ??= { - ...currentState, - }; - - newState.own_membership = event.member; - } - - return newState ?? currentState; - }); - }, - 'feeds.notification_feed.updated': (event) => { - console.info('notification feed updated', event); - // TODO: handle notification feed updates - }, + 'feeds.feed_member.added': handleFeedMemberAdded.bind(this), + 'feeds.feed_member.removed': handleFeedMemberRemoved.bind(this), + 'feeds.feed_member.updated': handleFeedMemberUpdated.bind(this), + 'feeds.notification_feed.updated': handleNotificationFeedUpdated.bind(this), // the poll events should be removed from here 'feeds.poll.closed': Feed.noop, 'feeds.poll.deleted': Feed.noop, @@ -478,7 +217,7 @@ export class Feed extends FeedApi { this.client = client; } - private readonly client: FeedsClient; + protected readonly client: FeedsClient; get fid() { return `${this.group}:${this.id}`; @@ -488,63 +227,6 @@ export class Feed extends FeedApi { return this.state.getLatestValue(); } - private handleCommentReactionEvent( - event: (CommentReactionAddedEvent | CommentReactionDeletedEvent) & { - type: 'feeds.comment.reaction.added' | 'feeds.comment.reaction.deleted'; - }, - ) { - const { comment, reaction } = event; - const connectedUser = this.client.state.getLatestValue().connected_user; - - this.state.next((currentState) => { - const forId = comment.parent_id ?? comment.object_id; - const entityState = currentState.comments_by_entity_id[forId]; - - const commentIndex = this.getCommentIndex(comment, currentState); - - if (commentIndex === -1) return currentState; - - const newComments = entityState?.comments?.concat([]) ?? []; - - const commentCopy: Partial = { ...comment }; - - delete commentCopy.own_reactions; - - const newComment: CommentResponse = { - ...newComments[commentIndex], - ...commentCopy, - // TODO: FIXME this should be handled by the backend - latest_reactions: commentCopy.latest_reactions ?? [], - reaction_groups: commentCopy.reaction_groups ?? {}, - }; - - newComments[commentIndex] = newComment; - - if (reaction.user.id === connectedUser?.id) { - if (event.type === 'feeds.comment.reaction.added') { - newComment.own_reactions = newComment.own_reactions.concat( - reaction, - ) ?? [reaction]; - } else if (event.type === 'feeds.comment.reaction.deleted') { - newComment.own_reactions = newComment.own_reactions.filter( - (r) => r.type !== reaction.type, - ); - } - } - - return { - ...currentState, - comments_by_entity_id: { - ...currentState.comments_by_entity_id, - [forId]: { - ...entityState, - comments: newComments, - }, - }, - }; - }); - } - async synchronize() { const { last_get_or_create_request_config } = this.state.getLatestValue(); if (last_get_or_create_request_config?.watch) { @@ -634,57 +316,6 @@ export class Feed extends FeedApi { } } - /** - * @internal - */ - handleFollowCreated(follow: FollowResponse) { - if ( - !shouldUpdateState({ - stateUpdateId: getStateUpdateQueueIdForFollow(follow), - stateUpdateQueue: this.stateUpdateQueue, - watch: this.currentState.watch, - }) - ) { - return; - } - const connectedUser = this.client.state.getLatestValue().connected_user; - const result = handleFollowCreated( - follow, - this.currentState, - this.fid, - connectedUser?.id, - ); - if (result.changed) { - this.state.next(result.data); - } - } - - /** - * @internal - */ - handleFollowDeleted(follow: FollowResponse) { - if ( - !shouldUpdateState({ - stateUpdateId: getStateUpdateQueueIdForUnfollow(follow), - stateUpdateQueue: this.stateUpdateQueue, - watch: this.currentState.watch, - }) - ) { - return; - } - - const connectedUser = this.client.state.getLatestValue().connected_user; - const result = handleFollowDeleted( - follow, - this.currentState, - this.fid, - connectedUser?.id, - ); - if (result.changed) { - this.state.next(result.data); - } - } - /** * @internal */ @@ -703,58 +334,10 @@ export class Feed extends FeedApi { }); } - private handleBookmarkAdded(event: BookmarkAddedEvent) { - const currentActivities = this.currentState.activities; - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - const isCurrentUser = event.bookmark.user.id === connectedUser?.id; - - const result = addBookmarkToActivities( - event, - currentActivities, - isCurrentUser, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - } - - private handleBookmarkDeleted(event: BookmarkDeletedEvent) { - const currentActivities = this.currentState.activities; - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - const isCurrentUser = event.bookmark.user.id === connectedUser?.id; - - const result = removeBookmarkFromActivities( - event, - currentActivities, - isCurrentUser, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - } - - private handleBookmarkUpdated(event: BookmarkUpdatedEvent) { - const currentActivities = this.currentState.activities; - const { connected_user: connectedUser } = - this.client.state.getLatestValue(); - const isCurrentUser = event.bookmark.user.id === connectedUser?.id; - - const result = updateBookmarkInActivities( - event, - currentActivities, - isCurrentUser, - ); - if (result.changed) { - this.state.partialNext({ activities: result.activities }); - } - } - /** * Returns index of the provided comment object. */ - private getCommentIndex( + protected getCommentIndex( comment: Pick, state?: FeedState, ) { diff --git a/packages/feeds-client/src/feed/index.ts b/packages/feeds-client/src/feed/index.ts new file mode 100644 index 00000000..d8760b09 --- /dev/null +++ b/packages/feeds-client/src/feed/index.ts @@ -0,0 +1,2 @@ +export * from './feed'; +export * from './event-handlers'; diff --git a/packages/feeds-client/src/FeedsClient.ts b/packages/feeds-client/src/feeds-client.ts similarity index 94% rename from packages/feeds-client/src/FeedsClient.ts rename to packages/feeds-client/src/feeds-client.ts index 5b3fe107..da67740c 100644 --- a/packages/feeds-client/src/FeedsClient.ts +++ b/packages/feeds-client/src/feeds-client.ts @@ -11,6 +11,7 @@ import { QueryFeedsRequest, QueryPollVotesRequest, SingleFollowRequest, + UpdateFollowRequest, UserRequest, WSEvent, } from './gen/models'; @@ -27,14 +28,19 @@ import { streamDevToken, } from './common/utils'; import { decodeWSEvent } from './gen/model-decoders/event-decoder-mapping'; -import { Feed } from './Feed'; import { FeedsClientOptions, NetworkChangedEvent, StreamResponse, } from './common/types'; -import { ModerationClient } from './ModerationClient'; +import { ModerationClient } from './moderation-client'; import { StreamPoll } from './common/Poll'; +import { + Feed, + handleFollowCreated, + handleFollowDeleted, + handleFollowUpdated, +} from './feed'; export type FeedsClientState = { connected_user: OwnUser | undefined; @@ -363,6 +369,21 @@ export class FeedsClient extends FeedsApi { this.eventDispatcher.dispatch(networkEvent); }; + async updateFollow(request: UpdateFollowRequest) { + const response = await super.updateFollow(request); + + [response.follow.source_feed.fid, response.follow.target_feed.fid].forEach( + (fid) => { + const feed = this.activeFeeds[fid]; + if (feed) { + handleFollowUpdated.bind(feed)(response); + } + }, + ); + + return response; + } + // For follow API endpoints we update the state after HTTP response to allow queryFeeds with watch: false async follow(request: SingleFollowRequest) { const response = await super.follow(request); @@ -371,7 +392,7 @@ export class FeedsClient extends FeedsApi { (fid) => { const feed = this.activeFeeds[fid]; if (feed) { - feed.handleFollowCreated(response.follow); + handleFollowCreated.bind(feed)(response); } }, ); @@ -385,7 +406,7 @@ export class FeedsClient extends FeedsApi { response.follows.forEach((follow) => { const feed = this.activeFeeds[follow.source_feed.fid]; if (feed) { - feed.handleFollowCreated(follow); + handleFollowCreated.bind(feed)({ follow }); } }); @@ -398,7 +419,7 @@ export class FeedsClient extends FeedsApi { [request.source, request.target].forEach((fid) => { const feed = this.activeFeeds[fid]; if (feed) { - feed.handleFollowDeleted(response.follow); + handleFollowDeleted.bind(feed)(response); } }); diff --git a/packages/feeds-client/src/gen-imports.ts b/packages/feeds-client/src/gen-imports.ts index e37a8ed6..26ebdba7 100644 --- a/packages/feeds-client/src/gen-imports.ts +++ b/packages/feeds-client/src/gen-imports.ts @@ -1,3 +1,3 @@ export type { ApiClient } from './common/ApiClient'; export type { StreamResponse } from './common/types'; -export { FeedsClient as FeedsApi } from './FeedsClient'; +export { FeedsClient as FeedsApi } from './feeds-client'; diff --git a/packages/feeds-client/src/ModerationClient.ts b/packages/feeds-client/src/moderation-client.ts similarity index 100% rename from packages/feeds-client/src/ModerationClient.ts rename to packages/feeds-client/src/moderation-client.ts diff --git a/packages/feeds-client/src/state-updates/activity-reaction-utils.ts b/packages/feeds-client/src/state-updates/activity-reaction-utils.ts deleted file mode 100644 index 14372f99..00000000 --- a/packages/feeds-client/src/state-updates/activity-reaction-utils.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { - ActivityReactionAddedEvent, - ActivityReactionDeletedEvent, - ActivityResponse, -} from '../gen/models'; -import { UpdateStateResult } from '../types-internal'; - -const updateActivityInActivities = ( - updatedActivity: ActivityResponse, - activities: ActivityResponse[], -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - const index = activities.findIndex((a) => a.id === updatedActivity.id); - if (index !== -1) { - const newActivities = [...activities]; - newActivities[index] = updatedActivity; - return { changed: true, activities: newActivities }; - } else { - return { changed: false, activities }; - } -}; - -export const addReactionToActivity = ( - event: ActivityReactionAddedEvent, - activity: ActivityResponse, - isCurrentUser: boolean, -): UpdateStateResult => { - // Update own_reactions if the reaction is from the current user - const ownReactions = [...(activity.own_reactions || [])]; - if (isCurrentUser) { - ownReactions.push(event.reaction); - } - - return { - ...activity, - own_reactions: ownReactions, - latest_reactions: event.activity.latest_reactions, - reaction_groups: event.activity.reaction_groups, - changed: true, - }; -}; - -export const removeReactionFromActivity = ( - event: ActivityReactionDeletedEvent, - activity: ActivityResponse, - isCurrentUser: boolean, -): UpdateStateResult => { - // Update own_reactions if the reaction is from the current user - const ownReactions = isCurrentUser - ? (activity.own_reactions || []).filter( - (r) => - !( - r.type === event.reaction.type && - r.user.id === event.reaction.user.id - ), - ) - : activity.own_reactions; - - return { - ...activity, - own_reactions: ownReactions, - latest_reactions: event.activity.latest_reactions, - reaction_groups: event.activity.reaction_groups, - changed: true, - }; -}; - -export const addReactionToActivities = ( - event: ActivityReactionAddedEvent, - activities: ActivityResponse[] | undefined, - isCurrentUser: boolean, -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - if (!activities) { - return { changed: false, activities: [] }; - } - - const activityIndex = activities.findIndex((a) => a.id === event.activity.id); - if (activityIndex === -1) { - return { changed: false, activities }; - } - - const activity = activities[activityIndex]; - const updatedActivity = addReactionToActivity(event, activity, isCurrentUser); - return updateActivityInActivities(updatedActivity, activities); -}; - -export const removeReactionFromActivities = ( - event: ActivityReactionDeletedEvent, - activities: ActivityResponse[] | undefined, - isCurrentUser: boolean, -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - if (!activities) { - return { changed: false, activities: [] }; - } - - const activityIndex = activities.findIndex((a) => a.id === event.activity.id); - if (activityIndex === -1) { - return { changed: false, activities }; - } - - const activity = activities[activityIndex]; - const updatedActivity = removeReactionFromActivity( - event, - activity, - isCurrentUser, - ); - return updateActivityInActivities(updatedActivity, activities); -}; diff --git a/packages/feeds-client/src/state-updates/bookmark-utils.ts b/packages/feeds-client/src/state-updates/bookmark-utils.ts deleted file mode 100644 index 500237fa..00000000 --- a/packages/feeds-client/src/state-updates/bookmark-utils.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { - BookmarkAddedEvent, - BookmarkDeletedEvent, - BookmarkUpdatedEvent, - ActivityResponse, - BookmarkResponse, -} from '../gen/models'; -import { UpdateStateResult } from '../types-internal'; - -// Helper function to check if two bookmarks are the same -// A bookmark is identified by activity_id + folder_id + user_id -const isSameBookmark = ( - bookmark1: BookmarkResponse, - bookmark2: BookmarkResponse, -): boolean => { - return ( - bookmark1.user.id === bookmark2.user.id && - bookmark1.activity.id === bookmark2.activity.id && - bookmark1.folder?.id === bookmark2.folder?.id - ); -}; - -const updateActivityInActivities = ( - updatedActivity: ActivityResponse, - activities: ActivityResponse[], -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - const index = activities.findIndex((a) => a.id === updatedActivity.id); - if (index !== -1) { - const newActivities = [...activities]; - newActivities[index] = updatedActivity; - return { changed: true, activities: newActivities }; - } else { - return { changed: false, activities }; - } -}; - -export const addBookmarkToActivity = ( - event: BookmarkAddedEvent, - activity: ActivityResponse, - isCurrentUser: boolean, -): UpdateStateResult => { - // Update own_bookmarks if the bookmark is from the current user - const ownBookmarks = [...(activity.own_bookmarks || [])]; - if (isCurrentUser) { - ownBookmarks.push(event.bookmark); - } - - return { - ...activity, - own_bookmarks: ownBookmarks, - changed: true, - }; -}; - -export const removeBookmarkFromActivity = ( - event: BookmarkDeletedEvent, - activity: ActivityResponse, - isCurrentUser: boolean, -): UpdateStateResult => { - // Update own_bookmarks if the bookmark is from the current user - const ownBookmarks = isCurrentUser - ? (activity.own_bookmarks || []).filter( - (bookmark) => !isSameBookmark(bookmark, event.bookmark), - ) - : activity.own_bookmarks; - - return { - ...activity, - own_bookmarks: ownBookmarks, - changed: true, - }; -}; - -export const updateBookmarkInActivity = ( - event: BookmarkUpdatedEvent, - activity: ActivityResponse, - isCurrentUser: boolean, -): UpdateStateResult => { - // Update own_bookmarks if the bookmark is from the current user - let ownBookmarks = activity.own_bookmarks || []; - if (isCurrentUser) { - const bookmarkIndex = ownBookmarks.findIndex((bookmark) => - isSameBookmark(bookmark, event.bookmark), - ); - if (bookmarkIndex !== -1) { - ownBookmarks = [...ownBookmarks]; - ownBookmarks[bookmarkIndex] = event.bookmark; - } - } - - return { - ...activity, - own_bookmarks: ownBookmarks, - changed: true, - }; -}; - -export const addBookmarkToActivities = ( - event: BookmarkAddedEvent, - activities: ActivityResponse[] | undefined, - isCurrentUser: boolean, -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - if (!activities) { - return { changed: false, activities: [] }; - } - - const activityIndex = activities.findIndex( - (a) => a.id === event.bookmark.activity.id, - ); - if (activityIndex === -1) { - return { changed: false, activities }; - } - - const activity = activities[activityIndex]; - const updatedActivity = addBookmarkToActivity(event, activity, isCurrentUser); - return updateActivityInActivities(updatedActivity, activities); -}; - -export const removeBookmarkFromActivities = ( - event: BookmarkDeletedEvent, - activities: ActivityResponse[] | undefined, - isCurrentUser: boolean, -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - if (!activities) { - return { changed: false, activities: [] }; - } - - const activityIndex = activities.findIndex( - (a) => a.id === event.bookmark.activity.id, - ); - if (activityIndex === -1) { - return { changed: false, activities }; - } - - const activity = activities[activityIndex]; - const updatedActivity = removeBookmarkFromActivity( - event, - activity, - isCurrentUser, - ); - return updateActivityInActivities(updatedActivity, activities); -}; - -export const updateBookmarkInActivities = ( - event: BookmarkUpdatedEvent, - activities: ActivityResponse[] | undefined, - isCurrentUser: boolean, -): UpdateStateResult<{ activities: ActivityResponse[] }> => { - if (!activities) { - return { changed: false, activities: [] }; - } - - const activityIndex = activities.findIndex( - (a) => a.id === event.bookmark.activity.id, - ); - if (activityIndex === -1) { - return { changed: false, activities }; - } - - const activity = activities[activityIndex]; - const updatedActivity = updateBookmarkInActivity( - event, - activity, - isCurrentUser, - ); - return updateActivityInActivities(updatedActivity, activities); -}; diff --git a/packages/feeds-client/src/state-updates/follow-utils.test.ts b/packages/feeds-client/src/state-updates/follow-utils.test.ts deleted file mode 100644 index 43373dcb..00000000 --- a/packages/feeds-client/src/state-updates/follow-utils.test.ts +++ /dev/null @@ -1,552 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { - handleFollowCreated, - handleFollowDeleted, - handleFollowUpdated, -} from './follow-utils'; -import { FollowResponse, FeedResponse, UserResponse } from '../gen/models'; -import { FeedState } from '../Feed'; - -describe('follow-utils', () => { - const mockUser: UserResponse = { - id: 'user-1', - created_at: new Date(), - updated_at: new Date(), - banned: false, - language: 'en', - online: false, - role: 'user', - blocked_user_ids: [], - teams: [], - custom: {}, - }; - - const mockFeed: FeedResponse = { - id: 'feed-1', - group_id: 'user', - created_at: new Date(), - updated_at: new Date(), - description: 'Test feed', - fid: 'user:feed-1', - follower_count: 0, - following_count: 0, - member_count: 0, - name: 'Test Feed', - pin_count: 0, - created_by: mockUser, - custom: {}, - }; - - const mockFollow: FollowResponse = { - created_at: new Date(), - updated_at: new Date(), - follower_role: 'user', - push_preference: 'all', - status: 'accepted', - source_feed: { - ...mockFeed, - id: 'source-feed', - fid: 'user:source-feed', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'target-feed', - fid: 'user:target-feed', - created_by: mockUser, - }, - }; - - describe('handleFollowCreated', () => { - it('should return unchanged state for non-accepted follows', () => { - const follow: FollowResponse = { - ...mockFollow, - status: 'pending', - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [], - following: [], - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(false); - }); - - it('should handle when this feed follows someone', () => { - const follow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'feed-x', - fid: 'user:feed-x', - created_by: { - ...mockUser, - id: 'user-x', - }, - following_count: 1, - }, - target_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: mockUser, - }, - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - following: [], - following_count: 0, - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-x', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.following).toHaveLength(1); - expect(result.data.following?.[0]).toEqual(follow); - expect(result.data).toMatchObject(follow.source_feed); - expect(result.data.own_follows).toBeUndefined(); - expect(result.data.following_count).toEqual(1); - }); - - it('should handle when someone follows this feed', () => { - const follow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: { - ...mockUser, - id: 'other-user', - }, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - follower_count: 1, - }, - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [], - follower_count: 0, - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toHaveLength(1); - expect(result.data.followers?.[0]).toEqual(follow); - expect(result.data).toMatchObject(follow.target_feed); - expect(result.data.own_follows).toBeUndefined(); - expect(result.data.follower_count).toEqual(1); - }); - - it('should add to own_follows when connected user is the source', () => { - const follow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: { ...mockUser, id: 'user-1' }, - }, - target_feed: { - ...mockFeed, - id: 'feed-x', - fid: 'user:feed-x', - created_by: { - ...mockUser, - id: 'user-x', - }, - }, - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [], - own_follows: [], - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-x', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.own_follows).toHaveLength(1); - expect(result.data.own_follows?.[0]).toEqual(follow); - }); - - it('should not update followers/following when they are undefined', () => { - const follow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: undefined, - following: undefined, - own_follows: undefined, - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toBeUndefined(); - expect(result.data).toMatchObject(follow.target_feed); - }); - - it('should add new followers to the top of existing arrays', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'existing-feed', - fid: 'user:existing-feed', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [existingFollow], - following: undefined, - own_follows: undefined, - }; - - const result = handleFollowCreated( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toHaveLength(2); - expect(result.data.followers?.[0]).toEqual(follow); - expect(result.data.followers?.[1]).toEqual(existingFollow); - }); - }); - - describe('handleFollowDeleted', () => { - it('should handle when this feed unfollows someone', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = existingFollow; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - following: [existingFollow], - following_count: 1, - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.following).toHaveLength(0); - expect(result.data).toMatchObject(follow.source_feed); - }); - - it('should handle when someone unfollows this feed', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: { - ...mockUser, - id: 'other-user', - }, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = existingFollow; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [existingFollow], - own_follows: [existingFollow], - following_count: 1, - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toHaveLength(0); - expect(result.data.own_follows).toEqual(currentState.own_follows); - expect(result.data).toMatchObject(follow.target_feed); - }); - - it('should only remove own_follows when connected user is the source', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: { ...mockUser, id: 'user-1' }, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = existingFollow; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [existingFollow], - own_follows: [existingFollow], - following_count: 1, - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toHaveLength(0); - expect(result.data.own_follows).toHaveLength(0); - }); - - it('should not remove own_follows when connected user is not the source', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: { ...mockUser, id: 'other-user' }, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = existingFollow; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [existingFollow], - own_follows: [existingFollow], - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toHaveLength(0); - expect(result.data.own_follows).toHaveLength(1); // Should remain unchanged - }); - - it('should not update followers/following when they are undefined in delete', () => { - const existingFollow: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'other-feed', - fid: 'user:other-feed', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = existingFollow; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: undefined, - own_follows: undefined, - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.followers).toBeUndefined(); - expect(result.data.own_follows).toBeUndefined(); - expect(result.data).toMatchObject(follow.target_feed); - }); - - it('should filter out the correct follow by target feed id', () => { - const followToRemove: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'target-to-remove', - fid: 'user:target-to-remove', - created_by: mockUser, - }, - }; - - const followToKeep: FollowResponse = { - ...mockFollow, - source_feed: { - ...mockFeed, - id: 'feed-1', - fid: 'user:feed-1', - created_by: mockUser, - }, - target_feed: { - ...mockFeed, - id: 'target-to-keep', - fid: 'user:target-to-keep', - created_by: mockUser, - }, - }; - - const follow: FollowResponse = followToRemove; - - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - following: [followToRemove, followToKeep], - following_count: 2, - }; - - const result = handleFollowDeleted( - follow, - currentState, - 'user:feed-1', - 'user-1', - ); - - expect(result.changed).toBe(true); - expect(result.data.following).toHaveLength(1); - expect(result.data.following?.[0]).toEqual(followToKeep); - }); - }); - - describe('handleFollowUpdated', () => { - // TODO: not yet implemented - it.skip('should return unchanged state (no-op)', () => { - // @ts-expect-error - we're not testing the full state here - const currentState: FeedState = { - followers: [], - following: [], - following_count: 0, - }; - - const result = handleFollowUpdated(currentState); - - expect(result.changed).toBe(false); - }); - }); -}); diff --git a/packages/feeds-client/src/state-updates/follow-utils.ts b/packages/feeds-client/src/state-updates/follow-utils.ts deleted file mode 100644 index a29ad09c..00000000 --- a/packages/feeds-client/src/state-updates/follow-utils.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { FeedState } from '../Feed'; -import { FollowResponse } from '../gen/models'; -import { UpdateStateResult } from '../types-internal'; - -export const handleFollowCreated = ( - follow: FollowResponse, - currentState: FeedState, - currentFeedId: string, - connectedUserId?: string, -): UpdateStateResult<{ data: FeedState }> => { - // filter non-accepted follows (the way getOrCreate does by default) - if (follow.status !== 'accepted') { - return { changed: false, data: currentState }; - } - - let newState: FeedState = { ...currentState }; - - // this feed followed someone - if (follow.source_feed.fid === currentFeedId) { - newState = { - ...newState, - // Update FeedResponse fields, that has the new follower/following count - ...follow.source_feed, - }; - - // Only update if following array already exists - if (currentState.following !== undefined) { - newState.following = [follow, ...currentState.following]; - } - } else if ( - // someone followed this feed - follow.target_feed.fid === currentFeedId - ) { - const source = follow.source_feed; - - newState = { - ...newState, - // Update FeedResponse fields, that has the new follower/following count - ...follow.target_feed, - }; - - if (source.created_by.id === connectedUserId) { - newState.own_follows = currentState.own_follows - ? currentState.own_follows.concat(follow) - : [follow]; - } - - // Only update if followers array already exists - if (currentState.followers !== undefined) { - newState.followers = [follow, ...currentState.followers]; - } - } - - return { changed: true, data: newState }; -}; - -export const handleFollowDeleted = ( - follow: FollowResponse, - currentState: FeedState, - currentFeedId: string, - connectedUserId?: string, -): UpdateStateResult<{ data: FeedState }> => { - let newState: FeedState = { ...currentState }; - - // this feed unfollowed someone - if (follow.source_feed.fid === currentFeedId) { - newState = { - ...newState, - // Update FeedResponse fields, that has the new follower/following count - ...follow.source_feed, - }; - - // Only update if following array already exists - if (currentState.following !== undefined) { - newState.following = currentState.following.filter( - (followItem) => followItem.target_feed.fid !== follow.target_feed.fid, - ); - } - } else if ( - // someone unfollowed this feed - follow.target_feed.fid === currentFeedId - ) { - const source = follow.source_feed; - - newState = { - ...newState, - // Update FeedResponse fields, that has the new follower/following count - ...follow.target_feed, - }; - - if ( - source.created_by.id === connectedUserId && - currentState.own_follows !== undefined - ) { - newState.own_follows = currentState.own_follows.filter( - (followItem) => followItem.source_feed.fid !== follow.source_feed.fid, - ); - } - - // Only update if followers array already exists - if (currentState.followers !== undefined) { - newState.followers = currentState.followers.filter( - (followItem) => followItem.source_feed.fid !== follow.source_feed.fid, - ); - } - } - - return { changed: true, data: newState }; -}; - -export const handleFollowUpdated = ( - currentState: FeedState, -): UpdateStateResult<{ data: FeedState }> => { - // For now, we'll treat follow updates as no-ops since the current implementation does - // This can be enhanced later if needed - return { changed: false, data: currentState }; -}; diff --git a/packages/feeds-client/src/state-updates/state-update-queue.ts b/packages/feeds-client/src/state-updates/state-update-queue.ts deleted file mode 100644 index 48ec8f19..00000000 --- a/packages/feeds-client/src/state-updates/state-update-queue.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { FollowResponse } from '../gen/models'; - -export const shouldUpdateState = ({ - stateUpdateId, - stateUpdateQueue, - watch, -}: { - stateUpdateId: string; - stateUpdateQueue: Set; - watch: boolean; -}) => { - if (!watch) { - return true; - } - - if (watch && stateUpdateQueue.has(stateUpdateId)) { - stateUpdateQueue.delete(stateUpdateId); - return false; - } - - stateUpdateQueue.add(stateUpdateId); - return true; -}; - -export const getStateUpdateQueueIdForFollow = (follow: FollowResponse) => { - return `follow${follow.source_feed.fid}-${follow.target_feed.fid}`; -}; - -export const getStateUpdateQueueIdForUnfollow = ( - follow: - | FollowResponse - | { source_feed: { fid: string }; target_feed: { fid: string } }, -) => { - return `unfollow${follow.source_feed.fid}-${follow.target_feed.fid}`; -}; diff --git a/packages/feeds-client/src/test-utils/index.ts b/packages/feeds-client/src/test-utils/index.ts new file mode 100644 index 00000000..2a43bcc7 --- /dev/null +++ b/packages/feeds-client/src/test-utils/index.ts @@ -0,0 +1 @@ +export * from './response-generators'; \ No newline at end of file diff --git a/packages/feeds-client/src/test-utils/response-generators.ts b/packages/feeds-client/src/test-utils/response-generators.ts new file mode 100644 index 00000000..944f3e93 --- /dev/null +++ b/packages/feeds-client/src/test-utils/response-generators.ts @@ -0,0 +1,102 @@ +import { + FeedResponse, + FollowResponse, + OwnUser, + OwnUserResponse, + UserResponse, +} from '../gen/models'; +import { humanId } from 'human-id'; + +export const getHumanId = () => humanId({ capitalize: false, separator: '-' }); + +export const generateUserResponse = ( + overrides: Partial = {}, +): UserResponse => ({ + id: `user-${getHumanId()}`, + created_at: new Date(), + updated_at: new Date(), + banned: false, + language: 'en', + online: false, + role: 'user', + blocked_user_ids: [], + teams: [], + custom: {}, + ...overrides, +}); + +export const generateOwnUserResponse = ( + overrides: Partial = {}, +): OwnUserResponse => ({ + ...generateUserResponse({ + id: `own-user-${getHumanId()}`, + }), + invisible: false, + total_unread_count: 0, + unread_channels: 0, + unread_count: 0, + unread_threads: 0, + channel_mutes: [], + devices: [], + mutes: [], + ...overrides, +}); + +export const generateOwnUser = (overrides: Partial = {}): OwnUser => ({ + ...generateOwnUserResponse(), + devices: [], + mutes: [], + total_unread_count_by_team: {}, + ...overrides, +}); + +export const generateFeedResponse = ( + overrides: Omit, 'created_by' | 'fid'> & { + created_by?: Partial; + } = {}, +): FeedResponse => { + const id = overrides.id || `feed-${getHumanId()}`; + const groupId = overrides.group_id || 'user'; + const fid = `${groupId}:${id}`; + const createdBy = generateUserResponse(overrides.created_by); + const description = humanId({ + addAdverb: true, + adjectiveCount: 4, + }); + const name = humanId(); + + return { + id, + group_id: groupId, + created_at: new Date(), + updated_at: new Date(), + description, + fid, + follower_count: 0, + following_count: 0, + member_count: 0, + name, + pin_count: 0, + custom: {}, + ...overrides, + created_by: createdBy, + }; +}; + +export const generateFollowResponse = ( + overrides: Partial = {}, +): FollowResponse => { + const sourceFeedResponse = generateFeedResponse(); + const targetFeedResponse = generateFeedResponse(); + + return { + created_at: new Date(), + updated_at: new Date(), + follower_role: 'user', + push_preference: 'all', + status: 'accepted', + source_feed: sourceFeedResponse, + target_feed: targetFeedResponse, + ...overrides, + }; +}; diff --git a/packages/feeds-client/src/types-internal.ts b/packages/feeds-client/src/types-internal.ts index 54e99c9e..b429df25 100644 --- a/packages/feeds-client/src/types-internal.ts +++ b/packages/feeds-client/src/types-internal.ts @@ -1,5 +1,16 @@ +import { WSEvent } from './gen/models'; + export type UpdateStateResult = T & { changed: boolean; }; export type FromArray = T extends Array ? L : never; + +export type EventPayload = Extract< + WSEvent, + { type: T } +>; + +export type PartializeAllBut = Pick & { + [key in K]?: T[key]; +}; diff --git a/packages/feeds-client/src/types.ts b/packages/feeds-client/src/types.ts index 468ca31f..d257a9d7 100644 --- a/packages/feeds-client/src/types.ts +++ b/packages/feeds-client/src/types.ts @@ -8,7 +8,7 @@ import type { ActivityResponse, CommentResponse, } from './gen/models'; -import { FeedsClient } from './FeedsClient'; +import { FeedsClient } from './feeds-client'; export type FeedsEvent = WSEvent | ConnectionChangedEvent | NetworkChangedEvent; export type ActivityIdOrCommentId = string; diff --git a/packages/feeds-client/src/utils.ts b/packages/feeds-client/src/utils.ts deleted file mode 100644 index 9d626b61..00000000 --- a/packages/feeds-client/src/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CommentParent, StreamFile } from './types'; -import type { CommentResponse } from './gen/models'; - -export const isImageFile = (file: StreamFile) => { - // photoshop files begin with 'image/' - return file.type.startsWith('image/') && !file.type.endsWith('.photoshop'); -}; - -export const isVideoFile = (file: StreamFile) => { - return file.type.startsWith('video/'); -}; - -export const checkHasAnotherPage = ( - v: T, - cursor: string | undefined, -) => - (typeof v === 'undefined' && typeof cursor === 'undefined') || - typeof cursor === 'string'; - -export const isCommentResponse = ( - entity: CommentParent, -): entity is CommentResponse => { - return typeof (entity as CommentResponse)?.object_id === 'string'; -}; - -export const Constants = { - DEFAULT_COMMENT_PAGINATION: 'first', -} as const; - -export const uniqueArrayMerge = ( - existingArray: T[], - arrayToMerge: T[], - getKey: (v: T) => string, -) => { - const existing = new Set(); - - existingArray.forEach((value) => { - const key = getKey(value); - existing.add(key); - }); - - const filteredArrayToMerge = arrayToMerge.filter((value) => { - const key = getKey(value); - return !existing.has(key); - }); - - return existingArray.concat(filteredArrayToMerge); -}; diff --git a/packages/feeds-client/src/utils/check-has-another-page.ts b/packages/feeds-client/src/utils/check-has-another-page.ts new file mode 100644 index 00000000..9822f799 --- /dev/null +++ b/packages/feeds-client/src/utils/check-has-another-page.ts @@ -0,0 +1,6 @@ +export const checkHasAnotherPage = ( + v: T, + cursor: string | undefined, +) => + (typeof v === 'undefined' && typeof cursor === 'undefined') || + typeof cursor === 'string'; diff --git a/packages/feeds-client/src/utils/constants.ts b/packages/feeds-client/src/utils/constants.ts new file mode 100644 index 00000000..098223db --- /dev/null +++ b/packages/feeds-client/src/utils/constants.ts @@ -0,0 +1,3 @@ +export const Constants = { + DEFAULT_COMMENT_PAGINATION: 'first', +} as const; diff --git a/packages/feeds-client/src/utils/index.ts b/packages/feeds-client/src/utils/index.ts new file mode 100644 index 00000000..cf510c0d --- /dev/null +++ b/packages/feeds-client/src/utils/index.ts @@ -0,0 +1,5 @@ +export * from './check-has-another-page'; +export * from './unique-array-merge'; +export * from './constants'; +export * from './type-assertions'; +export * from './state-update-queue'; diff --git a/packages/feeds-client/src/state-updates/state-update-queue.test.ts b/packages/feeds-client/src/utils/state-update-queue.test.ts similarity index 77% rename from packages/feeds-client/src/state-updates/state-update-queue.test.ts rename to packages/feeds-client/src/utils/state-update-queue.test.ts index 66733bb9..3da39038 100644 --- a/packages/feeds-client/src/state-updates/state-update-queue.test.ts +++ b/packages/feeds-client/src/utils/state-update-queue.test.ts @@ -6,7 +6,7 @@ describe('state-update-queue', () => { describe('shouldUpdateState', () => { it('should return true when watch is false', () => { const result = shouldUpdateState({ - stateUpdateId: 'test-id', + stateUpdateQueueId: 'test-id', stateUpdateQueue: new Set(['other-id']), watch: false, }); @@ -14,11 +14,11 @@ describe('state-update-queue', () => { expect(result).toBe(true); }); - it('should return true when watch is true but stateUpdateId is not in queue', () => { + it('should return true when watch is true but queueId is not in queue', () => { const stateUpdateQueue = new Set(['other-id-1', 'other-id-2']); const result = shouldUpdateState({ - stateUpdateId: 'test-id', + stateUpdateQueueId: 'test-id', stateUpdateQueue: stateUpdateQueue, watch: true, }); @@ -27,11 +27,11 @@ describe('state-update-queue', () => { expect(result).toBe(true); }); - it('should return false and remove stateUpdateId from queue when watch is true and stateUpdateId is in queue', () => { + it('should return false and remove queueId from queue when watch is true and queueId is in queue', () => { const stateUpdateQueue = new Set(['test-id', 'other-id']); const result = shouldUpdateState({ - stateUpdateId: 'test-id', + stateUpdateQueueId: 'test-id', stateUpdateQueue, watch: true, }); @@ -42,7 +42,7 @@ describe('state-update-queue', () => { it('should handle empty queue when watch is true', () => { const result = shouldUpdateState({ - stateUpdateId: 'test-id', + stateUpdateQueueId: 'test-id', stateUpdateQueue: new Set(), watch: true, }); diff --git a/packages/feeds-client/src/utils/state-update-queue.ts b/packages/feeds-client/src/utils/state-update-queue.ts new file mode 100644 index 00000000..ee2240ee --- /dev/null +++ b/packages/feeds-client/src/utils/state-update-queue.ts @@ -0,0 +1,42 @@ +import { isFollowResponse } from './type-assertions'; + +export const shouldUpdateState = ({ + stateUpdateQueueId, + stateUpdateQueue, + watch, +}: { + stateUpdateQueueId: string; + stateUpdateQueue: Set; + watch: boolean; +}) => { + if (!watch) { + return true; + } + + if (watch && stateUpdateQueue.has(stateUpdateQueueId)) { + stateUpdateQueue.delete(stateUpdateQueueId); + return false; + } + + stateUpdateQueue.add(stateUpdateQueueId); + return true; +}; + +export function getStateUpdateQueueId( + data: object, + prefix?: 'deleted' | 'updated' | 'created' | (string & {}), +) { + if (isFollowResponse(data)) { + const toJoin = [data.source_feed.fid, data.target_feed.fid]; + if (prefix) { + toJoin.unshift(prefix); + } + return toJoin.join('-'); + } + // else if (isMemberResponse(data)) { + // } + + throw new Error( + `Cannot create state update queueId for data: ${JSON.stringify(data)}`, + ); +} diff --git a/packages/feeds-client/src/utils/type-assertions.ts b/packages/feeds-client/src/utils/type-assertions.ts new file mode 100644 index 00000000..65db272f --- /dev/null +++ b/packages/feeds-client/src/utils/type-assertions.ts @@ -0,0 +1,22 @@ +import { CommentResponse, FollowResponse } from '../gen/models'; +import { StreamFile } from '../types'; +import { CommentParent } from '../types'; + +export const isFollowResponse = (data: object): data is FollowResponse => { + return 'source_feed' in data && 'target_feed' in data; +}; + +export const isCommentResponse = ( + entity: CommentParent, +): entity is CommentResponse => { + return typeof (entity as CommentResponse)?.object_id === 'string'; +}; + +export const isImageFile = (file: StreamFile) => { + // photoshop files begin with 'image/' + return file.type.startsWith('image/') && !file.type.endsWith('.photoshop'); +}; + +export const isVideoFile = (file: StreamFile) => { + return file.type.startsWith('video/'); +}; diff --git a/packages/feeds-client/src/utils.test.ts b/packages/feeds-client/src/utils/unique-array-merge.test.ts similarity index 96% rename from packages/feeds-client/src/utils.test.ts rename to packages/feeds-client/src/utils/unique-array-merge.test.ts index a3e3ac47..e56266c2 100644 --- a/packages/feeds-client/src/utils.test.ts +++ b/packages/feeds-client/src/utils/unique-array-merge.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { uniqueArrayMerge } from './utils'; +import { uniqueArrayMerge } from './unique-array-merge'; describe('utils', () => { - describe('uniqueMerge', () => { + describe(uniqueArrayMerge.name, () => { it('should merge arrays with unique objects based on key', () => { const existingArray = [ { id: '1', name: 'Alice' }, @@ -123,7 +123,11 @@ describe('utils', () => { email: string; }) => item.email; - const result = uniqueArrayMerge(existingArray, arrayToMerge, getKeyByEmail); + const result = uniqueArrayMerge( + existingArray, + arrayToMerge, + getKeyByEmail, + ); expect(result).toEqual([ { id: '1', name: 'Alice', email: 'alice@example.com' }, diff --git a/packages/feeds-client/src/utils/unique-array-merge.ts b/packages/feeds-client/src/utils/unique-array-merge.ts new file mode 100644 index 00000000..7e1583b1 --- /dev/null +++ b/packages/feeds-client/src/utils/unique-array-merge.ts @@ -0,0 +1,19 @@ +export const uniqueArrayMerge = ( + existingArray: T[], + arrayToMerge: T[], + getKey: (v: T) => string, +) => { + const existing = new Set(); + + existingArray.forEach((value) => { + const key = getKey(value); + existing.add(key); + }); + + const filteredArrayToMerge = arrayToMerge.filter((value) => { + const key = getKey(value); + return !existing.has(key); + }); + + return existingArray.concat(filteredArrayToMerge); +}; diff --git a/yarn.lock b/yarn.lock index 8575ea71..7c7ecc4c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5965,6 +5965,7 @@ __metadata: "@vitest/coverage-v8": 3.2.4 axios: ^1.7.7 dotenv: ^16.4.5 + human-id: ^4.1.1 react: 19.0.0 rimraf: ^6.0.1 rollup: ^4.24.0 @@ -13229,6 +13230,15 @@ __metadata: languageName: node linkType: hard +"human-id@npm:^4.1.1": + version: 4.1.1 + resolution: "human-id@npm:4.1.1" + bin: + human-id: dist/cli.js + checksum: 7d78857b532323e065ae6b6d141b3b8407d6a3714adea8c91f451d5ea8e5461677970b2e2c99e879ff94a8bbefdaa396e9a0f337d82e840183a86fa78c4f88ce + languageName: node + linkType: hard + "human-signals@npm:^1.1.1": version: 1.1.1 resolution: "human-signals@npm:1.1.1"