@doany-ai/inbox 0.0.0-stage → 0.1.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/README.md +175 -2
- package/dist/components/inbox-widget.d.ts +35 -0
- package/dist/components/inbox-widget.js +442 -0
- package/dist/components/parts.d.ts +67 -0
- package/dist/components/parts.js +115 -0
- package/dist/core.d.ts +6 -0
- package/dist/core.js +7 -0
- package/dist/format.d.ts +43 -0
- package/dist/format.js +136 -0
- package/dist/hooks.d.ts +69 -0
- package/dist/hooks.js +112 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +10 -0
- package/dist/labels.d.ts +209 -0
- package/dist/labels.js +210 -0
- package/dist/session.d.ts +204 -0
- package/dist/session.js +613 -0
- package/dist/storage.d.ts +21 -0
- package/dist/storage.js +86 -0
- package/dist/types.d.ts +189 -0
- package/dist/types.js +1 -0
- package/package.json +61 -4
package/dist/session.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { READ_KEY } from "./storage.js";
|
|
2
|
+
/** While an answer is on its way. */
|
|
3
|
+
export const POLL_REPLY_MS = 2000;
|
|
4
|
+
/** With the panel open. */
|
|
5
|
+
export const POLL_OPEN_MS = 5000;
|
|
6
|
+
/** With the panel closed, on a page with a chat. */
|
|
7
|
+
export const POLL_CLOSED_MS = 30000;
|
|
8
|
+
/** Polling stops after this long with nothing happening, and restarts on tab focus or panel open. */
|
|
9
|
+
export const IDLE_MS = 30 * 60 * 1000;
|
|
10
|
+
/** The longest wait between reads after failures. */
|
|
11
|
+
export const MAX_BACKOFF_MS = 60000;
|
|
12
|
+
/** A booking still waiting for its payment is reported again no sooner than this after the last time. */
|
|
13
|
+
export const BOOKING_REPORT_MS = 15000;
|
|
14
|
+
/** A booking still waiting for its payment after this long is no longer reported. */
|
|
15
|
+
export const BOOKING_REPORT_FOR_MS = 60 * 60 * 1000;
|
|
16
|
+
/** What one read returns at most (the platform's page). */
|
|
17
|
+
const THREAD_PAGE = 100;
|
|
18
|
+
/** Pages read in one poll before waiting for the next. */
|
|
19
|
+
const MAX_PAGES = 10;
|
|
20
|
+
const INITIAL = {
|
|
21
|
+
ready: false,
|
|
22
|
+
hasChat: false,
|
|
23
|
+
email: null,
|
|
24
|
+
me: null,
|
|
25
|
+
messages: [],
|
|
26
|
+
replyPending: false,
|
|
27
|
+
paused: false,
|
|
28
|
+
off: false,
|
|
29
|
+
closed: false,
|
|
30
|
+
pending: null,
|
|
31
|
+
draft: null,
|
|
32
|
+
busy: false,
|
|
33
|
+
problem: null,
|
|
34
|
+
retryAfter: null,
|
|
35
|
+
linkExpired: false,
|
|
36
|
+
unread: false,
|
|
37
|
+
};
|
|
38
|
+
/** What a refusal means here. The SDK's error keeps the status, the code and the response body (`data`). */
|
|
39
|
+
export function refusalOf(error) {
|
|
40
|
+
const { status, code, data } = (error ?? {});
|
|
41
|
+
const details = data?.details ?? {};
|
|
42
|
+
const reason = details.reason;
|
|
43
|
+
const retryAfter = typeof details.retry_after === "number" ? details.retry_after : null;
|
|
44
|
+
const refusal = (problem, notFound = false) => ({ status, notFound, problem, retryAfter });
|
|
45
|
+
if (code === "INBOX_NOT_FOUND")
|
|
46
|
+
return refusal("failed", true);
|
|
47
|
+
if (code === "INBOX_OFF")
|
|
48
|
+
return refusal("off");
|
|
49
|
+
if (reason === "blocked")
|
|
50
|
+
return refusal("closed");
|
|
51
|
+
if (reason === "preview_only")
|
|
52
|
+
return refusal("not_published");
|
|
53
|
+
if (status === 429)
|
|
54
|
+
return refusal(reason === "contact_limit" ? "contact_limit" : reason === "chat_limit" ? "chat_limit" : "rate_limited");
|
|
55
|
+
if (status === 400 && details.errors?.some((e) => e.field === "contact" && e.code === "required"))
|
|
56
|
+
return refusal("details_required");
|
|
57
|
+
return refusal("failed");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One browser's chat with the business: the thread and its polling, over the
|
|
61
|
+
* SDK's `client.inbox`, which holds the token and a first message still owed.
|
|
62
|
+
* No DOM: the default widget and a site's own chat UI drive it the same way.
|
|
63
|
+
* `useInbox()` wraps it for React.
|
|
64
|
+
*/
|
|
65
|
+
export class InboxSession {
|
|
66
|
+
constructor(client, storage) {
|
|
67
|
+
this.client = client;
|
|
68
|
+
this.storage = storage;
|
|
69
|
+
this.state = INITIAL;
|
|
70
|
+
this.listeners = new Set();
|
|
71
|
+
this.options = { active: true, open: false, visible: true };
|
|
72
|
+
this.actor = null;
|
|
73
|
+
this.chat = false;
|
|
74
|
+
this.cursor = null;
|
|
75
|
+
this.serverMessages = [];
|
|
76
|
+
this.localMessages = [];
|
|
77
|
+
this.lastSend = null;
|
|
78
|
+
this.timer = null;
|
|
79
|
+
this.inFlight = false;
|
|
80
|
+
this.failures = 0;
|
|
81
|
+
this.waitSeconds = null;
|
|
82
|
+
this.lastActivity = Date.now();
|
|
83
|
+
this.stopped = false;
|
|
84
|
+
/** Bumped whenever the chat changes hands, so a late answer for the old one is dropped. */
|
|
85
|
+
this.generation = 0;
|
|
86
|
+
this.attached = false;
|
|
87
|
+
this.seenFor = null;
|
|
88
|
+
/** Bookings already reported, by registration id. */
|
|
89
|
+
this.reported = new Set();
|
|
90
|
+
/**
|
|
91
|
+
* Bookings reported while waiting for their payment, oldest first. The
|
|
92
|
+
* platform notes a booking only once it is confirmed, so each is reported
|
|
93
|
+
* again alongside the reads until its line is in the thread.
|
|
94
|
+
*/
|
|
95
|
+
this.unpaid = [];
|
|
96
|
+
this.resolveReady = () => { };
|
|
97
|
+
this.whenReady = new Promise((resolve) => {
|
|
98
|
+
this.resolveReady = resolve;
|
|
99
|
+
});
|
|
100
|
+
/** Called after each read, so the page can re-read the button's settings alongside it. */
|
|
101
|
+
this.onPoll = null;
|
|
102
|
+
this.subscribe = (listener) => {
|
|
103
|
+
this.listeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
this.listeners.delete(listener);
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
this.getState = () => this.state;
|
|
109
|
+
/** The first message of a chat. Resolves true once the platform has it. */
|
|
110
|
+
this.start = async (input) => {
|
|
111
|
+
await this.whenReady;
|
|
112
|
+
const text = input.text.trim();
|
|
113
|
+
if (!text || this.chat || this.state.pending || this.state.busy)
|
|
114
|
+
return false;
|
|
115
|
+
const { pagePath, language, capabilities } = this.options;
|
|
116
|
+
const contact = input.contact ? cleanContact(input.contact) : undefined;
|
|
117
|
+
const intent = input.intent ?? "ask";
|
|
118
|
+
this.set({ draft: null, linkExpired: false, closed: false });
|
|
119
|
+
this.showPending("sending", { text, intent, contact });
|
|
120
|
+
return this.sendPending(() => this.client.inbox.start({
|
|
121
|
+
text,
|
|
122
|
+
intent,
|
|
123
|
+
...(contact ? { contact } : {}),
|
|
124
|
+
...(pagePath ? { pagePath: pagePath.slice(0, 2000) } : {}),
|
|
125
|
+
...(language ? { language: language.slice(0, 100) } : {}),
|
|
126
|
+
...(capabilities?.length ? { capabilities } : {}),
|
|
127
|
+
}), { text, intent, contact: contact ?? null });
|
|
128
|
+
};
|
|
129
|
+
/** Sends the first message that was not confirmed again, unchanged. */
|
|
130
|
+
this.retryPending = async () => {
|
|
131
|
+
if (!this.state.pending || this.state.busy)
|
|
132
|
+
return false;
|
|
133
|
+
this.showPending("sending");
|
|
134
|
+
return this.sendPending(() => this.client.inbox.start());
|
|
135
|
+
};
|
|
136
|
+
/** A later message in the chat. Resolves true once the platform has it; on false the page keeps the words. */
|
|
137
|
+
this.send = async (input) => {
|
|
138
|
+
const text = input.trim();
|
|
139
|
+
if (!this.chat || !text || this.state.busy)
|
|
140
|
+
return false;
|
|
141
|
+
// The same words again after a failure keep their id: a lost answer is not stored twice.
|
|
142
|
+
const id = this.lastSend?.text === text ? this.lastSend.id : this.client.inbox.newMessageId();
|
|
143
|
+
this.lastSend = { text, id };
|
|
144
|
+
const generation = this.generation;
|
|
145
|
+
const { capabilities } = this.options;
|
|
146
|
+
this.set({ busy: true, problem: null, retryAfter: null });
|
|
147
|
+
try {
|
|
148
|
+
const receipt = await this.client.inbox.send({ text, clientMessageId: id, ...(capabilities?.length ? { capabilities } : {}) });
|
|
149
|
+
if (generation !== this.generation)
|
|
150
|
+
return false;
|
|
151
|
+
this.lastSend = null;
|
|
152
|
+
this.localMessages.push(receipt.message);
|
|
153
|
+
this.set({ busy: false, replyPending: receipt.reply_pending, off: false, messages: this.thread() });
|
|
154
|
+
this.touch();
|
|
155
|
+
// Soon, for the lines that follow it ("Sent to the team.").
|
|
156
|
+
this.schedule(receipt.reply_pending ? undefined : POLL_REPLY_MS);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
if (generation !== this.generation)
|
|
161
|
+
return false;
|
|
162
|
+
this.refused(error);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
/** Hands the chat to a person, with the words in the box if any. */
|
|
167
|
+
this.askForPerson = async (input) => {
|
|
168
|
+
const text = input?.trim();
|
|
169
|
+
if (!this.chat || this.state.busy)
|
|
170
|
+
return false;
|
|
171
|
+
const generation = this.generation;
|
|
172
|
+
this.set({ busy: true, problem: null, retryAfter: null });
|
|
173
|
+
try {
|
|
174
|
+
await this.client.inbox.person(text ? { text } : undefined);
|
|
175
|
+
if (generation !== this.generation)
|
|
176
|
+
return false;
|
|
177
|
+
this.set({ busy: false, off: false });
|
|
178
|
+
this.touch();
|
|
179
|
+
this.schedule(0);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (generation !== this.generation)
|
|
184
|
+
return false;
|
|
185
|
+
this.refused(error);
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Opens the token of a reply's emailed link: the chat moves to this browser.
|
|
191
|
+
* A link that no longer works leaves this browser's own chat as it was and
|
|
192
|
+
* sets `linkExpired`.
|
|
193
|
+
*/
|
|
194
|
+
this.resume = async (linkToken) => {
|
|
195
|
+
await this.whenReady;
|
|
196
|
+
const generation = this.generation;
|
|
197
|
+
this.set({ busy: true, linkExpired: false, problem: null });
|
|
198
|
+
try {
|
|
199
|
+
const { me } = await this.client.inbox.resume(linkToken);
|
|
200
|
+
if (generation !== this.generation)
|
|
201
|
+
return false;
|
|
202
|
+
this.load();
|
|
203
|
+
this.set({ me });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (generation !== this.generation)
|
|
208
|
+
return false;
|
|
209
|
+
const refusal = refusalOf(error);
|
|
210
|
+
if (refusal.notFound || refusal.status === 400) {
|
|
211
|
+
this.set({ busy: false, linkExpired: true });
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
this.set({ busy: false, problem: refusal.problem, retryAfter: refusal.retryAfter, closed: refusal.problem === "closed" });
|
|
215
|
+
}
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* A booking the site's flow made from an offer in this chat (the flow's
|
|
221
|
+
* `onBooked`). The platform notes it on the chat, and the business's Inbox
|
|
222
|
+
* and Calendar link the two, when the booking is this chat's and confirmed.
|
|
223
|
+
* Each booking is reported once; one still waiting for its payment
|
|
224
|
+
* (`registration.status` `pending_payment`) is not booked yet, so it is
|
|
225
|
+
* reported again alongside the reads, for up to an hour, until the thread
|
|
226
|
+
* shows its line. Resolves false when there was nothing to report or the
|
|
227
|
+
* report did not go through; the booking stands either way.
|
|
228
|
+
*/
|
|
229
|
+
this.reportBooking = async (result) => {
|
|
230
|
+
const registrationId = result.registration?.id;
|
|
231
|
+
const accessToken = result.access_token;
|
|
232
|
+
if (!this.chat || !registrationId || !accessToken || this.reported.has(registrationId))
|
|
233
|
+
return false;
|
|
234
|
+
this.reported.add(registrationId);
|
|
235
|
+
if (result.registration?.status === "pending_payment") {
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
this.unpaid.push({ registrationId, accessToken, since: now, last: now });
|
|
238
|
+
}
|
|
239
|
+
const generation = this.generation;
|
|
240
|
+
try {
|
|
241
|
+
await this.client.inbox.bookings({ registrationId, accessToken });
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (generation === this.generation && refusalOf(error).notFound)
|
|
245
|
+
this.load();
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
if (generation !== this.generation)
|
|
249
|
+
return false;
|
|
250
|
+
// Read at once: the note follows the booking in the thread.
|
|
251
|
+
this.touch();
|
|
252
|
+
this.schedule(0);
|
|
253
|
+
return true;
|
|
254
|
+
};
|
|
255
|
+
/** Forgets this browser's chat with no call: the next message starts a new one. */
|
|
256
|
+
this.newChat = () => {
|
|
257
|
+
this.client.inbox.forget();
|
|
258
|
+
this.reset();
|
|
259
|
+
};
|
|
260
|
+
/** Something happened (the tab came back, the panel opened): polling restarts if it had stopped. */
|
|
261
|
+
this.wake = (now = false) => {
|
|
262
|
+
this.touch();
|
|
263
|
+
if (!this.chat || !this.attached || this.state.closed || !this.options.active)
|
|
264
|
+
return;
|
|
265
|
+
if (now || this.stopped || (!this.timer && !this.inFlight))
|
|
266
|
+
this.schedule(0);
|
|
267
|
+
};
|
|
268
|
+
/** The panel shows the newest reply: clear the button's mark. */
|
|
269
|
+
this.markRead = () => {
|
|
270
|
+
const newest = this.newestTeamMessage();
|
|
271
|
+
if (newest)
|
|
272
|
+
this.storage.set(READ_KEY, newest.id);
|
|
273
|
+
if (this.state.unread)
|
|
274
|
+
this.set({ unread: false });
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
set(patch) {
|
|
278
|
+
this.state = { ...this.state, ...patch };
|
|
279
|
+
for (const listener of this.listeners)
|
|
280
|
+
listener();
|
|
281
|
+
}
|
|
282
|
+
/** Starts polling (when there is a chat). A detached session keeps its state and makes no calls. */
|
|
283
|
+
attach() {
|
|
284
|
+
this.attached = true;
|
|
285
|
+
if (this.chat)
|
|
286
|
+
this.schedule(0);
|
|
287
|
+
}
|
|
288
|
+
detach() {
|
|
289
|
+
this.attached = false;
|
|
290
|
+
this.clearTimer();
|
|
291
|
+
}
|
|
292
|
+
setOptions(options) {
|
|
293
|
+
const opening = options.open === true && !this.options.open;
|
|
294
|
+
const closing = options.open === false && this.options.open;
|
|
295
|
+
const showing = options.visible === true && !this.options.visible;
|
|
296
|
+
const activating = options.active === true && !this.options.active;
|
|
297
|
+
const deactivating = options.active === false && this.options.active;
|
|
298
|
+
this.options = { ...this.options, ...options };
|
|
299
|
+
if (deactivating) {
|
|
300
|
+
this.clearTimer();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (activating && !opening) {
|
|
304
|
+
this.wake(true);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (opening) {
|
|
308
|
+
this.markRead();
|
|
309
|
+
this.wake(true);
|
|
310
|
+
}
|
|
311
|
+
else if (closing) {
|
|
312
|
+
// The panel closed: read at the slower pace from now on.
|
|
313
|
+
if (this.timer)
|
|
314
|
+
this.schedule();
|
|
315
|
+
}
|
|
316
|
+
else if (showing) {
|
|
317
|
+
this.maybeSeen();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Who is using the site: `guest`, or `user:<id>` for a signed-in account.
|
|
322
|
+
* The SDK keeps a chat per account, so a change reads the chat again.
|
|
323
|
+
*/
|
|
324
|
+
setActor(actor) {
|
|
325
|
+
if (actor === this.actor)
|
|
326
|
+
return;
|
|
327
|
+
this.actor = actor;
|
|
328
|
+
this.load();
|
|
329
|
+
this.resolveReady();
|
|
330
|
+
}
|
|
331
|
+
/** Reads which chat this browser holds, from the SDK, and starts from there. */
|
|
332
|
+
load() {
|
|
333
|
+
this.reset();
|
|
334
|
+
const held = this.client.inbox.current();
|
|
335
|
+
if (held) {
|
|
336
|
+
this.chat = true;
|
|
337
|
+
this.set({ ready: true, hasChat: true, email: held.email });
|
|
338
|
+
this.touch();
|
|
339
|
+
if (this.attached)
|
|
340
|
+
this.schedule(0);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
this.set({ ready: true });
|
|
344
|
+
if (this.client.inbox.pendingStart()) {
|
|
345
|
+
// A reload during the first send: send it once more, unchanged, before asking again.
|
|
346
|
+
this.showPending("sending");
|
|
347
|
+
void this.sendPending(() => this.client.inbox.start());
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
reset() {
|
|
351
|
+
this.generation += 1;
|
|
352
|
+
this.clearTimer();
|
|
353
|
+
this.chat = false;
|
|
354
|
+
this.cursor = null;
|
|
355
|
+
this.serverMessages = [];
|
|
356
|
+
this.localMessages = [];
|
|
357
|
+
this.lastSend = null;
|
|
358
|
+
this.failures = 0;
|
|
359
|
+
this.waitSeconds = null;
|
|
360
|
+
this.stopped = false;
|
|
361
|
+
this.seenFor = null;
|
|
362
|
+
this.unpaid = [];
|
|
363
|
+
this.set({ ...INITIAL, ready: this.state.ready });
|
|
364
|
+
}
|
|
365
|
+
showPending(state, given) {
|
|
366
|
+
const pending = given ?? this.client.inbox.pendingStart();
|
|
367
|
+
if (!pending)
|
|
368
|
+
return;
|
|
369
|
+
this.set({ pending: { text: pending.text, intent: pending.intent, email: pending.contact?.email ?? null, state } });
|
|
370
|
+
}
|
|
371
|
+
async sendPending(call, given) {
|
|
372
|
+
const generation = this.generation;
|
|
373
|
+
const asked = given ?? this.client.inbox.pendingStart();
|
|
374
|
+
this.set({ busy: true, problem: null, retryAfter: null });
|
|
375
|
+
try {
|
|
376
|
+
const receipt = await call();
|
|
377
|
+
if (generation !== this.generation)
|
|
378
|
+
return false;
|
|
379
|
+
const held = this.client.inbox.current();
|
|
380
|
+
this.chat = true;
|
|
381
|
+
this.localMessages = [receipt.message];
|
|
382
|
+
this.set({
|
|
383
|
+
hasChat: true,
|
|
384
|
+
email: receipt.me.signed_in ? null : held?.email ?? asked?.contact?.email ?? null,
|
|
385
|
+
me: receipt.me,
|
|
386
|
+
pending: null,
|
|
387
|
+
busy: false,
|
|
388
|
+
replyPending: receipt.reply_pending,
|
|
389
|
+
off: false,
|
|
390
|
+
messages: this.thread(),
|
|
391
|
+
});
|
|
392
|
+
this.touch();
|
|
393
|
+
this.schedule();
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
if (generation !== this.generation)
|
|
398
|
+
return false;
|
|
399
|
+
const refusal = refusalOf(error);
|
|
400
|
+
const closed = refusal.problem === "closed";
|
|
401
|
+
// The SDK keeps the first message while its answer is owed (a lost
|
|
402
|
+
// answer, a limit, the button off) and resends it unchanged: locked.
|
|
403
|
+
if (this.client.inbox.pendingStart()) {
|
|
404
|
+
this.set({ busy: false, problem: refusal.problem, retryAfter: refusal.retryAfter, closed });
|
|
405
|
+
this.showPending("failed");
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
// Refused for good: nothing was made. The words go back in the box.
|
|
409
|
+
this.set({
|
|
410
|
+
busy: false,
|
|
411
|
+
pending: null,
|
|
412
|
+
draft: asked ? { text: asked.text, intent: asked.intent, contact: asked.contact ?? null } : null,
|
|
413
|
+
problem: refusal.problem,
|
|
414
|
+
retryAfter: refusal.retryAfter,
|
|
415
|
+
closed,
|
|
416
|
+
});
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/** Reports the bookings still waiting for their payment again. A report that fails waits for the next read. */
|
|
421
|
+
async reportUnpaid() {
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
this.unpaid = this.unpaid.filter((booking) => now - booking.since < BOOKING_REPORT_FOR_MS);
|
|
424
|
+
for (const booking of this.unpaid) {
|
|
425
|
+
if (now - booking.last < BOOKING_REPORT_MS)
|
|
426
|
+
continue;
|
|
427
|
+
booking.last = now;
|
|
428
|
+
try {
|
|
429
|
+
await this.client.inbox.bookings({ registrationId: booking.registrationId, accessToken: booking.accessToken });
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// The read that follows says what happened to the chat.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
touch() {
|
|
437
|
+
this.lastActivity = Date.now();
|
|
438
|
+
this.stopped = false;
|
|
439
|
+
}
|
|
440
|
+
clearTimer() {
|
|
441
|
+
if (this.timer)
|
|
442
|
+
clearTimeout(this.timer);
|
|
443
|
+
this.timer = null;
|
|
444
|
+
}
|
|
445
|
+
/** The next read: 2 s while an answer is on its way, 5 s with the panel open, 30 s closed; longer after failures; none after 30 idle minutes. */
|
|
446
|
+
schedule(delay) {
|
|
447
|
+
this.clearTimer();
|
|
448
|
+
if (!this.attached || !this.chat || this.state.closed || !this.options.active)
|
|
449
|
+
return;
|
|
450
|
+
if (delay !== 0 && Date.now() - this.lastActivity >= IDLE_MS) {
|
|
451
|
+
this.stopped = true;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const base = this.state.replyPending ? POLL_REPLY_MS : this.options.open ? POLL_OPEN_MS : POLL_CLOSED_MS;
|
|
455
|
+
let wait = delay ?? base;
|
|
456
|
+
if (this.failures && delay === undefined) {
|
|
457
|
+
wait = Math.max(Math.min(base * 2 ** this.failures, MAX_BACKOFF_MS), (this.waitSeconds ?? 0) * 1000);
|
|
458
|
+
}
|
|
459
|
+
this.timer = setTimeout(() => {
|
|
460
|
+
this.timer = null;
|
|
461
|
+
void this.poll();
|
|
462
|
+
}, wait);
|
|
463
|
+
}
|
|
464
|
+
async poll() {
|
|
465
|
+
if (!this.chat || this.inFlight || !this.attached)
|
|
466
|
+
return;
|
|
467
|
+
const generation = this.generation;
|
|
468
|
+
this.inFlight = true;
|
|
469
|
+
try {
|
|
470
|
+
let page;
|
|
471
|
+
let pages = 0;
|
|
472
|
+
let fresh = [];
|
|
473
|
+
let restarted = false;
|
|
474
|
+
if (this.unpaid.length) {
|
|
475
|
+
// Before the read, so a booking paid since shows its line in this one.
|
|
476
|
+
await this.reportUnpaid();
|
|
477
|
+
if (generation !== this.generation)
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
do {
|
|
481
|
+
page = await this.client.inbox.messages(this.cursor ? { cursor: this.cursor } : {});
|
|
482
|
+
if (generation !== this.generation)
|
|
483
|
+
return;
|
|
484
|
+
// The cursor could not be used: the SDK read from the start.
|
|
485
|
+
if (page.restarted) {
|
|
486
|
+
this.serverMessages = [];
|
|
487
|
+
restarted = true;
|
|
488
|
+
}
|
|
489
|
+
fresh = fresh.concat(this.merge(page.messages));
|
|
490
|
+
if (page.next_cursor)
|
|
491
|
+
this.cursor = page.next_cursor;
|
|
492
|
+
pages += 1;
|
|
493
|
+
} while (page.messages.length >= THREAD_PAGE && pages < MAX_PAGES);
|
|
494
|
+
this.failures = 0;
|
|
495
|
+
this.waitSeconds = null;
|
|
496
|
+
// Each new "You're booked" line settles the oldest booking still waiting
|
|
497
|
+
// (a thread read again from the start brings back old lines, not new ones).
|
|
498
|
+
if (!restarted) {
|
|
499
|
+
const booked = fresh.filter((row) => row.kind === "event" && row.payload?.code === "booking_made").length;
|
|
500
|
+
if (booked)
|
|
501
|
+
this.unpaid = this.unpaid.slice(booked);
|
|
502
|
+
}
|
|
503
|
+
this.apply(page, fresh);
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
if (generation !== this.generation)
|
|
507
|
+
return;
|
|
508
|
+
const refusal = refusalOf(error);
|
|
509
|
+
if (refusal.notFound) {
|
|
510
|
+
// The SDK forgot that token: whatever it holds now (usually nothing) is the chat.
|
|
511
|
+
this.load();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (refusal.problem === "closed") {
|
|
515
|
+
this.set({ closed: true, problem: "closed" });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
this.failures += 1;
|
|
519
|
+
this.waitSeconds = refusal.retryAfter;
|
|
520
|
+
}
|
|
521
|
+
finally {
|
|
522
|
+
this.inFlight = false;
|
|
523
|
+
if (generation === this.generation) {
|
|
524
|
+
this.onPoll?.();
|
|
525
|
+
this.schedule();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/** The platform's rows in its order, each once; returns the ones not seen before. */
|
|
530
|
+
merge(rows) {
|
|
531
|
+
const known = new Set(this.serverMessages.map((message) => message.id));
|
|
532
|
+
const fresh = rows.filter((row) => !known.has(row.id));
|
|
533
|
+
this.serverMessages = this.serverMessages.concat(fresh);
|
|
534
|
+
return fresh;
|
|
535
|
+
}
|
|
536
|
+
thread() {
|
|
537
|
+
const shown = new Set(this.serverMessages.map((message) => message.id));
|
|
538
|
+
this.localMessages = this.localMessages.filter((message) => !shown.has(message.id));
|
|
539
|
+
return this.serverMessages.concat(this.localMessages);
|
|
540
|
+
}
|
|
541
|
+
newestTeamMessage() {
|
|
542
|
+
for (let i = this.serverMessages.length - 1; i >= 0; i--) {
|
|
543
|
+
if (this.serverMessages[i].author === "team")
|
|
544
|
+
return this.serverMessages[i];
|
|
545
|
+
}
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
apply(page, fresh) {
|
|
549
|
+
if (fresh.length)
|
|
550
|
+
this.touch();
|
|
551
|
+
const newest = this.newestTeamMessage();
|
|
552
|
+
let unread = false;
|
|
553
|
+
if (newest) {
|
|
554
|
+
if (this.options.open)
|
|
555
|
+
this.storage.set(READ_KEY, newest.id);
|
|
556
|
+
else
|
|
557
|
+
unread = this.storage.get(READ_KEY) !== newest.id;
|
|
558
|
+
}
|
|
559
|
+
this.set({
|
|
560
|
+
messages: this.thread(),
|
|
561
|
+
replyPending: page.reply_pending,
|
|
562
|
+
paused: page.paused,
|
|
563
|
+
off: page.off,
|
|
564
|
+
me: page.me,
|
|
565
|
+
email: page.me.signed_in ? null : this.client.inbox.current()?.email ?? this.state.email,
|
|
566
|
+
unread,
|
|
567
|
+
problem: this.state.problem === "off" && !page.off ? null : this.state.problem,
|
|
568
|
+
});
|
|
569
|
+
this.maybeSeen();
|
|
570
|
+
}
|
|
571
|
+
/** Tells the platform the newest team reply was shown: only with the panel open and the page visible. */
|
|
572
|
+
maybeSeen() {
|
|
573
|
+
const cursor = this.cursor;
|
|
574
|
+
if (!this.chat || !cursor || !this.options.open || !this.options.visible)
|
|
575
|
+
return;
|
|
576
|
+
const newest = this.newestTeamMessage();
|
|
577
|
+
if (!newest || this.seenFor === newest.id)
|
|
578
|
+
return;
|
|
579
|
+
const previous = this.seenFor;
|
|
580
|
+
this.seenFor = newest.id;
|
|
581
|
+
this.client.inbox.seen(cursor).catch(() => {
|
|
582
|
+
if (this.seenFor === newest.id)
|
|
583
|
+
this.seenFor = previous;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
refused(error) {
|
|
587
|
+
const refusal = refusalOf(error);
|
|
588
|
+
if (refusal.notFound) {
|
|
589
|
+
// The SDK forgot that token: start from what it holds now.
|
|
590
|
+
this.load();
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
this.set({
|
|
594
|
+
busy: false,
|
|
595
|
+
problem: refusal.problem,
|
|
596
|
+
retryAfter: refusal.retryAfter,
|
|
597
|
+
off: refusal.problem === "off" || this.state.off,
|
|
598
|
+
closed: refusal.problem === "closed",
|
|
599
|
+
});
|
|
600
|
+
if (refusal.problem === "closed")
|
|
601
|
+
this.clearTimer();
|
|
602
|
+
}
|
|
603
|
+
/** Stops every timer. The session can be attached again. */
|
|
604
|
+
dispose() {
|
|
605
|
+
this.detach();
|
|
606
|
+
this.listeners.clear();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function cleanContact(contact) {
|
|
610
|
+
const name = contact.name?.trim();
|
|
611
|
+
const phone = contact.phone?.trim();
|
|
612
|
+
return { email: contact.email.trim(), ...(name ? { name: name.slice(0, 80) } : {}), ...(phone ? { phone } : {}) };
|
|
613
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What this package keeps itself: only which team reply was last shown with
|
|
3
|
+
* the panel open, so a reload does not mark it unread again. The chat's token
|
|
4
|
+
* and a first message still owed are the SDK's (`client.inbox`), kept per
|
|
5
|
+
* account. A site can pass its own storage. No method throws.
|
|
6
|
+
*/
|
|
7
|
+
export interface InboxStorage {
|
|
8
|
+
get(key: string): string | null;
|
|
9
|
+
set(key: string, value: string): void;
|
|
10
|
+
remove(key: string): void;
|
|
11
|
+
}
|
|
12
|
+
export declare function memoryStorage(): InboxStorage;
|
|
13
|
+
/** The browser's localStorage when it can be used, else memory for this page. */
|
|
14
|
+
export declare function defaultStorage(): InboxStorage;
|
|
15
|
+
/** The visitor hid the privacy line: kept for the browser session. */
|
|
16
|
+
export declare const PRIVACY_DISMISSED_KEY = "doany_inbox_privacy_dismissed";
|
|
17
|
+
/** A mark kept for the browser session; for this page only where the session's storage cannot be used. */
|
|
18
|
+
export declare function sessionMark(key: string): boolean;
|
|
19
|
+
export declare function setSessionMark(key: string): void;
|
|
20
|
+
/** The newest team message shown with the panel open. Message ids are unique, so one key serves every chat. */
|
|
21
|
+
export declare const READ_KEY = "doany_inbox_read";
|