Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .changeset/clever-friends-carry.md
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like there may be a potential breakage if you upgrade without any code change?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. As per Chris’s comment here:

We'll need to mark this as a breaking change, which for us means a minor release (pre 1.0)


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 `<KnockFeedProvider>` via `@knocklabs/react`, `@knocklabs/react-native`, or `@knocklabs/expo`:

```tsx
<KnockFeedProvider
feedId={process.env.KNOCK_FEED_CHANNEL_ID}
defaultFeedOptions={{ mode: "full" }}
/>
```

If you are using the `useNotifications` hook via `@knocklabs/react-core`:

```js
const feedClient = useNotifications(
knockClient,
process.env.KNOCK_FEED_CHANNEL_ID,
{ mode: "full" },
);
```
3 changes: 2 additions & 1 deletion packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ import {
import { getFormattedTriggerData, mergeDateRangeParams } from "./utils";

// Default options to apply
const feedClientDefaults: Pick<FeedClientOptions, "archived"> = {
const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
archived: "exclude",
mode: "compact",
};

const DEFAULT_DISCONNECT_DELAY = 2000;
Expand Down
20 changes: 18 additions & 2 deletions packages/client/src/clients/feed/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -107,7 +115,11 @@ export type ContentBlock =
export interface FeedItem<T = GenericData> {
__cursor: string;
id: string;
activities: Activity<T>[];
/**
* List of activities associated with this feed item.
* Only present in "rich" mode.
*/
activities?: Activity<T>[];
actors: Recipient[];
blocks: ContentBlock[];
inserted_at: string;
Expand All @@ -118,7 +130,11 @@ export interface FeedItem<T = GenericData> {
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;
Expand Down
136 changes: 134 additions & 2 deletions packages/client/test/clients/feed/feed.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -83,6 +84,7 @@ describe("Feed", () => {
);

expect(feed.defaultOptions.archived).toBe("exclude");
expect(feed.defaultOptions.mode).toBe("compact");
} finally {
cleanup();
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -595,6 +598,7 @@ describe("Feed", () => {
page_size: 25,
source: "workflow_123",
tenant: "tenant_456",
mode: "rich",
},
});
} finally {
Expand Down Expand Up @@ -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",
},
});
Expand Down Expand Up @@ -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();
}
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test for default compact mode behavior

Low Severity

The test "sets mode query param to compact by default" is nearly identical to "fetches feed data successfully" (lines 516-560). Both create a feed with empty options and verify that fetch() sends { archived: "exclude", mode: "compact" } params. The only difference is the existing test also validates the response entries count.

Fix in Cursor Fix in Web


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();
}
});
});
});
4 changes: 4 additions & 0 deletions packages/react-core/test/feed/useNotifications.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe("useNotifications", () => {
archived: "include",
page_size: 10,
status: "all",
mode: "rich",
};

const { result } = renderHook(
Expand Down Expand Up @@ -127,6 +128,7 @@ describe("useNotifications", () => {
archived: "include",
page_size: 10,
status: "all",
mode: "rich",
};

const { result, rerender } = renderHook(
Expand Down Expand Up @@ -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(
Expand Down
Loading