@knocklabs/client 0.15.2 → 0.16.1

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 (34) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/cjs/api.js +1 -1
  3. package/dist/cjs/api.js.map +1 -1
  4. package/dist/cjs/clients/guide/client.js +1 -1
  5. package/dist/cjs/clients/guide/client.js.map +1 -1
  6. package/dist/cjs/clients/guide/helpers.js +2 -0
  7. package/dist/cjs/clients/guide/helpers.js.map +1 -0
  8. package/dist/cjs/clients/users/index.js.map +1 -1
  9. package/dist/esm/api.mjs +9 -9
  10. package/dist/esm/api.mjs.map +1 -1
  11. package/dist/esm/clients/guide/client.mjs +273 -117
  12. package/dist/esm/clients/guide/client.mjs.map +1 -1
  13. package/dist/esm/clients/guide/helpers.mjs +43 -0
  14. package/dist/esm/clients/guide/helpers.mjs.map +1 -0
  15. package/dist/esm/clients/users/index.mjs.map +1 -1
  16. package/dist/types/api.d.ts +1 -1
  17. package/dist/types/api.d.ts.map +1 -1
  18. package/dist/types/clients/guide/client.d.ts +17 -86
  19. package/dist/types/clients/guide/client.d.ts.map +1 -1
  20. package/dist/types/clients/guide/helpers.d.ts +16 -0
  21. package/dist/types/clients/guide/helpers.d.ts.map +1 -0
  22. package/dist/types/clients/guide/index.d.ts +1 -1
  23. package/dist/types/clients/guide/index.d.ts.map +1 -1
  24. package/dist/types/clients/guide/types.d.ts +147 -0
  25. package/dist/types/clients/guide/types.d.ts.map +1 -0
  26. package/dist/types/clients/users/index.d.ts +1 -1
  27. package/dist/types/clients/users/index.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/api.ts +2 -2
  30. package/src/clients/guide/client.ts +466 -225
  31. package/src/clients/guide/helpers.ts +98 -0
  32. package/src/clients/guide/index.ts +1 -1
  33. package/src/clients/guide/types.ts +206 -0
  34. package/src/clients/users/index.ts +2 -4
@@ -5,164 +5,132 @@ import { URLPattern } from "urlpattern-polyfill";
5
5
 
6
6
  import Knock from "../../knock";
7
7
 
8
- const sortGuides = (guides: KnockGuide[]) => {
9
- return [...guides].sort(
10
- (a, b) =>
11
- b.priority - a.priority ||
12
- new Date(b.inserted_at).getTime() - new Date(a.inserted_at).getTime(),
13
- );
14
- };
15
-
16
- //
17
- // Guides API (via User client)
18
- //
8
+ import {
9
+ DEFAULT_GROUP_KEY,
10
+ SelectionResult,
11
+ byKey,
12
+ checkIfThrottled,
13
+ findDefaultGroup,
14
+ formatFilters,
15
+ mockDefaultGroup,
16
+ } from "./helpers";
17
+ import {
18
+ ConstructorOpts,
19
+ GetGuidesQueryParams,
20
+ GetGuidesResponse,
21
+ GroupStage,
22
+ GuideAddedEvent,
23
+ GuideData,
24
+ GuideGroupAddedEvent,
25
+ GuideGroupUpdatedEvent,
26
+ GuideRemovedEvent,
27
+ GuideSocketEvent,
28
+ GuideStepData,
29
+ GuideUpdatedEvent,
30
+ KnockGuide,
31
+ KnockGuideStep,
32
+ MarkAsArchivedParams,
33
+ MarkAsInteractedParams,
34
+ MarkAsSeenParams,
35
+ MarkGuideAsResponse,
36
+ QueryFilterParams,
37
+ QueryStatus,
38
+ SelectFilterParams,
39
+ StepMessageState,
40
+ StoreState,
41
+ TargetParams,
42
+ } from "./types";
43
+
44
+ // How long to wait until we resolve the guides order and determine the
45
+ // prevailing guide.
46
+ const DEFAULT_ORDER_RESOLUTION_DURATION = 50; // in milliseconds
47
+
48
+ // How often we should increment the counter to refresh the store state and
49
+ // trigger subscribed callbacks.
50
+ const DEFAULT_COUNTER_INCREMENT_INTERVAL = 30 * 1000; // in milliseconds
19
51
 
