From 6d0c4807552c5407bba26b9e87aef72f70f7f60e Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 3 Feb 2026 18:05:30 -0500 Subject: [PATCH 01/17] Update feed client to support exclude --- packages/client/src/clients/feed/feed.ts | 11 +++++++---- packages/client/src/clients/feed/interfaces.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 01518180d..f102a41b4 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -536,19 +536,22 @@ class Feed { // Set the loading type based on the request type it is state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading); + const mergedOptions = { ...this.defaultOptions, ...options }; + // trigger_data should be a JSON string for the API // this function will format the trigger data if it's an object // https://docs.knock.app/reference#get-feed - const formattedTriggerData = getFormattedTriggerData({ - ...this.defaultOptions, - ...options, - }); + const formattedTriggerData = getFormattedTriggerData(mergedOptions); + + // Format exclude array to comma-separated string + const formattedExclude = mergedOptions.exclude?.join(","); // Always include the default params, if they have been set const queryParams: FetchFeedOptionsForRequest = { ...this.defaultOptions, ...mergeDateRangeParams(options), trigger_data: formattedTriggerData, + exclude: formattedExclude, // Unset options that should not be sent to the API __loadingType: undefined, __fetchSource: undefined, diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts index eeb9a3f41..a2b246e04 100644 --- a/packages/client/src/clients/feed/interfaces.ts +++ b/packages/client/src/clients/feed/interfaces.ts @@ -58,6 +58,11 @@ export interface FeedClientOptions { * @default "compact" */ mode?: "rich" | "compact"; + /** + * Field paths to exclude from the response. Use dot notation for nested fields + * (e.g., "entries.archived_at"). Limited to 3 levels deep. + */ + exclude?: string[]; } export type FetchFeedOptions = { @@ -69,10 +74,12 @@ export type FetchFeedOptions = { // Should match types here: https://docs.knock.app/reference#get-feed export type FetchFeedOptionsForRequest = Omit< FeedClientOptions, - "trigger_data" + "trigger_data" | "exclude" > & { // Formatted trigger data into a string trigger_data?: string; + // Formatted exclude into a comma-separated string + exclude?: string; // Unset options that should not be sent to the API __loadingType: undefined; __fetchSource: undefined; From 5c02e4ac853b567e2e659a33cba3b867c5c187dd Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 3 Feb 2026 18:12:05 -0500 Subject: [PATCH 02/17] Omit exclude query param if array empty --- packages/client/src/clients/feed/feed.ts | 9 ++++++--- packages/client/src/clients/feed/utils.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index f102a41b4..90102ae12 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -33,7 +33,11 @@ import { FeedMessagesReceivedPayload, FeedRealTimeCallback, } from "./types"; -import { getFormattedTriggerData, mergeDateRangeParams } from "./utils"; +import { + getFormattedExclude, + getFormattedTriggerData, + mergeDateRangeParams, +} from "./utils"; // Default options to apply const feedClientDefaults: Pick = { @@ -543,8 +547,7 @@ class Feed { // https://docs.knock.app/reference#get-feed const formattedTriggerData = getFormattedTriggerData(mergedOptions); - // Format exclude array to comma-separated string - const formattedExclude = mergedOptions.exclude?.join(","); + const formattedExclude = getFormattedExclude(mergedOptions); // Always include the default params, if they have been set const queryParams: FetchFeedOptionsForRequest = { diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts index 9d764c513..5b25b8096 100644 --- a/packages/client/src/clients/feed/utils.ts +++ b/packages/client/src/clients/feed/utils.ts @@ -66,3 +66,11 @@ export function getFormattedTriggerData(options: FeedClientOptions) { return undefined; } + +export function getFormattedExclude(options: FeedClientOptions) { + if (!options?.exclude?.length) { + return undefined; + } + + return options.exclude.join(","); +} From cb1ac3baed25cbb73258cd6e01046376eba10848 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 3 Feb 2026 18:12:11 -0500 Subject: [PATCH 03/17] Update tests --- .../client/test/clients/feed/feed.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 0cdf9a3cf..e3629613a 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1677,4 +1677,83 @@ describe("Feed", () => { } }); }); + + describe("Exclude Option", () => { + test("converts exclude array to comma-separated string in query params", 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({ + exclude: ["entries.archived_at", "meta.total_count"], + }); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", + params: { + archived: "exclude", + mode: "compact", + exclude: "entries.archived_at,meta.total_count", + }, + }); + } finally { + cleanup(); + } + }); + + test("ignores empty exclude array", 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({ exclude: [] }); + + expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", + params: { + archived: "exclude", + mode: "compact", + }, + }); + } finally { + cleanup(); + } + }); + }); }); From c8d8a0b7b5d3e96d73ed1e2a51c0a31064bad913 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 3 Feb 2026 18:14:53 -0500 Subject: [PATCH 04/17] Update tests --- .../client/test/clients/feed/utils.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/client/test/clients/feed/utils.test.ts b/packages/client/test/clients/feed/utils.test.ts index d8395df27..26a5ed19a 100644 --- a/packages/client/test/clients/feed/utils.test.ts +++ b/packages/client/test/clients/feed/utils.test.ts @@ -7,6 +7,7 @@ import type { } from "../../../src/clients/feed/interfaces"; import { deduplicateItems, + getFormattedExclude, getFormattedTriggerData, mergeDateRangeParams, sortItems, @@ -417,4 +418,68 @@ describe("feed utils", () => { }); }); }); + + describe("getFormattedExclude", () => { + test("returns undefined when no exclude option", () => { + const options: FeedClientOptions = { + archived: "exclude", + }; + + const result = getFormattedExclude(options); + + expect(result).toBeUndefined(); + }); + + test("returns undefined when exclude is undefined", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: undefined, + }; + + const result = getFormattedExclude(options); + + expect(result).toBeUndefined(); + }); + + test("returns undefined when exclude is empty array", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: [], + }; + + const result = getFormattedExclude(options); + + expect(result).toBeUndefined(); + }); + + test("returns single field as-is", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: ["entries.archived_at"], + }; + + const result = getFormattedExclude(options); + + expect(result).toBe("entries.archived_at"); + }); + + test("joins multiple fields with commas", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: ["entries.archived_at", "meta.total_count", "entries.data"], + }; + + const result = getFormattedExclude(options); + + expect(result).toBe("entries.archived_at,meta.total_count,entries.data"); + }); + + test("returns undefined for empty options object", () => { + const options: FeedClientOptions = {}; + + const result = getFormattedExclude(options); + + expect(result).toBeUndefined(); + }); + }); }); From 8109bf044e139b8cdbdc2e7d111b58584fc1ed49 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Tue, 3 Feb 2026 18:21:59 -0500 Subject: [PATCH 05/17] Exclude meta when refetching after socket receives new message --- packages/client/src/clients/feed/feed.ts | 7 ++++++- packages/client/src/clients/feed/store.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 90102ae12..96039bfe8 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -656,7 +656,12 @@ class Feed { } // Fetch the items before the current head (if it exists) - this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket" }); + this.fetch({ + before: currentHead?.__cursor, + __fetchSource: "socket", + // Exclude meta since we already have the badge counts from the socket + exclude: ["meta"], + }); } private buildUserFeedId() { diff --git a/packages/client/src/clients/feed/store.ts b/packages/client/src/clients/feed/store.ts index 6e48fedc9..d8c33cb87 100644 --- a/packages/client/src/clients/feed/store.ts +++ b/packages/client/src/clients/feed/store.ts @@ -73,7 +73,9 @@ const initalizeStore = () => { return { ...state, items, - metadata: meta, + // Preserve existing metadata if meta is not provided + // (e.g., when excluded via `exclude` param) + metadata: meta ?? state.metadata, pageInfo: options.shouldSetPage ? page_info : state.pageInfo, loading: false, networkStatus: NetworkStatus.ready, From 5919897765f993632a592d2df7f0619d129efc4e Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 11:08:07 -0500 Subject: [PATCH 06/17] Add changeset and update types --- .changeset/chatty-cars-care.md | 16 ++++++++++++++++ packages/client/src/clients/feed/types.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 .changeset/chatty-cars-care.md diff --git a/.changeset/chatty-cars-care.md b/.changeset/chatty-cars-care.md new file mode 100644 index 000000000..3e22738fd --- /dev/null +++ b/.changeset/chatty-cars-care.md @@ -0,0 +1,16 @@ +--- +"@knocklabs/client": minor +--- + +Exclude metadata when refetching feed after new message received + +If you configure a feed client to listen for events via [`Feed.on()`](https://docs.knock.app/in-app-ui/javascript/sdk/feed-client#on), the payload for feed events of type `"items.received.realtime"` will always have `metadata` set to `undefined`. + +```js +const knockFeed = knock.feeds.initialize(process.env.KNOCK_FEED_CHANNEL_ID); + +knockFeed.on("items.received.realtime", (eventPayload) => { + // eventPayload.metadata will always be undefined here + const { items, metadata } = eventPayload; +}); +``` diff --git a/packages/client/src/clients/feed/types.ts b/packages/client/src/clients/feed/types.ts index b83df1d62..e0c7f66d1 100644 --- a/packages/client/src/clients/feed/types.ts +++ b/packages/client/src/clients/feed/types.ts @@ -56,7 +56,7 @@ export type BindableFeedEvent = FeedEvent | "items.received.*" | "items.*"; export interface FeedEventPayload { event: Omit; items: FeedItem[]; - metadata: FeedMetadata; + metadata?: FeedMetadata; } export type FeedRealTimeCallback = (resp: FeedResponse) => void; From 7664f7e23bdf1b1b044ac5cb042cfa73fd86d79c Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 14:33:18 -0500 Subject: [PATCH 07/17] Add comment and update types --- packages/client/src/clients/feed/feed.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 96039bfe8..146a7934a 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -605,7 +605,8 @@ class Feed { const eventPayload = { items: response.entries as FeedItem[], - metadata: response.meta as FeedMetadata, + // meta will not be present on the response when __fetchSource is "socket" + metadata: response.meta as FeedMetadata | undefined, event: feedEventType, }; From 9209a5671f71a665ea08917f2090406de2c5ef0a Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 14:48:44 -0500 Subject: [PATCH 08/17] Update tests --- packages/client/test/clients/feed/feed.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index e3629613a..40bdf32ab 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1261,8 +1261,16 @@ describe("Feed", () => { await feed.handleSocketEvent(newMessagePayload); - // Should trigger a fetch to get the latest data - expect(mockApiClient.makeRequest).toHaveBeenCalled(); + // Should trigger a fetch to get the latest data with exclude: "meta" + expect(mockApiClient.makeRequest).toHaveBeenCalledWith({ + method: "GET", + url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef", + params: { + archived: "exclude", + mode: "compact", + exclude: "meta", + }, + }); } finally { cleanup(); } From 053bc373f76ee20903cfd0fb897dd1d7a1c55ced Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 17:58:06 -0500 Subject: [PATCH 09/17] Update changeset --- .changeset/chatty-cars-care.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chatty-cars-care.md b/.changeset/chatty-cars-care.md index 3e22738fd..d78249f6c 100644 --- a/.changeset/chatty-cars-care.md +++ b/.changeset/chatty-cars-care.md @@ -4,7 +4,7 @@ Exclude metadata when refetching feed after new message received -If you configure a feed client to listen for events via [`Feed.on()`](https://docs.knock.app/in-app-ui/javascript/sdk/feed-client#on), the payload for feed events of type `"items.received.realtime"` will always have `metadata` set to `undefined`. +Starting with this release, if you configure a feed client to listen for events via [`Feed.on()`](https://docs.knock.app/in-app-ui/javascript/sdk/feed-client#on), the payload for feed events of type `"items.received.realtime"` will always have `metadata` set to `undefined`. ```js const knockFeed = knock.feeds.initialize(process.env.KNOCK_FEED_CHANNEL_ID); From 5ba24dcd831630213700eb04b606fdccc3e115c3 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 18:03:56 -0500 Subject: [PATCH 10/17] Improve implementation of getFormattedExclude --- packages/client/src/clients/feed/utils.ts | 5 +++- .../client/test/clients/feed/utils.test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts index 5b25b8096..45ed2fe2e 100644 --- a/packages/client/src/clients/feed/utils.ts +++ b/packages/client/src/clients/feed/utils.ts @@ -72,5 +72,8 @@ export function getFormattedExclude(options: FeedClientOptions) { return undefined; } - return options.exclude.join(","); + return options.exclude + .map((field) => field.trim()) + .filter((field) => !!field) + .join(","); } diff --git a/packages/client/test/clients/feed/utils.test.ts b/packages/client/test/clients/feed/utils.test.ts index 26a5ed19a..36e1f1746 100644 --- a/packages/client/test/clients/feed/utils.test.ts +++ b/packages/client/test/clients/feed/utils.test.ts @@ -474,6 +474,32 @@ describe("feed utils", () => { expect(result).toBe("entries.archived_at,meta.total_count,entries.data"); }); + test("trims whitespace before joining fields", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: [ + " entries.archived_at ", + "meta.total_count\n", + "\tentries.data ", + ], + }; + + const result = getFormattedExclude(options); + + expect(result).toBe("entries.archived_at,meta.total_count,entries.data"); + }); + + test("filters out empty fields", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: ["entries.archived_at", " ", "entries.data"], + }; + + const result = getFormattedExclude(options); + + expect(result).toBe("entries.archived_at,entries.data"); + }); + test("returns undefined for empty options object", () => { const options: FeedClientOptions = {}; From d8f54ab9cc1a5d31b835947c5454d072d0be9233 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 18:05:40 -0500 Subject: [PATCH 11/17] Update comment --- packages/client/src/clients/feed/interfaces.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts index a2b246e04..b2a633968 100644 --- a/packages/client/src/clients/feed/interfaces.ts +++ b/packages/client/src/clients/feed/interfaces.ts @@ -78,7 +78,7 @@ export type FetchFeedOptionsForRequest = Omit< > & { // Formatted trigger data into a string trigger_data?: string; - // Formatted exclude into a comma-separated string + /** Fields to exclude from the response, joined by commas. */ exclude?: string; // Unset options that should not be sent to the API __loadingType: undefined; From ea5a66e755e81e58c56138e2903bd7c5881577bf Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Wed, 4 Feb 2026 18:14:35 -0500 Subject: [PATCH 12/17] Update tests --- .../client/test/clients/feed/store.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/client/test/clients/feed/store.test.ts b/packages/client/test/clients/feed/store.test.ts index 4e03f8905..c3465fc31 100644 --- a/packages/client/test/clients/feed/store.test.ts +++ b/packages/client/test/clients/feed/store.test.ts @@ -232,6 +232,32 @@ describe("feed store", () => { const state = store.getState(); expect(state.items).toHaveLength(2); // Should deduplicate }); + + test("preserves existing metadata when meta is undefined", () => { + const store = createStore(); + + // Set initial result with metadata + store.getState().setResult({ + entries: mockItems, + meta: mockMetadata, + page_info: mockPageInfo, + }); + + // Verify initial metadata is set + expect(store.getState().metadata).toEqual(mockMetadata); + + // Set new result without meta (simulating exclude param) + store.getState().setResult({ + entries: [mockItems[0]!], + meta: undefined, + page_info: mockPageInfo, + }); + + // Metadata should be preserved + const state = store.getState(); + expect(state.metadata).toEqual(mockMetadata); + expect(state.items).toHaveLength(1); + }); }); describe("setMetadata", () => { From 70c866c0cc08001bf3c3a06fa60df891d661692f Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 5 Feb 2026 11:18:49 -0500 Subject: [PATCH 13/17] Fix getFormattedExclude bug caught by Cursor --- packages/client/src/clients/feed/utils.ts | 7 ++++--- packages/client/test/clients/feed/utils.test.ts | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts index 45ed2fe2e..13eb20dc9 100644 --- a/packages/client/src/clients/feed/utils.ts +++ b/packages/client/src/clients/feed/utils.ts @@ -72,8 +72,9 @@ export function getFormattedExclude(options: FeedClientOptions) { return undefined; } - return options.exclude + const fields = options.exclude .map((field) => field.trim()) - .filter((field) => !!field) - .join(","); + .filter((field) => !!field); + + return fields.length ? fields.join(",") : undefined; } diff --git a/packages/client/test/clients/feed/utils.test.ts b/packages/client/test/clients/feed/utils.test.ts index 36e1f1746..855cea13a 100644 --- a/packages/client/test/clients/feed/utils.test.ts +++ b/packages/client/test/clients/feed/utils.test.ts @@ -507,5 +507,16 @@ describe("feed utils", () => { expect(result).toBeUndefined(); }); + + test("returns undefined when all fields are whitespace-only", () => { + const options: FeedClientOptions = { + archived: "exclude", + exclude: [" ", " ", "\t", "\n"], + }; + + const result = getFormattedExclude(options); + + expect(result).toBeUndefined(); + }); }); }); From 918f3305f80c506f7e4ad93992fd63d2470dc8ad Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 5 Feb 2026 11:54:53 -0500 Subject: [PATCH 14/17] Gracefully handle when socket lacks metadata --- packages/client/src/clients/feed/feed.ts | 5 +- .../client/test/clients/feed/feed.test.ts | 52 ++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 146a7934a..3f82f83b7 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -660,8 +660,9 @@ class Feed { this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket", - // Exclude meta since we already have the badge counts from the socket - exclude: ["meta"], + // The socket event payload should *always* include metadata, + // but to be safe, only exclude meta from the fetch when it's present + exclude: metadata ? ["meta"] : [], }); } diff --git a/packages/client/test/clients/feed/feed.test.ts b/packages/client/test/clients/feed/feed.test.ts index 40bdf32ab..d2b52a732 100644 --- a/packages/client/test/clients/feed/feed.test.ts +++ b/packages/client/test/clients/feed/feed.test.ts @@ -1253,7 +1253,7 @@ describe("Feed", () => { event: "new-message" as const, metadata: { total_count: 2, unread_count: 2, unseen_count: 2 }, data: { - client_ref_id: { + [feed.referenceId]: { metadata: { total_count: 2, unread_count: 2, unseen_count: 2 }, }, }, @@ -1276,6 +1276,56 @@ describe("Feed", () => { } }); + test("handles new message socket events without metadata in payload", async () => { + const { knock, mockApiClient, cleanup } = getTestSetup(); + + try { + const mockSocketManager = { + join: vi.fn().mockReturnValue(vi.fn()), + leave: vi.fn(), + }; + + // Mock the store response for the feed fetch + mockApiClient.makeRequest.mockResolvedValue({ + statusCode: "ok", + body: { + entries: [], + page_info: { before: null, after: null }, + meta: { total_count: 1, unread_count: 1, unseen_count: 1 }, + }, + }); + + const feed = new Feed( + knock, + "01234567-89ab-cdef-0123-456789abcdef", + {}, + mockSocketManager as unknown as FeedSocketManager, + ); + + // Payload lacking metadata for this client's referenceId. + // This should never happen in practice, but we should handle it gracefully. + const newMessagePayload = { + event: "new-message" as const, + metadata: { total_count: 2, unread_count: 2, unseen_count: 2 }, + data: {}, + }; + + await feed.handleSocketEvent(newMessagePayload); + + // Should trigger a fetch WITHOUT exclude: "meta" to get badge counts from API + 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("initializes realtime connection with socket manager", () => { const { knock, cleanup } = getTestSetup(); From 28ba9059658f378d4ac13593169eb786bf48b99c Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 5 Feb 2026 13:20:20 -0500 Subject: [PATCH 15/17] Add mergeExcludeArrays --- packages/client/src/clients/feed/feed.ts | 7 +- packages/client/src/clients/feed/utils.ts | 12 +++ .../client/test/clients/feed/utils.test.ts | 75 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 3f82f83b7..991f6a9d7 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -37,6 +37,7 @@ import { getFormattedExclude, getFormattedTriggerData, mergeDateRangeParams, + mergeExcludeArrays, } from "./utils"; // Default options to apply @@ -540,7 +541,11 @@ class Feed { // Set the loading type based on the request type it is state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading); - const mergedOptions = { ...this.defaultOptions, ...options }; + const mergedOptions = { + ...this.defaultOptions, + ...options, + exclude: mergeExcludeArrays(this.defaultOptions.exclude, options.exclude), + }; // trigger_data should be a JSON string for the API // this function will format the trigger data if it's an object diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts index 13eb20dc9..a47e88e3d 100644 --- a/packages/client/src/clients/feed/utils.ts +++ b/packages/client/src/clients/feed/utils.ts @@ -78,3 +78,15 @@ export function getFormattedExclude(options: FeedClientOptions) { return fields.length ? fields.join(",") : undefined; } + +/** + * Merges two exclude arrays, deduplicating values. + * Returns undefined if the merged result is empty. + */ +export function mergeExcludeArrays( + exclude1: string[] | undefined, + exclude2: string[] | undefined, +): string[] | undefined { + const merged = [...(exclude1 ?? []), ...(exclude2 ?? [])]; + return merged.length ? [...new Set(merged)] : undefined; +} diff --git a/packages/client/test/clients/feed/utils.test.ts b/packages/client/test/clients/feed/utils.test.ts index 855cea13a..ce903012c 100644 --- a/packages/client/test/clients/feed/utils.test.ts +++ b/packages/client/test/clients/feed/utils.test.ts @@ -10,6 +10,7 @@ import { getFormattedExclude, getFormattedTriggerData, mergeDateRangeParams, + mergeExcludeArrays, sortItems, } from "../../../src/clients/feed/utils"; @@ -519,4 +520,78 @@ describe("feed utils", () => { expect(result).toBeUndefined(); }); }); + + describe("mergeExcludeArrays", () => { + test("returns undefined when both arrays are undefined", () => { + const result = mergeExcludeArrays(undefined, undefined); + expect(result).toBeUndefined(); + }); + + test("returns undefined when both arrays are empty", () => { + const result = mergeExcludeArrays([], []); + expect(result).toBeUndefined(); + }); + + test("returns default exclude when options exclude is undefined", () => { + const result = mergeExcludeArrays(["entries.archived_at"], undefined); + expect(result).toEqual(["entries.archived_at"]); + }); + + test("returns default exclude when options exclude is empty", () => { + const result = mergeExcludeArrays(["entries.archived_at"], []); + expect(result).toEqual(["entries.archived_at"]); + }); + + test("returns options exclude when default exclude is undefined", () => { + const result = mergeExcludeArrays(undefined, ["meta"]); + expect(result).toEqual(["meta"]); + }); + + test("returns options exclude when default exclude is empty", () => { + const result = mergeExcludeArrays([], ["meta"]); + expect(result).toEqual(["meta"]); + }); + + test("merges both exclude arrays", () => { + const result = mergeExcludeArrays(["entries.archived_at"], ["meta"]); + expect(result).toEqual(["entries.archived_at", "meta"]); + }); + + test("merges multiple fields from both arrays", () => { + const result = mergeExcludeArrays( + ["entries.archived_at", "entries.data"], + ["meta", "entries.blocks"], + ); + expect(result).toEqual([ + "entries.archived_at", + "entries.data", + "meta", + "entries.blocks", + ]); + }); + + test("deduplicates merged arrays", () => { + const result = mergeExcludeArrays( + ["entries.archived_at", "meta"], + ["meta", "entries.data"], + ); + expect(result).toEqual(["entries.archived_at", "meta", "entries.data"]); + }); + + test("handles duplicate values in default exclude", () => { + const result = mergeExcludeArrays( + ["entries.archived_at", "entries.archived_at"], + ["meta"], + ); + expect(result).toEqual(["entries.archived_at", "meta"]); + }); + + test("handles duplicate values in options exclude", () => { + const result = mergeExcludeArrays( + ["entries.archived_at"], + ["meta", "meta"], + ); + expect(result).toEqual(["entries.archived_at", "meta"]); + }); + }); }); From 0bd81d86ffc6f55e188b225dee3ae403cfd0b383 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 5 Feb 2026 13:26:40 -0500 Subject: [PATCH 16/17] Change implementation to mergeAndDedupeArrays --- packages/client/src/clients/feed/feed.ts | 7 +- packages/client/src/clients/feed/utils.ts | 12 +-- .../client/test/clients/feed/utils.test.ts | 74 ++++++------------- 3 files changed, 32 insertions(+), 61 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 991f6a9d7..1cdc7d07c 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -36,8 +36,8 @@ import { import { getFormattedExclude, getFormattedTriggerData, + mergeAndDedupeArrays, mergeDateRangeParams, - mergeExcludeArrays, } from "./utils"; // Default options to apply @@ -544,7 +544,10 @@ class Feed { const mergedOptions = { ...this.defaultOptions, ...options, - exclude: mergeExcludeArrays(this.defaultOptions.exclude, options.exclude), + exclude: mergeAndDedupeArrays( + this.defaultOptions.exclude, + options.exclude, + ), }; // trigger_data should be a JSON string for the API diff --git a/packages/client/src/clients/feed/utils.ts b/packages/client/src/clients/feed/utils.ts index a47e88e3d..e4cbb5bd4 100644 --- a/packages/client/src/clients/feed/utils.ts +++ b/packages/client/src/clients/feed/utils.ts @@ -80,13 +80,13 @@ export function getFormattedExclude(options: FeedClientOptions) { } /** - * Merges two exclude arrays, deduplicating values. + * Merges two arrays, deduplicating values. * Returns undefined if the merged result is empty. */ -export function mergeExcludeArrays( - exclude1: string[] | undefined, - exclude2: string[] | undefined, -): string[] | undefined { - const merged = [...(exclude1 ?? []), ...(exclude2 ?? [])]; +export function mergeAndDedupeArrays( + array1: T[] | undefined, + array2: T[] | undefined, +): T[] | undefined { + const merged = [...(array1 ?? []), ...(array2 ?? [])]; return merged.length ? [...new Set(merged)] : undefined; } diff --git a/packages/client/test/clients/feed/utils.test.ts b/packages/client/test/clients/feed/utils.test.ts index ce903012c..9bcb581fe 100644 --- a/packages/client/test/clients/feed/utils.test.ts +++ b/packages/client/test/clients/feed/utils.test.ts @@ -9,8 +9,8 @@ import { deduplicateItems, getFormattedExclude, getFormattedTriggerData, + mergeAndDedupeArrays, mergeDateRangeParams, - mergeExcludeArrays, sortItems, } from "../../../src/clients/feed/utils"; @@ -521,77 +521,45 @@ describe("feed utils", () => { }); }); - describe("mergeExcludeArrays", () => { + describe("mergeAndDedupeArrays", () => { test("returns undefined when both arrays are undefined", () => { - const result = mergeExcludeArrays(undefined, undefined); + const result = mergeAndDedupeArrays(undefined, undefined); expect(result).toBeUndefined(); }); test("returns undefined when both arrays are empty", () => { - const result = mergeExcludeArrays([], []); + const result = mergeAndDedupeArrays([], []); expect(result).toBeUndefined(); }); - test("returns default exclude when options exclude is undefined", () => { - const result = mergeExcludeArrays(["entries.archived_at"], undefined); - expect(result).toEqual(["entries.archived_at"]); + test("returns first array when second is undefined", () => { + const result = mergeAndDedupeArrays(["a", "b"], undefined); + expect(result).toEqual(["a", "b"]); }); - test("returns default exclude when options exclude is empty", () => { - const result = mergeExcludeArrays(["entries.archived_at"], []); - expect(result).toEqual(["entries.archived_at"]); + test("returns first array when second is empty", () => { + const result = mergeAndDedupeArrays(["a", "b"], []); + expect(result).toEqual(["a", "b"]); }); - test("returns options exclude when default exclude is undefined", () => { - const result = mergeExcludeArrays(undefined, ["meta"]); - expect(result).toEqual(["meta"]); + test("returns second array when first is undefined", () => { + const result = mergeAndDedupeArrays(undefined, ["c", "d"]); + expect(result).toEqual(["c", "d"]); }); - test("returns options exclude when default exclude is empty", () => { - const result = mergeExcludeArrays([], ["meta"]); - expect(result).toEqual(["meta"]); + test("returns second array when first is empty", () => { + const result = mergeAndDedupeArrays([], ["c", "d"]); + expect(result).toEqual(["c", "d"]); }); - test("merges both exclude arrays", () => { - const result = mergeExcludeArrays(["entries.archived_at"], ["meta"]); - expect(result).toEqual(["entries.archived_at", "meta"]); - }); - - test("merges multiple fields from both arrays", () => { - const result = mergeExcludeArrays( - ["entries.archived_at", "entries.data"], - ["meta", "entries.blocks"], - ); - expect(result).toEqual([ - "entries.archived_at", - "entries.data", - "meta", - "entries.blocks", - ]); + test("merges both arrays", () => { + const result = mergeAndDedupeArrays(["a", "b"], ["c", "d"]); + expect(result).toEqual(["a", "b", "c", "d"]); }); test("deduplicates merged arrays", () => { - const result = mergeExcludeArrays( - ["entries.archived_at", "meta"], - ["meta", "entries.data"], - ); - expect(result).toEqual(["entries.archived_at", "meta", "entries.data"]); - }); - - test("handles duplicate values in default exclude", () => { - const result = mergeExcludeArrays( - ["entries.archived_at", "entries.archived_at"], - ["meta"], - ); - expect(result).toEqual(["entries.archived_at", "meta"]); - }); - - test("handles duplicate values in options exclude", () => { - const result = mergeExcludeArrays( - ["entries.archived_at"], - ["meta", "meta"], - ); - expect(result).toEqual(["entries.archived_at", "meta"]); + const result = mergeAndDedupeArrays(["a", "b"], ["b", "c"]); + expect(result).toEqual(["a", "b", "c"]); }); }); }); From b67f43670048ee8bd735616f8854e2410a565dd6 Mon Sep 17 00:00:00 2001 From: Matthew Mikolay Date: Thu, 5 Feb 2026 17:46:54 -0500 Subject: [PATCH 17/17] Use mergedOptions for query params --- packages/client/src/clients/feed/feed.ts | 7 +++---- packages/client/src/clients/feed/interfaces.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/client/src/clients/feed/feed.ts b/packages/client/src/clients/feed/feed.ts index 1cdc7d07c..b4cd71055 100644 --- a/packages/client/src/clients/feed/feed.ts +++ b/packages/client/src/clients/feed/feed.ts @@ -543,7 +543,7 @@ class Feed { const mergedOptions = { ...this.defaultOptions, - ...options, + ...mergeDateRangeParams(options), exclude: mergeAndDedupeArrays( this.defaultOptions.exclude, options.exclude, @@ -552,15 +552,14 @@ class Feed { // trigger_data should be a JSON string for the API // this function will format the trigger data if it's an object - // https://docs.knock.app/reference#get-feed + // https://docs.knock.app/api-reference/users/feeds/list_items const formattedTriggerData = getFormattedTriggerData(mergedOptions); const formattedExclude = getFormattedExclude(mergedOptions); // Always include the default params, if they have been set const queryParams: FetchFeedOptionsForRequest = { - ...this.defaultOptions, - ...mergeDateRangeParams(options), + ...mergedOptions, trigger_data: formattedTriggerData, exclude: formattedExclude, // Unset options that should not be sent to the API diff --git a/packages/client/src/clients/feed/interfaces.ts b/packages/client/src/clients/feed/interfaces.ts index b2a633968..f90b2c75e 100644 --- a/packages/client/src/clients/feed/interfaces.ts +++ b/packages/client/src/clients/feed/interfaces.ts @@ -70,8 +70,11 @@ export type FetchFeedOptions = { __fetchSource?: "socket" | "http"; } & Omit; -// The final data shape that is sent to the API -// Should match types here: https://docs.knock.app/reference#get-feed +/** + * The final data shape that is sent to the the list feed items endpoint of the Knock API. + * + * @see https://docs.knock.app/api-reference/users/feeds/list_items + */ export type FetchFeedOptionsForRequest = Omit< FeedClientOptions, "trigger_data" | "exclude" @@ -80,6 +83,10 @@ export type FetchFeedOptionsForRequest = Omit< trigger_data?: string; /** Fields to exclude from the response, joined by commas. */ exclude?: string; + "inserted_at.gte"?: string; + "inserted_at.lte"?: string; + "inserted_at.gt"?: string; + "inserted_at.lt"?: string; // Unset options that should not be sent to the API __loadingType: undefined; __fetchSource: undefined;