@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.
- package/CHANGELOG.md +15 -0
- package/dist/cjs/api.js +1 -1
- package/dist/cjs/api.js.map +1 -1
- package/dist/cjs/clients/guide/client.js +1 -1
- package/dist/cjs/clients/guide/client.js.map +1 -1
- package/dist/cjs/clients/guide/helpers.js +2 -0
- package/dist/cjs/clients/guide/helpers.js.map +1 -0
- package/dist/cjs/clients/users/index.js.map +1 -1
- package/dist/esm/api.mjs +9 -9
- package/dist/esm/api.mjs.map +1 -1
- package/dist/esm/clients/guide/client.mjs +273 -117
- package/dist/esm/clients/guide/client.mjs.map +1 -1
- package/dist/esm/clients/guide/helpers.mjs +43 -0
- package/dist/esm/clients/guide/helpers.mjs.map +1 -0
- package/dist/esm/clients/users/index.mjs.map +1 -1
- package/dist/types/api.d.ts +1 -1
- package/dist/types/api.d.ts.map +1 -1
- package/dist/types/clients/guide/client.d.ts +17 -86
- package/dist/types/clients/guide/client.d.ts.map +1 -1
- package/dist/types/clients/guide/helpers.d.ts +16 -0
- package/dist/types/clients/guide/helpers.d.ts.map +1 -0
- package/dist/types/clients/guide/index.d.ts +1 -1
- package/dist/types/clients/guide/index.d.ts.map +1 -1
- package/dist/types/clients/guide/types.d.ts +147 -0
- package/dist/types/clients/guide/types.d.ts.map +1 -0
- package/dist/types/clients/users/index.d.ts +1 -1
- package/dist/types/clients/users/index.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api.ts +2 -2
- package/src/clients/guide/client.ts +466 -225
- package/src/clients/guide/helpers.ts +98 -0
- package/src/clients/guide/index.ts +1 -1
- package/src/clients/guide/types.ts +206 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
pathname: string;
|
|
45
|
-
}
|
|
62
|
+
const displaySequence = defaultGroup.display_sequence;
|
|
63
|
+
const location = state.location;
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
107
|
-
|
|
75
|
+
result.metadata = { guideGroup: defaultGroup };
|
|
76
|
+
return result;
|
|
108
77
|
};
|
|
109
78
|
|
|
110
|
-
type
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
location: string | undefined;
|
|
150
|
-
};
|
|
92
|
+
if (filters.key && filters.key !== guide.key) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
151
95
|
|
|
152
|
-
|
|
96
|
+
if (guide.steps.every((s) => !!s.message.archived_at)) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
153
99
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
tenant?: string | undefined;
|
|
162
|
-
};
|
|
130
|
+
if (!allowed) return false;
|
|
131
|
+
}
|
|
163
132
|
|
|
164
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|
|
333
|
+
return this.addOrReplaceGuide(payload);
|
|
314
334
|
|
|
315
335
|
case "guide.updated":
|
|
316
336
|
return data.eligible
|
|
317
|
-
? this.
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
467
|
+
case "closed": {
|
|
468
|
+
return this.stage.resolved === guide.key ? guide : undefined;
|
|
376
469
|
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
377
472
|
|
|
378
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
|
556
|
-
|
|
765
|
+
const guide = state.guides[guideKey];
|
|
766
|
+
if (!guide) return state;
|
|
557
767
|
|
|
558
|
-
|
|
559
|
-
|
|
768
|
+
const steps = guide.steps.map((step) => {
|
|
769
|
+
if (step.ref !== stepRef) return step;
|
|
560
770
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
567
|
-
});
|
|
568
|
-
return { ...guide, steps };
|
|
776
|
+
return step;
|
|
569
777
|
});
|
|
570
|
-
|
|
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
|
|
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
|
-
|
|
817
|
+
const guides = { ...state.guides, [guide.key]: guide };
|
|
818
|
+
|
|
819
|
+
return { ...state, guides };
|
|
594
820
|
});
|
|
595
821
|
}
|
|
596
822
|
|
|
597
|
-
private
|
|
598
|
-
|
|
823
|
+
private removeGuide({ data }: GuideUpdatedEvent | GuideRemovedEvent) {
|
|
824
|
+
this.patchClosedGroupStage();
|
|
599
825
|
|
|
600
826
|
this.store.setState((state) => {
|
|
601
|
-
|
|
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
|
|
832
|
+
private addOrReplaceGuideGroup({
|
|
833
|
+
data,
|
|
834
|
+
}: GuideGroupAddedEvent | GuideGroupUpdatedEvent) {
|
|
835
|
+
this.patchClosedGroupStage();
|
|
836
|
+
|
|
617
837
|
this.store.setState((state) => {
|
|
618
|
-
|
|
619
|
-
|
|
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() {
|