Skip to content

Commit 1e75952

Browse files
authored
chore(KNO-11486): exclude metadata when refetching feed after new message received (#853)
1 parent 2af3f5e commit 1e75952

9 files changed

Lines changed: 402 additions & 18 deletions

File tree

.changeset/chatty-cars-care.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@knocklabs/client": minor
3+
---
4+
5+
Exclude metadata when refetching feed after new message received
6+
7+
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`.
8+
9+
```js
10+
const knockFeed = knock.feeds.initialize(process.env.KNOCK_FEED_CHANNEL_ID);
11+
12+
knockFeed.on("items.received.realtime", (eventPayload) => {
13+
// eventPayload.metadata will always be undefined here
14+
const { items, metadata } = eventPayload;
15+
});
16+
```

packages/client/src/clients/feed/feed.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ import {
3333
FeedMessagesReceivedPayload,
3434
FeedRealTimeCallback,
3535
} from "./types";
36-
import { getFormattedTriggerData, mergeDateRangeParams } from "./utils";
36+
import {
37+
getFormattedExclude,
38+
getFormattedTriggerData,
39+
mergeAndDedupeArrays,
40+
mergeDateRangeParams,
41+
} from "./utils";
3742

3843
// Default options to apply
3944
const feedClientDefaults: Pick<FeedClientOptions, "archived" | "mode"> = {
@@ -536,19 +541,27 @@ class Feed {
536541
// Set the loading type based on the request type it is
537542
state.setNetworkStatus(options.__loadingType ?? NetworkStatus.loading);
538543

544+
const mergedOptions = {
545+
...this.defaultOptions,
546+
...mergeDateRangeParams(options),
547+
exclude: mergeAndDedupeArrays(
548+
this.defaultOptions.exclude,
549+
options.exclude,
550+
),
551+
};
552+
539553
// trigger_data should be a JSON string for the API
540554
// this function will format the trigger data if it's an object
541-
// https://docs.knock.app/reference#get-feed
542-
const formattedTriggerData = getFormattedTriggerData({
543-
...this.defaultOptions,
544-
...options,
545-
});
555+
// https://docs.knock.app/api-reference/users/feeds/list_items
556+
const formattedTriggerData = getFormattedTriggerData(mergedOptions);
557+
558+
const formattedExclude = getFormattedExclude(mergedOptions);
546559

547560
// Always include the default params, if they have been set
548561
const queryParams: FetchFeedOptionsForRequest = {
549-
...this.defaultOptions,
550-
...mergeDateRangeParams(options),
562+
...mergedOptions,
551563
trigger_data: formattedTriggerData,
564+
exclude: formattedExclude,
552565
// Unset options that should not be sent to the API
553566
__loadingType: undefined,
554567
__fetchSource: undefined,
@@ -599,7 +612,8 @@ class Feed {
599612

600613
const eventPayload = {
601614
items: response.entries as FeedItem[],
602-
metadata: response.meta as FeedMetadata,
615+
// meta will not be present on the response when __fetchSource is "socket"
616+
metadata: response.meta as FeedMetadata | undefined,
603617
event: feedEventType,
604618
};
605619

@@ -650,7 +664,13 @@ class Feed {
650664
}
651665

652666
// Fetch the items before the current head (if it exists)
653-
this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket" });
667+
this.fetch({
668+
before: currentHead?.__cursor,
669+
__fetchSource: "socket",
670+
// The socket event payload should *always* include metadata,
671+
// but to be safe, only exclude meta from the fetch when it's present
672+
exclude: metadata ? ["meta"] : [],
673+
});
654674
}
655675

656676
private buildUserFeedId() {

packages/client/src/clients/feed/interfaces.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,35 @@ export interface FeedClientOptions {
5858
* @default "compact"
5959
*/
6060
mode?: "rich" | "compact";
61+
/**
62+
* Field paths to exclude from the response. Use dot notation for nested fields
63+
* (e.g., "entries.archived_at"). Limited to 3 levels deep.
64+
*/
65+
exclude?: string[];
6166
}
6267

6368
export type FetchFeedOptions = {
6469
__loadingType?: NetworkStatus.loading | NetworkStatus.fetchMore;
6570
__fetchSource?: "socket" | "http";
6671
} & Omit<FeedClientOptions, "__experimentalCrossBrowserUpdates">;
6772

68-
// The final data shape that is sent to the API
69-
// Should match types here: https://docs.knock.app/reference#get-feed
73+
/**
74+
* The final data shape that is sent to the the list feed items endpoint of the Knock API.
75+
*
76+
* @see https://docs.knock.app/api-reference/users/feeds/list_items
77+
*/
7078
export type FetchFeedOptionsForRequest = Omit<
7179
FeedClientOptions,
72-
"trigger_data"
80+
"trigger_data" | "exclude"
7381
> & {
7482
// Formatted trigger data into a string
7583
trigger_data?: string;
84+
/** Fields to exclude from the response, joined by commas. */
85+
exclude?: string;
86+
"inserted_at.gte"?: string;
87+
"inserted_at.lte"?: string;
88+
"inserted_at.gt"?: string;
89+
"inserted_at.lt"?: string;
7690
// Unset options that should not be sent to the API
7791
__loadingType: undefined;
7892
__fetchSource: undefined;

packages/client/src/clients/feed/store.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ const initalizeStore = () => {
7373
return {
7474
...state,
7575
items,
76-
metadata: meta,
76+
// Preserve existing metadata if meta is not provided
77+
// (e.g., when excluded via `exclude` param)
78+
metadata: meta ?? state.metadata,
7779
pageInfo: options.shouldSetPage ? page_info : state.pageInfo,
7880
loading: false,
7981
networkStatus: NetworkStatus.ready,

packages/client/src/clients/feed/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export type BindableFeedEvent = FeedEvent | "items.received.*" | "items.*";
5656
export interface FeedEventPayload<T = GenericData> {
5757
event: Omit<FeedEvent, "messages.new">;
5858
items: FeedItem<T>[];
59-
metadata: FeedMetadata;
59+
metadata?: FeedMetadata;
6060
}
6161

6262
export type FeedRealTimeCallback = (resp: FeedResponse) => void;

packages/client/src/clients/feed/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,27 @@ export function getFormattedTriggerData(options: FeedClientOptions) {
6666

6767
return undefined;
6868
}
69+
70+
export function getFormattedExclude(options: FeedClientOptions) {
71+
if (!options?.exclude?.length) {
72+
return undefined;
73+
}
74+
75+
const fields = options.exclude
76+
.map((field) => field.trim())
77+
.filter((field) => !!field);
78+
79+
return fields.length ? fields.join(",") : undefined;
80+
}
81+
82+
/**
83+
* Merges two arrays, deduplicating values.
84+
* Returns undefined if the merged result is empty.
85+
*/
86+
export function mergeAndDedupeArrays<T>(
87+
array1: T[] | undefined,
88+
array2: T[] | undefined,
89+
): T[] | undefined {
90+
const merged = [...(array1 ?? []), ...(array2 ?? [])];
91+
return merged.length ? [...new Set(merged)] : undefined;
92+
}

packages/client/test/clients/feed/feed.test.ts

Lines changed: 140 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,16 +1253,74 @@ describe("Feed", () => {
12531253
event: "new-message" as const,
12541254
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
12551255
data: {
1256-
client_ref_id: {
1256+
[feed.referenceId]: {
12571257
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
12581258
},
12591259
},
12601260
};
12611261

12621262
await feed.handleSocketEvent(newMessagePayload);
12631263

1264-
// Should trigger a fetch to get the latest data
1265-
expect(mockApiClient.makeRequest).toHaveBeenCalled();
1264+
// Should trigger a fetch to get the latest data with exclude: "meta"
1265+
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
1266+
method: "GET",
1267+
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
1268+
params: {
1269+
archived: "exclude",
1270+
mode: "compact",
1271+
exclude: "meta",
1272+
},
1273+
});
1274+
} finally {
1275+
cleanup();
1276+
}
1277+
});
1278+
1279+
test("handles new message socket events without metadata in payload", async () => {
1280+
const { knock, mockApiClient, cleanup } = getTestSetup();
1281+
1282+
try {
1283+
const mockSocketManager = {
1284+
join: vi.fn().mockReturnValue(vi.fn()),
1285+
leave: vi.fn(),
1286+
};
1287+
1288+
// Mock the store response for the feed fetch
1289+
mockApiClient.makeRequest.mockResolvedValue({
1290+
statusCode: "ok",
1291+
body: {
1292+
entries: [],
1293+
page_info: { before: null, after: null },
1294+
meta: { total_count: 1, unread_count: 1, unseen_count: 1 },
1295+
},
1296+
});
1297+
1298+
const feed = new Feed(
1299+
knock,
1300+
"01234567-89ab-cdef-0123-456789abcdef",
1301+
{},
1302+
mockSocketManager as unknown as FeedSocketManager,
1303+
);
1304+
1305+
// Payload lacking metadata for this client's referenceId.
1306+
// This should never happen in practice, but we should handle it gracefully.
1307+
const newMessagePayload = {
1308+
event: "new-message" as const,
1309+
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
1310+
data: {},
1311+
};
1312+
1313+
await feed.handleSocketEvent(newMessagePayload);
1314+
1315+
// Should trigger a fetch WITHOUT exclude: "meta" to get badge counts from API
1316+
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
1317+
method: "GET",
1318+
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
1319+
params: {
1320+
archived: "exclude",
1321+
mode: "compact",
1322+
},
1323+
});
12661324
} finally {
12671325
cleanup();
12681326
}
@@ -1677,4 +1735,83 @@ describe("Feed", () => {
16771735
}
16781736
});
16791737
});
1738+
1739+
describe("Exclude Option", () => {
1740+
test("converts exclude array to comma-separated string in query params", async () => {
1741+
const { knock, mockApiClient, cleanup } = getTestSetup();
1742+
1743+
try {
1744+
const mockFeedResponse = {
1745+
entries: [],
1746+
meta: { total_count: 0, unread_count: 0, unseen_count: 0 },
1747+
page_info: { before: null, after: null, page_size: 50 },
1748+
};
1749+
1750+
mockApiClient.makeRequest.mockResolvedValue({
1751+
statusCode: "ok",
1752+
body: mockFeedResponse,
1753+
});
1754+
1755+
const feed = new Feed(
1756+
knock,
1757+
"01234567-89ab-cdef-0123-456789abcdef",
1758+
{},
1759+
undefined,
1760+
);
1761+
1762+
await feed.fetch({
1763+
exclude: ["entries.archived_at", "meta.total_count"],
1764+
});
1765+
1766+
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
1767+
method: "GET",
1768+
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
1769+
params: {
1770+
archived: "exclude",
1771+
mode: "compact",
1772+
exclude: "entries.archived_at,meta.total_count",
1773+
},
1774+
});
1775+
} finally {
1776+
cleanup();
1777+
}
1778+
});
1779+
1780+
test("ignores empty exclude array", async () => {
1781+
const { knock, mockApiClient, cleanup } = getTestSetup();
1782+
1783+
try {
1784+
const mockFeedResponse = {
1785+
entries: [],
1786+
meta: { total_count: 0, unread_count: 0, unseen_count: 0 },
1787+
page_info: { before: null, after: null, page_size: 50 },
1788+
};
1789+
1790+
mockApiClient.makeRequest.mockResolvedValue({
1791+
statusCode: "ok",
1792+
body: mockFeedResponse,
1793+
});
1794+
1795+
const feed = new Feed(
1796+
knock,
1797+
"01234567-89ab-cdef-0123-456789abcdef",
1798+
{},
1799+
undefined,
1800+
);
1801+
1802+
await feed.fetch({ exclude: [] });
1803+
1804+
expect(mockApiClient.makeRequest).toHaveBeenCalledWith({
1805+
method: "GET",
1806+
url: "/v1/users/user_123/feeds/01234567-89ab-cdef-0123-456789abcdef",
1807+
params: {
1808+
archived: "exclude",
1809+
mode: "compact",
1810+
},
1811+
});
1812+
} finally {
1813+
cleanup();
1814+
}
1815+
});
1816+
});
16801817
});

packages/client/test/clients/feed/store.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,32 @@ describe("feed store", () => {
232232
const state = store.getState();
233233
expect(state.items).toHaveLength(2); // Should deduplicate
234234
});
235+
236+
test("preserves existing metadata when meta is undefined", () => {
237+
const store = createStore();
238+
239+
// Set initial result with metadata
240+
store.getState().setResult({
241+
entries: mockItems,
242+
meta: mockMetadata,
243+
page_info: mockPageInfo,
244+
});
245+
246+
// Verify initial metadata is set
247+
expect(store.getState().metadata).toEqual(mockMetadata);
248+
249+
// Set new result without meta (simulating exclude param)
250+
store.getState().setResult({
251+
entries: [mockItems[0]!],
252+
meta: undefined,
253+
page_info: mockPageInfo,
254+
});
255+
256+
// Metadata should be preserved
257+
const state = store.getState();
258+
expect(state.metadata).toEqual(mockMetadata);
259+
expect(state.items).toHaveLength(1);
260+
});
235261
});
236262

237263
describe("setMetadata", () => {

0 commit comments

Comments
 (0)