@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cjs/clients/feed/feed.js +1 -1
  3. package/dist/cjs/clients/feed/feed.js.map +1 -1
  4. package/dist/cjs/clients/feed/utils.js +1 -1
  5. package/dist/cjs/clients/feed/utils.js.map +1 -1
  6. package/dist/cjs/clients/guide/client.js +2 -0
  7. package/dist/cjs/clients/guide/client.js.map +1 -0
  8. package/dist/cjs/clients/users/index.js +1 -1
  9. package/dist/cjs/clients/users/index.js.map +1 -1
  10. package/dist/cjs/index.js +1 -1
  11. package/dist/esm/clients/feed/feed.mjs +72 -83
  12. package/dist/esm/clients/feed/feed.mjs.map +1 -1
  13. package/dist/esm/clients/feed/utils.mjs +23 -7
  14. package/dist/esm/clients/feed/utils.mjs.map +1 -1
  15. package/dist/esm/clients/guide/client.mjs +225 -0
  16. package/dist/esm/clients/guide/client.mjs.map +1 -0
  17. package/dist/esm/clients/users/index.mjs +30 -13
  18. package/dist/esm/clients/users/index.mjs.map +1 -1
  19. package/dist/esm/index.mjs +9 -7
  20. package/dist/esm/index.mjs.map +1 -1
  21. package/dist/types/clients/feed/feed.d.ts.map +1 -1
  22. package/dist/types/clients/feed/utils.d.ts +16 -1
  23. package/dist/types/clients/feed/utils.d.ts.map +1 -1
  24. package/dist/types/clients/guide/client.d.ts +103 -0
  25. package/dist/types/clients/guide/client.d.ts.map +1 -0
  26. package/dist/types/clients/guide/index.d.ts +3 -0
  27. package/dist/types/clients/guide/index.d.ts.map +1 -0
  28. package/dist/types/clients/users/index.d.ts +3 -0
  29. package/dist/types/clients/users/index.d.ts.map +1 -1
  30. package/dist/types/index.d.ts +1 -0
  31. package/dist/types/index.d.ts.map +1 -1
  32. package/package.json +5 -4
  33. package/src/clients/feed/feed.ts +7 -34
  34. package/src/clients/feed/utils.ts +28 -1
  35. package/src/clients/guide/client.ts +541 -0
  36. package/src/clients/guide/index.ts +7 -0
  37. package/src/clients/users/index.ts +27 -0
  38. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knocklabs/client",
3
- "version": "0.13.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.24.0",
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.26.0",
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.1",
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",
@@ -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 = { ...feedClientDefaults, ...options };
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: finalQueryParams,
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
+ }
@@ -0,0 +1,7 @@
1
+ export { KnockGuideClient } from "./client";
2
+ export type {
3
+ KnockGuide,
4
+ KnockGuideStep,
5
+ TargetParams as KnockGuideTargetParams,
6
+ SelectFilterParams as KnockGuideFilterParams,
7
+ } from "./client";