diff --git a/.changeset/clever-friends-carry.md b/.changeset/clever-friends-carry.md new file mode 100644 index 000000000..80d9e7f69 --- /dev/null +++ b/.changeset/clever-friends-carry.md @@ -0,0 +1,41 @@ +--- +"@knocklabs/client": minor +"@knocklabs/react-core": minor +"@knocklabs/expo": minor +"@knocklabs/react": minor +"@knocklabs/react-native": minor +--- + +Initialize feeds in `"compact"` mode by default + +The feed client can now be initialized with a `mode` option, set to either `"compact"` or `"rich"`. When `mode` is `"compact"`, the `activities` and `total_activities` fields will _not_ be present on feed items, and the `data` field will not include nested arrays and objects. + +**By default, feeds are initialized in `"compact"` mode. If you need to access `activities`, `total_activities`, or the complete `data`, you must initialize your feed in `"rich"` mode.** + +If you are using the feed client via `@knocklabs/client` directly: + +```js +const knockFeed = knockClient.feeds.initialize( + process.env.KNOCK_FEED_CHANNEL_ID, + { mode: "full" }, +); +``` + +If you are using `` via `@knocklabs/react`, `@knocklabs/react-native`, or `@knocklabs/expo`: + +```tsx + +``` + +If you are using the `useNotifications` hook via `@knocklabs/react-core`: + +```js +const feedClient = useNotifications( + knockClient, + process.env.KNOCK_FEED_CHANNEL_ID, + { mode: "full" }, +); +``` diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 21cff1c1f..01518180d 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -36,8 +36,9 @@ import { import { getFormattedTriggerData, mergeDateRangeParams } from "./utils"; // Default options to apply -const feedClientDefaults: Pick = { +const feedClientDefaults: Pick = { archived: "exclude", + mode: "compact", }; const DEFAULT_DISCONNECT_DELAY = 2000; diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts index 2f4f163a2..dcda7b6c7 100644 --- a/packages/client/src/clients/feed/interfaces.ts +++ b/packages/client/src/clients/feed/interfaces.ts @@ -48,6 +48,14 @@ export interface FeedClientOptions { // Optionally set whether to be inclusive of the start and end dates inclusive?: boolean; }; + /** + * The mode to render the feed items in. When `mode` is `compact`, feed items will not have + * `activities` and `total_activities` fields, and the `data` field will not include nested + * arrays and objects. + * + * @default "compact" + */ + mode?: "rich" | "compact"; } export type FetchFeedOptions = { @@ -107,7 +115,11 @@ export type ContentBlock = export interface FeedItem { __cursor: string; id: string; - activities: Activity[]; + /** + * List of activities associated with this feed item. + * Only present in "rich" mode. + */ + activities?: Activity[]; actors: Recipient[]; blocks: ContentBlock[]; inserted_at: string; @@ -118,7 +130,11 @@ export interface FeedItem { interacted_at: string | null; link_clicked_at: string | null; archived_at: string | null; - total_activities: number; + /** + * Total number of activities related to this feed item. + * Only present in "rich" mode. + */ + total_activities?: number; total_actors: number; data: T | null; source: NotificationSource; diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index f616fe8de..fbde2fda0 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi } from "vitest"; +import type { FetchFeedOptions } from "../../../src"; import ApiClient from "../../../src/api"; import Feed from "../../../src/clients/feed/feed"; import { FeedSocketManager } from "../../../src/clients/feed/socket-manager"; @@ -83,6 +84,7 @@ describe("Feed", () => { ); expect(feed.defaultOptions.archived).toBe("exclude"); + expect(feed.defaultOptions.mode).toBe("compact"); } finally { cleanup(); } @@ -546,7 +548,7 @@ describe("Feed", () => { expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ method: "GET", url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", - params: { archived: "exclude" }, + params: { archived: "exclude", mode: "compact" }, }); expect(result).toBeDefined(); if (result && "entries" in result) { @@ -579,10 +581,11 @@ describe("Feed", () => { undefined, ); - const options = { + const options: FetchFeedOptions = { page_size: 25, source: "workflow_123", tenant: "tenant_456", + mode: "rich", }; await feed.fetch(options); @@ -595,6 +598,7 @@ describe("Feed", () => { page_size: 25, source: "workflow_123", tenant: "tenant_456", + mode: "rich", }, }); } finally { @@ -650,6 +654,7 @@ describe("Feed", () => { url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", params: { archived: "exclude", + mode: "compact", after: "cursor_123", }, }); @@ -1582,4 +1587,131 @@ describe("Feed", () => { } }); }); + + describe("Feed Mode", () => { + test("sets mode query param to compact by default", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const mockFeedResponse = { + entries: [], + meta: { total_count: 0, unread_count: 0, unseen_count: 0 }, + page_info: { before: null, after: null, page_size: 50 }, + }; + + mockApiClient.makeRequest.mockResolvedValue({ + statusCode: "ok", + body: mockFeedResponse, + }); + + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + undefined, + ); + + await feed.fetch(); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", + params: { + archived: "exclude", + mode: "compact", + }, + }); + } finally { + cleanup(); + } + }); + + test("sets mode query param to rich when initialized in rich mode", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const mockFeedResponse = { + entries: [], + meta: { total_count: 0, unread_count: 0, unseen_count: 0 }, + page_info: { before: null, after: null, page_size: 50 }, + }; + + mockApiClient.makeRequest.mockResolvedValue({ + statusCode: "ok", + body: mockFeedResponse, + }); + + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + { mode: "rich" }, + undefined, + ); + + await feed.fetch(); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", + params: { + archived: "exclude", + mode: "rich", + }, + }); + } finally { + cleanup(); + } + }); + + test("handles lack of activities and total_activities in compact mode", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + // Create a compact mode feed item (no activities or total_activities) + const compactFeedItem = { + __cursor: "cursor_123", + id: "msg_123", + actors: [], + blocks: [], + archived_at: null, + inserted_at: new Date().toISOString(), + read_at: null, + seen_at: null, + clicked_at: null, + interacted_at: null, + link_clicked_at: null, + source: { key: "workflow", version_id: "v1", categories: [] }, + tenant: null, + total_actors: 1, + updated_at: new Date().toISOString(), + data: { message: "Hello" }, + }; + + const mockFeedResponse = { + entries: [compactFeedItem], + meta: { total_count: 1, unread_count: 1, unseen_count: 1 }, + page_info: { before: null, after: null, page_size: 50 }, + }; + + mockApiClient.makeRequest.mockResolvedValue({ + statusCode: "ok", + body: mockFeedResponse, + }); + + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + { mode: "compact" }, + undefined, + ); + + const result = await feed.fetch(); + + expect(result).toBeDefined(); + expect(result!.data).toEqual(mockFeedResponse); + } finally { + cleanup(); + } + }); + }); }); diff --git a/packages/react-core/test/feed/useNotifications.test.tsx b/packages/react-core/test/feed/useNotifications.test.tsx index 8f97040bf..e89f1f023 100644 --- a/packages/react-core/test/feed/useNotifications.test.tsx +++ b/packages/react-core/test/feed/useNotifications.test.tsx @@ -20,6 +20,7 @@ describe("useNotifications", () => { archived: "include", page_size: 10, status: "all", + mode: "rich", }; const { result } = renderHook( @@ -127,6 +128,7 @@ describe("useNotifications", () => { archived: "include", page_size: 10, status: "all", + mode: "rich", }; const { result, rerender } = renderHook( @@ -162,12 +164,14 @@ describe("useNotifications", () => { archived: "include", page_size: 10, status: "all", + mode: "compact", }; const options2: FeedClientOptions = { archived: "exclude", page_size: 10, status: "read", + mode: "rich", }; const { result, rerender } = renderHook(