Skip to content
Merged
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
16 changes: 0 additions & 16 deletions .changeset/chatty-cars-care.md

This file was deleted.

40 changes: 10 additions & 30 deletions packages/client/src/clients/feed/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ import {
FeedMessagesReceivedPayload,
FeedRealTimeCallback,
} from "./types";
import {
getFormattedExclude,
getFormattedTriggerData,
mergeAndDedupeArrays,
mergeDateRangeParams,
} from "./utils";
import { getFormattedTriggerData, mergeDateRangeParams } from "./utils";

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

const mergedOptions = {
...this.defaultOptions,
...mergeDateRangeParams(options),
exclude: mergeAndDedupeArrays(
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
// https://docs.knock.app/api-reference/users/feeds/list_items
const formattedTriggerData = getFormattedTriggerData(mergedOptions);

const formattedExclude = getFormattedExclude(mergedOptions);
// https://docs.knock.app/reference#get-feed
const formattedTriggerData = getFormattedTriggerData({
...this.defaultOptions,
...options,
});

// Always include the default params, if they have been set
const queryParams: FetchFeedOptionsForRequest = {
...mergedOptions,
...this.defaultOptions,
...mergeDateRangeParams(options),
trigger_data: formattedTriggerData,
exclude: formattedExclude,
// Unset options that should not be sent to the API
__loadingType: undefined,
__fetchSource: undefined,
Expand Down Expand Up @@ -612,8 +599,7 @@ class Feed {

const eventPayload = {
items: response.entries as FeedItem[],
// meta will not be present on the response when __fetchSource is "socket"
metadata: response.meta as FeedMetadata | undefined,
metadata: response.meta as FeedMetadata,
event: feedEventType,
};

Expand Down Expand Up @@ -664,13 +650,7 @@ class Feed {
}

// Fetch the items before the current head (if it exists)
this.fetch({
before: currentHead?.__cursor,
__fetchSource: "socket",
// 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"] : [],
});
this.fetch({ before: currentHead?.__cursor, __fetchSource: "socket" });
}

private buildUserFeedId() {
Expand Down
20 changes: 3 additions & 17 deletions packages/client/src/clients/feed/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,35 +58,21 @@ 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 = {
__loadingType?: NetworkStatus.loading | NetworkStatus.fetchMore;
__fetchSource?: "socket" | "http";
} & Omit<FeedClientOptions, "__experimentalCrossBrowserUpdates">;

/**
* 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
*/
// The final data shape that is sent to the API
// Should match types here: https://docs.knock.app/reference#get-feed
export type FetchFeedOptionsForRequest = Omit<
FeedClientOptions,
"trigger_data" | "exclude"
"trigger_data"
> & {
// Formatted trigger data into a string
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;
Comment on lines -86 to -89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thomas asked the same question here. I added these in #853 because they were missing from the FetchFeedOptionsForRequest type. I can add them back in a separate PR, though I don’t think it’s much of an issue, since the FetchFeedOptionsForRequest type is only meant for internal use by the client SDK.

// Unset options that should not be sent to the API
__loadingType: undefined;
__fetchSource: undefined;
Expand Down
4 changes: 1 addition & 3 deletions packages/client/src/clients/feed/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ const initalizeStore = () => {
return {
...state,
items,
// Preserve existing metadata if meta is not provided
// (e.g., when excluded via `exclude` param)
metadata: meta ?? state.metadata,
metadata: meta,
pageInfo: options.shouldSetPage ? page_info : state.pageInfo,
loading: false,
networkStatus: NetworkStatus.ready,
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/clients/feed/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export type BindableFeedEvent = FeedEvent | "items.received.*" | "items.*";
export interface FeedEventPayload<T = GenericData> {
event: Omit<FeedEvent, "messages.new">;
items: FeedItem<T>[];
metadata?: FeedMetadata;
metadata: FeedMetadata;
}

export type FeedRealTimeCallback = (resp: FeedResponse) => void;
Expand Down
24 changes: 0 additions & 24 deletions packages/client/src/clients/feed/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,27 +66,3 @@ export function getFormattedTriggerData(options: FeedClientOptions) {

return undefined;
}

export function getFormattedExclude(options: FeedClientOptions) {
if (!options?.exclude?.length) {
return undefined;
}

const fields = options.exclude
.map((field) => field.trim())
.filter((field) => !!field);

return fields.length ? fields.join(",") : undefined;
}

/**
* Merges two arrays, deduplicating values.
* Returns undefined if the merged result is empty.
*/
export function mergeAndDedupeArrays<T>(
array1: T[] | undefined,
array2: T[] | undefined,
): T[] | undefined {
const merged = [...(array1 ?? []), ...(array2 ?? [])];
return merged.length ? [...new Set(merged)] : undefined;
}
143 changes: 3 additions & 140 deletions packages/client/test/clients/feed/feed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1253,74 +1253,16 @@ describe("Feed", () => {
event: "new-message" as const,
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
data: {
[feed.referenceId]: {
client_ref_id: {
Comment thread
mattmikolay marked this conversation as resolved.
metadata: { total_count: 2, unread_count: 2, unseen_count: 2 },
},
},
};

await feed.handleSocketEvent(newMessagePayload);

// 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();
}
});

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",
},
});
// Should trigger a fetch to get the latest data
expect(mockApiClient.makeRequest).toHaveBeenCalled();
} finally {
cleanup();
}
Expand Down Expand Up @@ -1735,83 +1677,4 @@ 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();
}
});
});
});
26 changes: 0 additions & 26 deletions packages/client/test/clients/feed/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,32 +232,6 @@ 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", () => {
Expand Down
Loading
Loading