@knocklabs/client 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cjs/clients/feed/feed.js +1 -1
- package/dist/cjs/clients/feed/feed.js.map +1 -1
- package/dist/cjs/clients/feed/utils.js +1 -1
- package/dist/cjs/clients/feed/utils.js.map +1 -1
- package/dist/cjs/clients/guide/client.js +2 -0
- package/dist/cjs/clients/guide/client.js.map +1 -0
- package/dist/cjs/clients/users/index.js +1 -1
- package/dist/cjs/clients/users/index.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/esm/clients/feed/feed.mjs +72 -83
- package/dist/esm/clients/feed/feed.mjs.map +1 -1
- package/dist/esm/clients/feed/utils.mjs +23 -7
- package/dist/esm/clients/feed/utils.mjs.map +1 -1
- package/dist/esm/clients/guide/client.mjs +225 -0
- package/dist/esm/clients/guide/client.mjs.map +1 -0
- package/dist/esm/clients/users/index.mjs +30 -13
- package/dist/esm/clients/users/index.mjs.map +1 -1
- package/dist/esm/index.mjs +9 -7
- package/dist/esm/index.mjs.map +1 -1
- package/dist/types/clients/feed/feed.d.ts.map +1 -1
- package/dist/types/clients/feed/utils.d.ts +16 -1
- package/dist/types/clients/feed/utils.d.ts.map +1 -1
- package/dist/types/clients/guide/client.d.ts +103 -0
- package/dist/types/clients/guide/client.d.ts.map +1 -0
- package/dist/types/clients/guide/index.d.ts +3 -0
- package/dist/types/clients/guide/index.d.ts.map +1 -0
- package/dist/types/clients/users/index.d.ts +3 -0
- package/dist/types/clients/users/index.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/clients/feed/feed.ts +7 -34
- package/src/clients/feed/utils.ts +28 -1
- package/src/clients/guide/client.ts +541 -0
- package/src/clients/guide/index.ts +7 -0
- package/src/clients/users/index.ts +27 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knocklabs/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The clientside library for interacting with Knock",
|
|
5
5
|
"homepage": "https://github.com/knocklabs/javascript/tree/main/packages/client",
|
|
6
6
|
"author": "@knocklabs",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@babel/preset-typescript": "^7.26.0",
|
|
57
57
|
"@types/jsonwebtoken": "^9.0.9",
|
|
58
58
|
"@typescript-eslint/eslint-plugin": "^8.19.1",
|
|
59
|
-
"@typescript-eslint/parser": "^8.
|
|
59
|
+
"@typescript-eslint/parser": "^8.27.0",
|
|
60
60
|
"cross-env": "^7.0.3",
|
|
61
61
|
"crypto": "^1.0.1",
|
|
62
62
|
"eslint": "^8.56.0",
|
|
@@ -69,10 +69,11 @@
|
|
|
69
69
|
"vitest": "^2.1.8"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@babel/runtime": "^7.
|
|
72
|
+
"@babel/runtime": "^7.27.0",
|
|
73
73
|
"@knocklabs/types": "^0.1.5",
|
|
74
|
+
"@tanstack/store": "^0.5.5",
|
|
74
75
|
"@types/phoenix": "^1.6.6",
|
|
75
|
-
"axios": "^1.8.
|
|
76
|
+
"axios": "^1.8.4",
|
|
76
77
|
"axios-retry": "^4.5.0",
|
|
77
78
|
"eventemitter2": "^6.4.5",
|
|
78
79
|
"jwt-decode": "^4.0.0",
|
package/src/clients/feed/feed.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
FeedRealTimeCallback,
|
|
29
29
|
FeedStoreState,
|
|
30
30
|
} from "./types";
|
|
31
|
+
import { mergeDateRangeParams } from "./utils";
|
|
31
32
|
|
|
32
33
|
// Default options to apply
|
|
33
34
|
const feedClientDefaults: Pick<FeedClientOptions, "archived"> = {
|
|
@@ -59,8 +60,10 @@ class Feed {
|
|
|
59
60
|
this.userFeedId = this.buildUserFeedId();
|
|
60
61
|
this.store = createStore();
|
|
61
62
|
this.broadcaster = new EventEmitter({ wildcard: true, delimiter: "." });
|
|
62
|
-
this.defaultOptions = {
|
|
63
|
-
|
|
63
|
+
this.defaultOptions = {
|
|
64
|
+
...feedClientDefaults,
|
|
65
|
+
...mergeDateRangeParams(options),
|
|
66
|
+
};
|
|
64
67
|
this.knock.log(`[Feed] Initialized a feed on channel ${feedId}`);
|
|
65
68
|
|
|
66
69
|
// Attempt to setup a realtime connection (does not join)
|
|
@@ -490,49 +493,19 @@ class Feed {
|
|
|
490
493
|
// Always include the default params, if they have been set
|
|
491
494
|
const queryParams = {
|
|
492
495
|
...this.defaultOptions,
|
|
493
|
-
...options,
|
|
496
|
+
...mergeDateRangeParams(options),
|
|
494
497
|
// Unset options that should not be sent to the API
|
|
495
498
|
__loadingType: undefined,
|
|
496
499
|
__fetchSource: undefined,
|
|
497
500
|
__experimentalCrossBrowserUpdates: undefined,
|
|
498
501
|
auto_manage_socket_connection: undefined,
|
|
499
502
|
auto_manage_socket_connection_delay: undefined,
|
|
500
|
-
inserted_at_date_range: undefined,
|
|
501
503
|
};
|
|
502
504
|
|
|
503
|
-
// If the user has set a date range, transform it to the backend's expected format
|
|
504
|
-
const dateRange = options.inserted_at_date_range;
|
|
505
|
-
let finalQueryParams = { ...queryParams };
|
|
506
|
-
|
|
507
|
-
if (dateRange) {
|
|
508
|
-
// Create a properly typed object for our date filter parameters
|
|
509
|
-
const dateRangeParams: Record<string, string> = {};
|
|
510
|
-
|
|
511
|
-
// Determine which operators to use based on the inclusive flag
|
|
512
|
-
const isInclusive = dateRange.inclusive ?? false;
|
|
513
|
-
|
|
514
|
-
// For start date: use gte if inclusive, gt if not
|
|
515
|
-
if (dateRange.start) {
|
|
516
|
-
const startOperator = isInclusive
|
|
517
|
-
? "inserted_at.gte"
|
|
518
|
-
: "inserted_at.gt";
|
|
519
|
-
dateRangeParams[startOperator] = dateRange.start;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// For end date: use lte if inclusive, lt if not
|
|
523
|
-
if (dateRange.end) {
|
|
524
|
-
const endOperator = isInclusive ? "inserted_at.lte" : "inserted_at.lt";
|
|
525
|
-
dateRangeParams[endOperator] = dateRange.end;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
// Create a new object combining queryParams and dateRangeParams
|
|
529
|
-
finalQueryParams = { ...queryParams, ...dateRangeParams };
|
|
530
|
-
}
|
|
531
|
-
|
|
532
505
|
const result = await this.knock.client().makeRequest({
|
|
533
506
|
method: "GET",
|
|
534
507
|
url: `/v1/users/${this.knock.userId}/feeds/${this.feedId}`,
|
|
535
|
-
params:
|
|
508
|
+
params: queryParams,
|
|
536
509
|
});
|
|
537
510
|
|
|
538
511
|
if (result.statusCode === "error" || !result.body) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FeedItem } from "./interfaces";
|
|
1
|
+
import { FeedClientOptions, FeedItem } from "./interfaces";
|
|
2
2
|
|
|
3
3
|
export function deduplicateItems(items: FeedItem[]): FeedItem[] {
|
|
4
4
|
const seen: Record<string, boolean> = {};
|
|
@@ -21,3 +21,30 @@ export function sortItems(items: FeedItem[]) {
|
|
|
21
21
|
);
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
export function mergeDateRangeParams(options: FeedClientOptions) {
|
|
26
|
+
const { inserted_at_date_range, ...rest } = options;
|
|
27
|
+
|
|
28
|
+
if (!inserted_at_date_range) {
|
|
29
|
+
return rest;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dateRangeParams: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
// Determine which operators to use based on the inclusive flag
|
|
35
|
+
const isInclusive = inserted_at_date_range.inclusive ?? false;
|
|
36
|
+
|
|
37
|
+
// For start date: use gte if inclusive, gt if not
|
|
38
|
+
if (inserted_at_date_range.start) {
|
|
39
|
+
const startOperator = isInclusive ? "inserted_at.gte" : "inserted_at.gt";
|
|
40
|
+
dateRangeParams[startOperator] = inserted_at_date_range.start;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// For end date: use lte if inclusive, lt if not
|
|
44
|
+
if (inserted_at_date_range.end) {
|
|
45
|
+
const endOperator = isInclusive ? "inserted_at.lte" : "inserted_at.lt";
|
|
46
|
+
dateRangeParams[endOperator] = inserted_at_date_range.end;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ...rest, ...dateRangeParams };
|
|
50
|
+
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { GenericData } from "@knocklabs/types";
|
|
2
|
+
import { Store } from "@tanstack/store";
|
|
3
|
+
import { Channel, Socket } from "phoenix";
|
|
4
|
+
|
|
5
|
+
import Knock from "../../knock";
|
|
6
|
+
|
|
7
|
+
const sortGuides = (guides: KnockGuide[]) => {
|
|
8
|
+
return [...guides].sort(
|
|
9
|
+
(a, b) =>
|
|
10
|
+
b.priority - a.priority ||
|
|
11
|
+
new Date(b.inserted_at).getTime() - new Date(a.inserted_at).getTime(),
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
//
|
|
16
|
+
// Guides API (via User client)
|
|
17
|
+
//
|
|
18
|
+
|
|
19
|
+
export const guidesApiRootPath = (userId: string | undefined | null) =>
|
|
20
|
+
`/v1/users/${userId}/guides`;
|
|
21
|
+
|
|
22
|
+
interface StepMessageState {
|
|
23
|
+
id: string;
|
|
24
|
+
seen_at: string | null;
|
|
25
|
+
read_at: string | null;
|
|
26
|
+
interacted_at: string | null;
|
|
27
|
+
archived_at: string | null;
|
|
28
|
+
link_clicked_at: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface GuideStepData {
|
|
32
|
+
ref: string;
|
|
33
|
+
schema_key: string;
|
|
34
|
+
schema_semver: string;
|
|
35
|
+
schema_variant_key: string;
|
|
36
|
+
message: StepMessageState;
|
|
37
|
+
// eslint-disable-next-line
|
|
38
|
+
content: any;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface GuideData {
|
|
42
|
+
__typename: "Guide";
|
|
43
|
+
channel_id: string;
|
|
44
|
+
id: string;
|
|
45
|
+
key: string;
|
|
46
|
+
priority: number;
|
|
47
|
+
type: string;
|
|
48
|
+
semver: string;
|
|
49
|
+
steps: GuideStepData[];
|
|
50
|
+
inserted_at: string;
|
|
51
|
+
updated_at: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface KnockGuideStep extends GuideStepData {
|
|
55
|
+
markAsSeen: () => void;
|
|
56
|
+
markAsInteracted: (params?: { metadata?: GenericData }) => void;
|
|
57
|
+
markAsArchived: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface KnockGuide extends GuideData {
|
|
61
|
+
steps: KnockGuideStep[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type GetGuidesQueryParams = {
|
|
65
|
+
data?: string;
|
|
66
|
+
tenant?: string;
|
|
67
|
+
type?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
type GetGuidesResponse = {
|
|
71
|
+
entries: GuideData[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type GuideEngagementEventBaseParams = {
|
|
75
|
+
// Base params required for all engagement update events
|
|
76
|
+
message_id: string;
|
|
77
|
+
channel_id: string;
|
|
78
|
+
guide_key: string;
|
|
79
|
+
guide_id: string;
|
|
80
|
+
guide_step_ref: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type MarkAsSeenParams = GuideEngagementEventBaseParams & {
|
|
84
|
+
// Rendered step content seen by the recipient
|
|
85
|
+
content: GenericData;
|
|
86
|
+
// Target params
|
|
87
|
+
data?: GenericData;
|
|
88
|
+
tenant?: string;
|
|
89
|
+
};
|
|
90
|
+
type MarkAsInteractedParams = GuideEngagementEventBaseParams;
|
|
91
|
+
type MarkAsArchivedParams = GuideEngagementEventBaseParams;
|
|
92
|
+
|
|
93
|
+
type MarkGuideAsResponse = {
|
|
94
|
+
status: "ok";
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type SocketEventType = "guide.added" | "guide.updated" | "guide.removed";
|
|
98
|
+
|
|
99
|
+
type SocketEventPayload<E extends SocketEventType, D> = {
|
|
100
|
+
topic: string;
|
|
101
|
+
event: E;
|
|
102
|
+
data: D;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type GuideAddedEvent = SocketEventPayload<
|
|
106
|
+
"guide.added",
|
|
107
|
+
{ guide: GuideData; eligible: true }
|
|
108
|
+
>;
|
|
109
|
+
|
|
110
|
+
type GuideUpdatedEvent = SocketEventPayload<
|
|
111
|
+
"guide.updated",
|
|
112
|
+
{ guide: GuideData; eligible: boolean }
|
|
113
|
+
>;
|
|
114
|
+
|
|
115
|
+
type GuideRemovedEvent = SocketEventPayload<
|
|
116
|
+
"guide.removed",
|
|
117
|
+
{ guide: Pick<GuideData, "key"> }
|
|
118
|
+
>;
|
|
119
|
+
|
|
120
|
+
type GuideSocketEvent = GuideAddedEvent | GuideUpdatedEvent | GuideRemovedEvent;
|
|
121
|
+
|
|
122
|
+
//
|
|
123
|
+
// Guides client
|
|
124
|
+
//
|
|
125
|
+
|
|
126
|
+
type QueryKey = string;
|
|
127
|
+
|
|
128
|
+
type QueryStatus = {
|
|
129
|
+
status: "loading" | "ok" | "error";
|
|
130
|
+
error?: Error;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type StoreState = {
|
|
134
|
+
guides: KnockGuide[];
|
|
135
|
+
queries: Record<QueryKey, QueryStatus>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
|
|
139
|
+
|
|
140
|
+
export type SelectFilterParams = {
|
|
141
|
+
key?: string;
|
|
142
|
+
type?: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export type TargetParams = {
|
|
146
|
+
data?: GenericData | undefined;
|
|
147
|
+
tenant?: string | undefined;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export class KnockGuideClient {
|
|
151
|
+
public store: Store<StoreState, (state: StoreState) => StoreState>;
|
|
152
|
+
|
|
153
|
+
// Phoenix channels for real time guide updates over websocket
|
|
154
|
+
private socket: Socket | undefined;
|
|
155
|
+
private socketChannel: Channel | undefined;
|
|
156
|
+
private socketChannelTopic: string;
|
|
157
|
+
private socketEventTypes = ["guide.added", "guide.updated", "guide.removed"];
|
|
158
|
+
|
|
159
|
+
constructor(
|
|
160
|
+
readonly knock: Knock,
|
|
161
|
+
readonly channelId: string,
|
|
162
|
+
readonly targetParams: TargetParams = {},
|
|
163
|
+
) {
|
|
164
|
+
this.store = new Store<StoreState>({
|
|
165
|
+
guides: [],
|
|
166
|
+
queries: {},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// In server environments we might not have a socket connection.
|
|
170
|
+
const { socket: maybeSocket } = this.knock.client();
|
|
171
|
+
this.socket = maybeSocket;
|
|
172
|
+
this.socketChannelTopic = `guides:${channelId}`;
|
|
173
|
+
|
|
174
|
+
this.knock.log("[Guide] Initialized a guide client");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async fetch(opts?: { filters?: QueryFilterParams }) {
|
|
178
|
+
this.knock.failIfNotAuthenticated();
|
|
179
|
+
this.knock.log("[Guide] Loading all eligible guides");
|
|
180
|
+
|
|
181
|
+
const queryParams = this.buildQueryParams(opts?.filters);
|
|
182
|
+
const queryKey = this.formatQueryKey(queryParams);
|
|
183
|
+
|
|
184
|
+
// If already fetched before, then noop.
|
|
185
|
+
const maybeQueryStatus = this.store.state.queries[queryKey];
|
|
186
|
+
if (maybeQueryStatus) {
|
|
187
|
+
return maybeQueryStatus;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Mark this query status as loading.
|
|
191
|
+
this.store.setState((state) => ({
|
|
192
|
+
...state,
|
|
193
|
+
queries: { ...state.queries, [queryKey]: { status: "loading" } },
|
|
194
|
+
}));
|
|
195
|
+
|
|
196
|
+
let queryStatus: QueryStatus;
|
|
197
|
+
try {
|
|
198
|
+
const data = await this.knock.user.getGuides<
|
|
199
|
+
GetGuidesQueryParams,
|
|
200
|
+
GetGuidesResponse
|
|
201
|
+
>(this.channelId, queryParams);
|
|
202
|
+
queryStatus = { status: "ok" };
|
|
203
|
+
|
|
204
|
+
this.store.setState((state) => ({
|
|
205
|
+
...state,
|
|
206
|
+
// For now assume a single fetch to get all eligible guides. When/if
|
|
207
|
+
// we implement incremental loads, then this will need to be a merge
|
|
208
|
+
// and sort operation.
|
|
209
|
+
guides: data.entries.map((g) => this.localCopy(g)),
|
|
210
|
+
queries: { ...state.queries, [queryKey]: queryStatus },
|
|
211
|
+
}));
|
|
212
|
+
} catch (e) {
|
|
213
|
+
queryStatus = { status: "error", error: e as Error };
|
|
214
|
+
|
|
215
|
+
this.store.setState((state) => ({
|
|
216
|
+
...state,
|
|
217
|
+
queries: { ...state.queries, [queryKey]: queryStatus },
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return queryStatus;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
subscribe() {
|
|
225
|
+
if (!this.socket) return;
|
|
226
|
+
this.knock.failIfNotAuthenticated();
|
|
227
|
+
this.knock.log("[Guide] Subscribing to real time updates");
|
|
228
|
+
|
|
229
|
+
// Ensure a live socket connection if not yet connected.
|
|
230
|
+
if (!this.socket.isConnected()) {
|
|
231
|
+
this.socket.connect();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// If there's an existing connected channel, then disconnect.
|
|
235
|
+
if (this.socketChannel) {
|
|
236
|
+
this.unsubscribe();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Join the channel topic and subscribe to supported events.
|
|
240
|
+
const params = { ...this.targetParams, user_id: this.knock.userId };
|
|
241
|
+
const newChannel = this.socket.channel(this.socketChannelTopic, params);
|
|
242
|
+
|
|
243
|
+
for (const eventType of this.socketEventTypes) {
|
|
244
|
+
newChannel.on(eventType, (payload) => this.handleSocketEvent(payload));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (["closed", "errored"].includes(newChannel.state)) {
|
|
248
|
+
newChannel.join();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Track the joined channel.
|
|
252
|
+
this.socketChannel = newChannel;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
unsubscribe() {
|
|
256
|
+
if (!this.socketChannel) return;
|
|
257
|
+
this.knock.log("[Guide] Unsubscribing from real time updates");
|
|
258
|
+
|
|
259
|
+
// Unsubscribe from the socket events and leave the channel.
|
|
260
|
+
for (const eventType of this.socketEventTypes) {
|
|
261
|
+
this.socketChannel.off(eventType);
|
|
262
|
+
}
|
|
263
|
+
this.socketChannel.leave();
|
|
264
|
+
|
|
265
|
+
// Unset the channel.
|
|
266
|
+
this.socketChannel = undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private handleSocketEvent(payload: GuideSocketEvent) {
|
|
270
|
+
const { event, data } = payload;
|
|
271
|
+
|
|
272
|
+
switch (event) {
|
|
273
|
+
case "guide.added":
|
|
274
|
+
return this.addGuide(payload);
|
|
275
|
+
|
|
276
|
+
case "guide.updated":
|
|
277
|
+
return data.eligible
|
|
278
|
+
? this.replaceOrAddGuide(payload)
|
|
279
|
+
: this.removeGuide(payload);
|
|
280
|
+
|
|
281
|
+
case "guide.removed":
|
|
282
|
+
return this.removeGuide(payload);
|
|
283
|
+
|
|
284
|
+
default:
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
//
|
|
290
|
+
// Store selector
|
|
291
|
+
//
|
|
292
|
+
|
|
293
|
+
select(state: StoreState, filters: SelectFilterParams = {}) {
|
|
294
|
+
// TODO(KNO-7790): Need to evaluate activation rules also.
|
|
295
|
+
|
|
296
|
+
return state.guides.filter((guide) => {
|
|
297
|
+
if (filters.type && filters.type !== guide.type) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (filters.key && filters.key !== guide.key) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return true;
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
//
|
|
310
|
+
// Engagement event handlers
|
|
311
|
+
//
|
|
312
|
+
// Make an optimistic update on the client side first, then send an engagement
|
|
313
|
+
// event to the backend.
|
|
314
|
+
//
|
|
315
|
+
|
|
316
|
+
async markAsSeen(guide: GuideData, step: GuideStepData) {
|
|
317
|
+
this.knock.log(
|
|
318
|
+
`[Guide] Marking as seen (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
322
|
+
seen_at: new Date().toISOString(),
|
|
323
|
+
});
|
|
324
|
+
if (!updatedStep) return;
|
|
325
|
+
|
|
326
|
+
const params = {
|
|
327
|
+
...this.buildEngagementEventBaseParams(guide, updatedStep),
|
|
328
|
+
content: updatedStep.content,
|
|
329
|
+
data: this.targetParams.data,
|
|
330
|
+
tenant: this.targetParams.tenant,
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
this.knock.user.markGuideStepAs<MarkAsSeenParams, MarkGuideAsResponse>(
|
|
334
|
+
"seen",
|
|
335
|
+
params,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
return updatedStep;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async markAsInteracted(
|
|
342
|
+
guide: GuideData,
|
|
343
|
+
step: GuideStepData,
|
|
344
|
+
metadata?: GenericData,
|
|
345
|
+
) {
|
|
346
|
+
this.knock.log(
|
|
347
|
+
`[Guide] Marking as interacted (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const ts = new Date().toISOString();
|
|
351
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
352
|
+
read_at: ts,
|
|
353
|
+
interacted_at: ts,
|
|
354
|
+
});
|
|
355
|
+
if (!updatedStep) return;
|
|
356
|
+
|
|
357
|
+
const params = {
|
|
358
|
+
...this.buildEngagementEventBaseParams(guide, updatedStep),
|
|
359
|
+
metadata,
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
this.knock.user.markGuideStepAs<
|
|
363
|
+
MarkAsInteractedParams,
|
|
364
|
+
MarkGuideAsResponse
|
|
365
|
+
>("interacted", params);
|
|
366
|
+
|
|
367
|
+
return updatedStep;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async markAsArchived(guide: GuideData, step: GuideStepData) {
|
|
371
|
+
this.knock.log(
|
|
372
|
+
`[Guide] Marking as archived (Guide key: ${guide.key}, Step ref:${step.ref})`,
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const updatedStep = this.setStepMessageAttrs(guide.key, step.ref, {
|
|
376
|
+
archived_at: new Date().toISOString(),
|
|
377
|
+
});
|
|
378
|
+
if (!updatedStep) return;
|
|
379
|
+
|
|
380
|
+
const params = this.buildEngagementEventBaseParams(guide, updatedStep);
|
|
381
|
+
|
|
382
|
+
this.knock.user.markGuideStepAs<MarkAsArchivedParams, MarkGuideAsResponse>(
|
|
383
|
+
"archived",
|
|
384
|
+
params,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return updatedStep;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
//
|
|
391
|
+
// Helpers
|
|
392
|
+
//
|
|
393
|
+
|
|
394
|
+
private localCopy(remoteGuide: GuideData) {
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
396
|
+
const self = this;
|
|
397
|
+
|
|
398
|
+
// Build a local copy with helper methods added.
|
|
399
|
+
const localGuide = { ...remoteGuide };
|
|
400
|
+
|
|
401
|
+
localGuide.steps = remoteGuide.steps.map(({ message, ...rest }) => {
|
|
402
|
+
const localStep = {
|
|
403
|
+
...rest,
|
|
404
|
+
message: { ...message },
|
|
405
|
+
markAsSeen() {
|
|
406
|
+
// Send a seen event if it has not been previously seen.
|
|
407
|
+
if (this.message.seen_at) return;
|
|
408
|
+
return self.markAsSeen(localGuide, this);
|
|
409
|
+
},
|
|
410
|
+
markAsInteracted({ metadata }: { metadata?: GenericData } = {}) {
|
|
411
|
+
// Always send an interaction event through.
|
|
412
|
+
return self.markAsInteracted(localGuide, this, metadata);
|
|
413
|
+
},
|
|
414
|
+
markAsArchived() {
|
|
415
|
+
// Send an archived event if it has not been previously archived.
|
|
416
|
+
if (this.message.archived_at) return;
|
|
417
|
+
return self.markAsArchived(localGuide, this);
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
// Bind all engagement action handler methods to the local step object so
|
|
422
|
+
// they can operate on itself.
|
|
423
|
+
localStep.markAsSeen = localStep.markAsSeen.bind(localStep);
|
|
424
|
+
localStep.markAsInteracted = localStep.markAsInteracted.bind(localStep);
|
|
425
|
+
localStep.markAsArchived = localStep.markAsArchived.bind(localStep);
|
|
426
|
+
|
|
427
|
+
return localStep;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
return localGuide as KnockGuide;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private buildQueryParams(filterParams: QueryFilterParams = {}) {
|
|
434
|
+
// Combine the target params with the given filter params.
|
|
435
|
+
const combinedParams = { ...this.targetParams, ...filterParams };
|
|
436
|
+
|
|
437
|
+
// Prune out any keys that have an undefined or null value.
|
|
438
|
+
let params = Object.fromEntries(
|
|
439
|
+
Object.entries(combinedParams).filter(
|
|
440
|
+
([_k, v]) => v !== undefined && v !== null,
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// Encode target data as a JSON string, if provided.
|
|
445
|
+
params = params.data
|
|
446
|
+
? { ...params, data: JSON.stringify(params.data) }
|
|
447
|
+
: params;
|
|
448
|
+
|
|
449
|
+
return params as GetGuidesQueryParams;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private formatQueryKey(queryParams: GenericData) {
|
|
453
|
+
const sortedKeys = Object.keys(queryParams).sort();
|
|
454
|
+
|
|
455
|
+
const queryStr = sortedKeys
|
|
456
|
+
.map(
|
|
457
|
+
(key) =>
|
|
458
|
+
`${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`,
|
|
459
|
+
)
|
|
460
|
+
.join("&");
|
|
461
|
+
|
|
462
|
+
const basePath = guidesApiRootPath(this.knock.userId);
|
|
463
|
+
return queryStr ? `${basePath}?${queryStr}` : basePath;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private setStepMessageAttrs(
|
|
467
|
+
guideKey: string,
|
|
468
|
+
stepRef: string,
|
|
469
|
+
attrs: Partial<StepMessageState>,
|
|
470
|
+
) {
|
|
471
|
+
let updatedStep: KnockGuideStep | undefined;
|
|
472
|
+
|
|
473
|
+
this.store.setState((state) => {
|
|
474
|
+
const guides = state.guides.map((guide) => {
|
|
475
|
+
if (guide.key !== guideKey) return guide;
|
|
476
|
+
|
|
477
|
+
const steps = guide.steps.map((step) => {
|
|
478
|
+
if (step.ref !== stepRef) return step;
|
|
479
|
+
|
|
480
|
+
// Mutate in place and maintain the same obj ref so to make it easier
|
|
481
|
+
// to use in hook deps.
|
|
482
|
+
step.message = { ...step.message, ...attrs };
|
|
483
|
+
updatedStep = step;
|
|
484
|
+
|
|
485
|
+
return step;
|
|
486
|
+
});
|
|
487
|
+
return { ...guide, steps };
|
|
488
|
+
});
|
|
489
|
+
return { ...state, guides };
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
return updatedStep;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
private buildEngagementEventBaseParams(
|
|
496
|
+
guide: GuideData,
|
|
497
|
+
step: GuideStepData,
|
|
498
|
+
) {
|
|
499
|
+
return {
|
|
500
|
+
message_id: step.message.id,
|
|
501
|
+
channel_id: guide.channel_id,
|
|
502
|
+
guide_key: guide.key,
|
|
503
|
+
guide_id: guide.id,
|
|
504
|
+
guide_step_ref: step.ref,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private addGuide({ data }: GuideAddedEvent) {
|
|
509
|
+
const guide = this.localCopy(data.guide);
|
|
510
|
+
|
|
511
|
+
this.store.setState((state) => {
|
|
512
|
+
return { ...state, guides: sortGuides([...state.guides, guide]) };
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private replaceOrAddGuide({ data }: GuideUpdatedEvent) {
|
|
517
|
+
const guide = this.localCopy(data.guide);
|
|
518
|
+
|
|
519
|
+
this.store.setState((state) => {
|
|
520
|
+
let replaced = false;
|
|
521
|
+
|
|
522
|
+
const guides = state.guides.map((g) => {
|
|
523
|
+
if (g.key !== guide.key) return g;
|
|
524
|
+
replaced = true;
|
|
525
|
+
return guide;
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
...state,
|
|
530
|
+
guides: replaced ? sortGuides(guides) : sortGuides([...guides, guide]),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
|
|
536
|
+
this.store.setState((state) => {
|
|
537
|
+
const guides = state.guides.filter((g) => g.key !== data.guide.key);
|
|
538
|
+
return { ...state, guides };
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|