20
52
  export const guidesApiRootPath = (userId: string | undefined | null) =>
21
53
  `/v1/users/${userId}/guides`;
22
54
 
23
- interface StepMessageState {
24
- id: string;
25
- seen_at: string | null;
26
- read_at: string | null;
27
- interacted_at: string | null;
28
- archived_at: string | null;
29
- link_clicked_at: string | null;
30
- }
55
+ const select = (state: StoreState, filters: SelectFilterParams = {}) => {
56
+ // A map of selected guides as values, with its order index as keys.
57
+ const result = new SelectionResult();
31
58
 
32
- interface GuideStepData {
33
- ref: string;
34
- schema_key: string;
35
- schema_semver: string;
36
- schema_variant_key: string;
37
- message: StepMessageState;
38
- // eslint-disable-next-line
39
- content: any;
40
- }
59
+ const defaultGroup = findDefaultGroup(state.guideGroups);
60
+ if (!defaultGroup) return result;
41
61
 
42
- interface GuideActivationLocationRuleData {
43
- directive: "allow" | "block";
44
- pathname: string;
45
- }
62
+ const displaySequence = defaultGroup.display_sequence;
63
+ const location = state.location;
46
64
 
47
- interface GuideData {
48
- __typename: "Guide";
49
- channel_id: string;
50
- id: string;
51
- key: string;
52
- priority: number;
53
- type: string;
54
- semver: string;
55
- steps: GuideStepData[];
56
- activation_location_rules: GuideActivationLocationRuleData[];
57
- inserted_at: string;
58
- updated_at: string;
59
- }
60
-
61
- export interface KnockGuideStep extends GuideStepData {
62
- markAsSeen: () => void;
63
- markAsInteracted: (params?: { metadata?: GenericData }) => void;
64
- markAsArchived: () => void;
65
- }
65
+ for (const [index, guideKey] of displaySequence.entries()) {
66
+ const guide = state.guides[guideKey];
67
+ if (!guide) continue;
66
68
 
67
- interface KnockGuideActivationLocationRule
68
- extends GuideActivationLocationRuleData {
69
- pattern: URLPattern;
70
- }
71
-
72
- export interface KnockGuide extends GuideData {
73
- steps: KnockGuideStep[];
74
- activation_location_rules: KnockGuideActivationLocationRule[];
75
- }
76
-
77
- type GetGuidesQueryParams = {
78
- data?: string;
79
- tenant?: string;
80
- type?: string;
81
- };
82
-
83
- type GetGuidesResponse = {
84
- entries: GuideData[];
85
- };
86
-
87
- export type GuideEngagementEventBaseParams = {
88
- // Base params required for all engagement update events
89
- message_id: string;
90
- channel_id: string;
91
- guide_key: string;
92
- guide_id: string;
93
- guide_step_ref: string;
94
- };
69
+ const affirmed = predicate(guide, { location, filters });
70
+ if (!affirmed) continue;
95
71
 
96
- type MarkAsSeenParams = GuideEngagementEventBaseParams & {
97
- // Rendered step content seen by the recipient
98
- content: GenericData;
99
- // Target params
100
- data?: GenericData;
101
- tenant?: string;
102
- };
103
- type MarkAsInteractedParams = GuideEngagementEventBaseParams;
104
- type MarkAsArchivedParams = GuideEngagementEventBaseParams;
72
+ result.set(index, guide);
73
+ }
105
74
 
106
- type MarkGuideAsResponse = {
107
- status: "ok";
75
+ result.metadata = { guideGroup: defaultGroup };
76
+ return result;
108
77
  };
109
78
 
110
- type SocketEventType = "guide.added" | "guide.updated" | "guide.removed";
111
-
112
- type SocketEventPayload<E extends SocketEventType, D> = {
113
- topic: string;
114
- event: E;
115
- data: D;
79
+ type PredicateOpts = {
80
+ location?: string | undefined;
81
+ filters?: SelectFilterParams | undefined;
116
82
  };
117
83
 
118
- type GuideAddedEvent = SocketEventPayload<
119
- "guide.added",
120
- { guide: GuideData; eligible: true }
121
- >;
122
-
123
- type GuideUpdatedEvent = SocketEventPayload<
124
- "guide.updated",
125
- { guide: GuideData; eligible: boolean }
126
- >;
127
-
128
- type GuideRemovedEvent = SocketEventPayload<
129
- "guide.removed",
130
- { guide: Pick<GuideData, "key"> }
131
- >;
132
-
133
- type GuideSocketEvent = GuideAddedEvent | GuideUpdatedEvent | GuideRemovedEvent;
134
-
135
- //
136
- // Guides client
137
- //
138
-
139
- type QueryKey = string;
140
-
141
- type QueryStatus = {
142
- status: "loading" | "ok" | "error";
143
- error?: Error;
144
- };
84
+ const predicate = (
85
+ guide: KnockGuide,
86
+ { location, filters = {} }: PredicateOpts,
87
+ ) => {
88
+ if (filters.type && filters.type !== guide.type) {
89
+ return false;
90
+ }
145
91
 
146
- type StoreState = {
147
- guides: KnockGuide[];
148
- queries: Record<QueryKey, QueryStatus>;
149
- location: string | undefined;
150
- };
92
+ if (filters.key && filters.key !== guide.key) {
93
+ return false;
94
+ }
151
95
 
152
- type QueryFilterParams = Pick<GetGuidesQueryParams, "type">;
96
+ if (guide.steps.every((s) => !!s.message.archived_at)) {
97
+ return false;
98
+ }
153
99
 
154
- export type SelectFilterParams = {
155
- key?: string;
156
- type?: string;
157
- };
100
+ const locationRules = guide.activation_location_rules || [];
101
+
102
+ if (locationRules.length > 0 && location) {
103
+ const allowed = locationRules.reduce<boolean | undefined>((acc, rule) => {
104
+ // Any matched block rule prevails so no need to evaluate further
105
+ // as soon as there is one.
106
+ if (acc === false) return false;
107
+
108
+ // At this point we either have a matched allow rule (acc is true),
109
+ // or no matched rule found yet (acc is undefined).
110
+
111
+ switch (rule.directive) {
112
+ case "allow": {
113
+ // No need to evaluate more allow rules once we matched one
114
+ // since any matched allowed rule means allow.
115
+ if (acc === true) return true;
116
+
117
+ const matched = rule.pattern.test(location);
118
+ return matched ? true : undefined;
119
+ }
120
+
121
+ case "block": {
122
+ // Always test block rules (unless already matched to block)
123
+ // because they'd prevail over matched allow rules.
124
+ const matched = rule.pattern.test(location);
125
+ return matched ? false : acc;
126
+ }
127
+ }
128
+ }, undefined);
158
129
 
159
- export type TargetParams = {
160
- data?: GenericData | undefined;
161
- tenant?: string | undefined;
162
- };
130
+ if (!allowed) return false;
131
+ }
163
132
 
164
- type ConstructorOpts = {
165
- trackLocationFromWindow?: boolean;
133
+ return true;
166
134
  };
167
135
 
168
136
  export class KnockGuideClient {
@@ -172,12 +140,25 @@ export class KnockGuideClient {
172
140
  private socket: Socket | undefined;
173
141
  private socketChannel: Channel | undefined;
174
142
  private socketChannelTopic: string;
175
- private socketEventTypes = ["guide.added", "guide.updated", "guide.removed"];
143
+ private socketEventTypes = [
144
+ "guide.added",
145
+ "guide.updated",
146
+ "guide.removed",
147
+ "guide_group.added",
148
+ "guide_group.updated",
149
+ ];
176
150
 
177
151
  // Original history methods to monkey patch, or restore in cleanups.
178
152
  private pushStateFn: History["pushState"] | undefined;
179
153
  private replaceStateFn: History["replaceState"] | undefined;
180
154
 
155
+ // Guides that are competing to render are "staged" first without rendering
156
+ // and ranked based on its relative order in the group over a duration of time
157
+ // to resolve and render the prevailing one.
158
+ private stage: GroupStage | undefined;
159
+
160
+ private counterIntervalId: ReturnType<typeof setInterval> | undefined;
161
+
181
162
  constructor(
182
163
  readonly knock: Knock,
183
164
  readonly channelId: string,
@@ -191,9 +172,13 @@ export class KnockGuideClient {
191
172
  : undefined;
192
173
 
193
174
  this.store = new Store<StoreState>({
194
- guides: [],
175
+ guideGroups: [],
176
+ guideGroupDisplayLogs: {},
177
+ guides: {},
195
178
  queries: {},
196
179
  location,
180
+ // Increment to update the state store and trigger re-selection.
181
+ counter: 0,
197
182
  });
198
183
 
199
184
  // In server environments we might not have a socket connection.
@@ -205,12 +190,42 @@ export class KnockGuideClient {
205
190
  this.listenForLocationChangesFromWindow();
206
191
  }
207
192
 
193
+ // Start the counter loop to increment at an interval.
194
+ this.startCounterInterval();
195
+
208
196
  this.knock.log("[Guide] Initialized a guide client");
209
197
  }
210
198
 
199
+ private incrementCounter() {
200
+ this.knock.log("[Guide] Incrementing the counter");
201
+ this.store.setState((state) => ({ ...state, counter: state.counter + 1 }));
202
+ }
203
+
204
+ private startCounterInterval() {
205
+ const {
206
+ throttleCheckInterval: delay = DEFAULT_COUNTER_INCREMENT_INTERVAL,
207
+ } = this.options;
208
+
209
+ this.counterIntervalId = setInterval(() => {
210
+ this.knock.log("[Guide] Counter interval tick");
211
+ if (this.stage && this.stage.status !== "closed") return;
212
+
213
+ this.incrementCounter();
214
+ }, delay);
215
+ }
216
+
217
+ private clearCounterInterval() {
218
+ if (this.counterIntervalId) {
219
+ clearInterval(this.counterIntervalId);
220
+ this.counterIntervalId = undefined;
221
+ }
222
+ }
223
+
211
224
  cleanup() {
212
225
  this.unsubscribe();
213
226
  this.removeEventListeners();
227
+ this.clearGroupStage();
228
+ this.clearCounterInterval();
214
229
  }
215
230
 
216
231
  async fetch(opts?: { filters?: QueryFilterParams }) {
@@ -240,12 +255,17 @@ export class KnockGuideClient {
240
255
  >(this.channelId, queryParams);
241
256
  queryStatus = { status: "ok" };
242
257
 
258
+ const {
259
+ entries,
260
+ guide_groups: groups,
261
+ guide_group_display_logs: guideGroupDisplayLogs,
262
+ } = data;
263
+
243
264
  this.store.setState((state) => ({
244
265
  ...state,
245
- // For now assume a single fetch to get all eligible guides. When/if
246
- // we implement incremental loads, then this will need to be a merge
247
- // and sort operation.
248
- guides: data.entries.map((g) => this.localCopy(g)),
266
+ guideGroups: groups?.length > 0 ? groups : [mockDefaultGroup(entries)],
267
+ guideGroupDisplayLogs,
268
+ guides: byKey(entries.map((g) => this.localCopy(g))),
249
269
  queries: { ...state.queries, [queryKey]: queryStatus },
250
270
  }));
251
271
  } catch (e) {
@@ -310,73 +330,242 @@ export class KnockGuideClient {
310
330
 
311
331
  switch (event) {
312
332
  case "guide.added":
313
- return this.addGuide(payload);
333
+ return this.addOrReplaceGuide(payload);
314
334
 
315
335
  case "guide.updated":
316
336
  return data.eligible
317
- ? this.replaceOrAddGuide(payload)
337
+ ? this.addOrReplaceGuide(payload)
318
338
  : this.removeGuide(payload);
319
339
 
320
340
  case "guide.removed":
321
341
  return this.removeGuide(payload);
322
342
 
343
+ case "guide_group.added":
344
+ case "guide_group.updated":
345
+ return this.addOrReplaceGuideGroup(payload);
346
+
323
347
  default:
324
348
  return;
325
349
  }
326
350
  }
327
351
 
352
+ setLocation(href: string) {
353
+ // Make sure to clear out the stage.
354
+ this.clearGroupStage();
355
+
356
+ this.store.setState((state) => ({ ...state, location: href }));
357
+ }
358
+
328
359
  //
329
360
  // Store selector
330
361
  //
331
362
 
332
- select(state: StoreState, filters: SelectFilterParams = {}) {
333
- return state.guides.filter((guide) => {
334
- if (filters.type && filters.type !== guide.type) {
335
- return false;
336
- }
363
+ selectGuides(state: StoreState, filters: SelectFilterParams = {}) {
364
+ if (Object.keys(state.guides).length === 0) {
365
+ return [];
366
+ }
367
+ this.knock.log(`[Guide] Selecting guides for: ${formatFilters(filters)}`);
368
+
369
+ const result = select(state, filters);
370
+
371
+ if (result.size === 0) {
372
+ this.knock.log("[Guide] Selection returned zero result");
373
+ return [];
374
+ }
375
+
376
+ // Return all selected guides, since we cannot apply the one-at-a-time limit
377
+ // or throttle settings, but rather defer to the caller to decide which ones
378
+ // to render. Note
379
+ return [...result.values()];
380
+ }
381
+
382
+ selectGuide(state: StoreState, filters: SelectFilterParams = {}) {
383
+ if (Object.keys(state.guides).length === 0) {
384
+ return undefined;
385
+ }
386
+ this.knock.log(`[Guide] Selecting a guide for: ${formatFilters(filters)}`);
387
+
388
+ const result = select(state, filters);
389
+
390
+ if (result.size === 0) {
391
+ this.knock.log("[Guide] Selection returned zero result");
392
+ return undefined;
393
+ }
394
+
395
+ const [index, guide] = [...result][0]!;
396
+
397
+ // If a guide ignores the group limit, then return immediately to render
398
+ // always.
399
+ if (guide.bypass_global_group_limit) {
400
+ return guide;
401
+ }
337
402
 
338
- if (filters.key && filters.key !== guide.key) {
339
- return false;
403
+ // Check if inside the throttle window (i.e. throttled) and if so stop and
404
+ // return undefined.
405
+ const defaultGroup = findDefaultGroup(state.guideGroups);
406
+ const throttleWindowStartedAt =
407
+ state.guideGroupDisplayLogs[DEFAULT_GROUP_KEY];
408
+
409
+ if (
410
+ defaultGroup &&
411
+ defaultGroup.display_interval &&
412
+ throttleWindowStartedAt
413
+ ) {
414
+ const throttled = checkIfThrottled(
415
+ throttleWindowStartedAt,
416
+ defaultGroup.display_interval,
417
+ );
418
+ if (throttled) return undefined;
419
+ }
420
+
421
+ // Starting here to the end of this method represents the core logic of how
422
+ // "group stage" works. It provides a mechanism for 1) figuring out which
423
+ // guide components are about to render on a page, 2) determining which
424
+ // among them ranks highest in the configured display sequence, and 3)
425
+ // returning only the prevailing guide to render at a time.
426
+ //
427
+ // Imagine N number of components that use the `useGuide()` hook which
428
+ // calls this `selectGuide()` method, and the logic works like this:
429
+ // * The first time this method is called, we don't have an "open" group
430
+ // stage, so we open one (this occurs when a new page/route is rendering).
431
+ // * While it is open, we record which guide was selected and its order
432
+ // index from each call, but we do NOT return any guide to render yet.
433
+ // * When a group stage opens, it schedules a timer to close itself. How
434
+ // long this timer waits is configurable. Note, `setTimeout` with 0
435
+ // delay seems to work well for React apps, where we "yield" to React
436
+ // for one render cycle and close the group right after.
437
+ // * When a group stage closes, we evaluate which guides were selected and
438
+ // recorded, then determine the winning guide (i.e. the one with the
439
+ // lowest order index value).
440
+ // * Then increment the internal counter to trigger a store state update,
441
+ // which allows `useGuide()` and `selectGuide()` to re-run. This second
442
+ // round of `selectGuide()` calls, occurring when the group stage is
443
+ // closed, results in returning the prevailing guide.
444
+ // * Whenever a user navigates to a new page, we repeat the same process
445
+ // above.
446
+ // * There's a third status called "patch," which is for handling real-time
447
+ // updates received from the API. It's similar to the "open" to "closed"
448
+ // flow, except we keep the resolved guide in place while we recalculate.
449
+ // This is done so that we don't cause flickers or CLS.
450
+ if (!this.stage) {
451
+ this.stage = this.openGroupStage(); // Assign here to make tsc happy
452
+ }
453
+
454
+ switch (this.stage.status) {
455
+ case "open": {
456
+ this.knock.log(`[Guide] Addng to the group stage: ${guide.key}`);
457
+ this.stage.ordered[index] = guide.key;
458
+ return undefined;
340
459
  }
341
460
 
342
- const locationRules = guide.activation_location_rules || [];
343
-
344
- if (locationRules.length > 0 && state.location) {
345
- const allowed = locationRules.reduce<boolean | undefined>(
346
- (acc, rule) => {
347
- // Any matched block rule prevails so no need to evaluate further
348
- // as soon as there is one.
349
- if (acc === false) return false;
350
-
351
- // At this point we either have a matched allow rule (acc is true),
352
- // or no matched rule found yet (acc is undefined).
353
-
354
- switch (rule.directive) {
355
- case "allow": {
356
- // No need to evaluate more allow rules once we matched one
357
- // since any matched allowed rule means allow.
358
- if (acc === true) return true;
359
-
360
- const matched = rule.pattern.test(state.location);
361
- return matched ? true : undefined;
362
- }
363
-
364
- case "block": {
365
- // Always test block rules (unless already matched to block)
366
- // because they'd prevail over matched allow rules.
367
- const matched = rule.pattern.test(state.location);
368
- return matched ? false : acc;
369
- }
370
- }
371
- },
372
- undefined,
373
- );
461
+ case "patch": {
462
+ this.knock.log(`[Guide] Patching the group stage: ${guide.key}`);
463
+ this.stage.ordered[index] = guide.key;
464
+ return this.stage.resolved === guide.key ? guide : undefined;
465
+ }
374
466
 
375
- if (!allowed) return false;
467
+ case "closed": {
468
+ return this.stage.resolved === guide.key ? guide : undefined;
376
469
  }
470
+ }
471
+ }
377
472
 
378
- return true;
379
- });
473
+ private openGroupStage() {
474
+ this.knock.log("[Guide] Opening a new group stage");
475
+
476
+ const {
477
+ orderResolutionDuration: delay = DEFAULT_ORDER_RESOLUTION_DURATION,
478
+ } = this.options;
479
+
480
+ const timeoutId = setTimeout(() => {
481
+ this.closePendingGroupStage();
482
+ this.incrementCounter();
483
+ }, delay);
484
+
485
+ this.stage = {
486
+ status: "open",
487
+ ordered: [],
488
+ timeoutId,
489
+ };
490
+
491
+ return this.stage;
492
+ }
493
+
494
+ // Close the current non-closed stage to resolve the prevailing guide up next
495
+ // for display amongst the ones that have been staged.
496
+ private closePendingGroupStage() {
497
+ if (!this.stage || this.stage.status === "closed") return;
498
+
499
+ this.knock.log("[Guide] Closing the current group stage");
500
+
501
+ // Should have been cleared already since this method should be called as a
502
+ // callback to a setTimeout, but just to be safe.
503
+ this.ensureClearTimeout();
504
+
505
+ this.stage = {
506
+ ...this.stage,
507
+ status: "closed",
508
+ resolved: this.stage.ordered.find((x) => x !== undefined),
509
+ timeoutId: null,
510
+ };
511
+
512
+ return this.stage;
513
+ }
514
+
515
+ // Set the current closed stage status to "patch" to allow re-running
516
+ // selections and re-building a group stage with the latest/updated state,
517
+ // while keeping the currently resolved guide in place so that it stays
518
+ // rendered until we are ready to resolve the updated stage and re-render.
519
+ // Note, must be called ahead of updating the state store.
520
+ private patchClosedGroupStage() {
521
+ if (this.stage?.status !== "closed") return;
522
+
523
+ this.knock.log("[Guide] Patching the current group stage");
524
+
525
+ const { orderResolutionDuration: delay = 0 } = this.options;
526
+
527
+ const timeoutId = setTimeout(() => {
528
+ this.closePendingGroupStage();
529
+ this.incrementCounter();
530
+ }, delay);
531
+
532
+ // Just to be safe.
533
+ this.ensureClearTimeout();
534
+
535
+ this.stage = {
536
+ ...this.stage,
537
+ status: "patch",
538
+ ordered: [],
539
+ timeoutId,
540
+ };
541
+
542
+ return this.stage;
543
+ }
544
+
545
+ private clearGroupStage() {
546
+ if (!this.stage) return;
547
+
548
+ this.knock.log("[Guide] Clearing the current group stage");
549
+
550
+ this.ensureClearTimeout();
551
+ this.stage = undefined;
552
+ }
553
+
554
+ private ensureClearTimeout() {
555
+ if (this.stage?.timeoutId) {
556
+ clearTimeout(this.stage.timeoutId);
557
+ }
558
+ }
559
+
560
+ // Test helper that opens and closes the group stage to return the select
561
+ // result immediately.
562
+ private _selectGuide(state: StoreState, filters: SelectFilterParams = {}) {
563
+ this.openGroupStage();
564
+
565
+ this.selectGuide(state, filters);
566
+ this.closePendingGroupStage();
567
+
568
+ return this.selectGuide(state, filters);
380
569
  }
381
570
 
382
571
  //
@@ -387,6 +576,8 @@ export class KnockGuideClient {
387
576
  //
388
577
 
389
578
  async markAsSeen(guide: GuideData, step: GuideStepData) {
579
+ if (step.message.seen_at) return;
580
+
390
581
  this.knock.log(
391
582
  `[Guide] Marking as seen (Guide key: ${guide.key}, Step ref:${step.ref})`,
392
583
  );
@@ -441,6 +632,8 @@ export class KnockGuideClient {
441
632
  }
442
633
 
443
634
  async markAsArchived(guide: GuideData, step: GuideStepData) {
635
+ if (step.message.archived_at) return;
636
+
444
637
  this.knock.log(
445
638
  `[Guide] Marking as archived (Guide key: ${guide.key}, Step ref:${step.ref})`,
446
639
  );
@@ -454,7 +647,10 @@ export class KnockGuideClient {
454
647
 
455
648
  this.knock.user.markGuideStepAs<MarkAsArchivedParams, MarkGuideAsResponse>(
456
649
  "archived",
457
- params,
650
+ {
651
+ ...params,
652
+ unthrottled: guide.bypass_global_group_limit,
653
+ },
458
654
  );
459
655
 
460
656
  return updatedStep;
@@ -469,7 +665,15 @@ export class KnockGuideClient {
469
665
  const self = this;
470
666
 
471
667
  // Build a local copy with helper methods added.
472
- const localGuide = { ...remoteGuide };
668
+ const localGuide = {
669
+ ...remoteGuide,
670
+ // Get the next unarchived step.
671
+ getStep() {
672
+ return this.steps.find((s) => !s.message.archived_at);
673
+ },
674
+ } as KnockGuide;
675
+
676
+ localGuide.getStep = localGuide.getStep.bind(localGuide);
473
677
 
474
678
  localGuide.steps = remoteGuide.steps.map(({ message, ...rest }) => {
475
679
  const localStep = {
@@ -508,7 +712,7 @@ export class KnockGuideClient {
508
712
  };
509
713
  });
510
714
 
511
- return localGuide as KnockGuide;
715
+ return localGuide;
512
716
  }
513
717
 
514
718
  private buildQueryParams(filterParams: QueryFilterParams = {}) {
@@ -551,23 +755,41 @@ export class KnockGuideClient {
551
755
  ) {
552
756
  let updatedStep: KnockGuideStep | undefined;
553
757
 
758
+ // If we are marking as archived, clear the group stage so we can render
759
+ // the next guide in the group.
760
+ if (attrs.archived_at) {
761
+ this.clearGroupStage();
762
+ }
763
+
554
764
  this.store.setState((state) => {
555
- const guides = state.guides.map((guide) => {
556
- if (guide.key !== guideKey) return guide;
765
+ const guide = state.guides[guideKey];
766
+ if (!guide) return state;
557
767
 
558
- const steps = guide.steps.map((step) => {
559
- if (step.ref !== stepRef) return step;
768
+ const steps = guide.steps.map((step) => {
769
+ if (step.ref !== stepRef) return step;
560
770
 
561
- // Mutate in place and maintain the same obj ref so to make it easier
562
- // to use in hook deps.
563
- step.message = { ...step.message, ...attrs };
564
- updatedStep = step;
771
+ // Mutate in place and maintain the same obj ref so to make it easier
772
+ // to use in hook deps.
773
+ step.message = { ...step.message, ...attrs };
774
+ updatedStep = step;
565
775
 
566
- return step;
567
- });
568
- return { ...guide, steps };
776
+ return step;
569
777
  });
570
- return { ...state, guides };
778
+ // Mutate in place and maintain the same obj ref.
779
+ guide.steps = steps;
780
+ const guides = { ...state.guides, [guide.key]: guide };
781
+
782
+ // If the guide is subject to throttled settings and we are marking as
783
+ // archived, then update the display logs to start a new throttle window.
784
+ const guideGroupDisplayLogs =
785
+ attrs.archived_at && !guide.bypass_global_group_limit
786
+ ? {
787
+ ...state.guideGroupDisplayLogs,
788
+ [DEFAULT_GROUP_KEY]: attrs.archived_at,
789
+ }
790
+ : state.guideGroupDisplayLogs;
791
+
792
+ return { ...state, guides, guideGroupDisplayLogs };
571
793
  });
572
794
 
573
795
  return updatedStep;
@@ -586,37 +808,57 @@ export class KnockGuideClient {
586
808
  };
587
809
  }
588
810
 
589
- private addGuide({ data }: GuideAddedEvent) {
811
+ private addOrReplaceGuide({ data }: GuideAddedEvent | GuideUpdatedEvent) {
812
+ this.patchClosedGroupStage();
813
+
590
814
  const guide = this.localCopy(data.guide);
591
815
 
592
816
  this.store.setState((state) => {
593
- return { ...state, guides: sortGuides([...state.guides, guide]) };
817
+ const guides = { ...state.guides, [guide.key]: guide };
818
+
819
+ return { ...state, guides };
594
820
  });
595
821
  }
596
822
 
597
- private replaceOrAddGuide({ data }: GuideUpdatedEvent) {
598
- const guide = this.localCopy(data.guide);
823
+ private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
824
+ this.patchClosedGroupStage();
599
825
 
600
826
  this.store.setState((state) => {
601
- let replaced = false;
602
-
603
- const guides = state.guides.map((g) => {
604
- if (g.key !== guide.key) return g;
605
- replaced = true;
606
- return guide;
607
- });
608
-
609
- return {
610
- ...state,
611
- guides: replaced ? sortGuides(guides) : sortGuides([...guides, guide]),
612
- };
827
+ const { [data.guide.key]: _, ...rest } = state.guides;
828
+ return { ...state, guides: rest };
613
829
  });
614
830
  }
615
831
 
616
- private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
832
+ private addOrReplaceGuideGroup({
833
+ data,
834
+ }: GuideGroupAddedEvent | GuideGroupUpdatedEvent) {
835
+ this.patchClosedGroupStage();
836
+
617
837
  this.store.setState((state) => {
618
- const guides = state.guides.filter((g) => g.key !== data.guide.key);
619
- return { ...state, guides };
838
+ // Currently we only support a single default global group, so we can just
839
+ // update the list with the added/updated group.
840
+ const guideGroups = [data.guide_group];
841
+
842
+ // A guide group event can include lists of unthrottled vs throttled guide
843
+ // keys which we can use to bulk update the guides in the store already.
844
+ const unthrottled = data.guide_group.display_sequence_unthrottled || [];
845
+ const throttled = data.guide_group.display_sequence_throttled || [];
846
+
847
+ let guides = state.guides;
848
+
849
+ guides = unthrottled.reduce((acc, key) => {
850
+ if (!acc[key]) return acc;
851
+ const guide = { ...acc[key], bypass_global_group_limit: true };
852
+ return { ...acc, [key]: guide };
853
+ }, guides);
854
+
855
+ guides = throttled.reduce((acc, key) => {
856
+ if (!acc[key]) return acc;
857
+ const guide = { ...acc[key], bypass_global_group_limit: false };
858
+ return { ...acc, [key]: guide };
859
+ }, guides);
860
+
861
+ return { ...state, guides, guideGroups };
620
862
  });
621
863
  }
622
864
 
@@ -626,8 +868,7 @@ export class KnockGuideClient {
626
868
  if (this.store.state.location === href) return;
627
869
 
628
870
  this.knock.log(`[Guide] Handle Location change: ${href}`);
629
-
630
- this.store.setState((state) => ({ ...state, location: href }));
871
+ this.setLocation(href);
631
872
  };
632
873
 
633
874
  private listenForLocationChangesFromWindow() {