@joinskips/brand 9.0.0-develop.147 → 9.0.0-develop.149
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/chat/chat/SkipsChat.d.ts +36 -0
- package/chat/chat/SkipsChat.js +98 -0
- package/chat/chat/client.d.ts +48 -0
- package/chat/chat/client.js +173 -0
- package/chat/chat/connection.d.ts +4 -0
- package/chat/chat/connection.js +6 -0
- package/chat/chat/generated/skips-app/index.d.ts +1 -0
- package/chat/chat/generated/skips-app/index.js +2 -0
- package/chat/chat/generated/skips-app/types.gen.d.ts +733 -0
- package/chat/chat/generated/skips-app/types.gen.js +2 -0
- package/chat/chat/index.d.ts +3 -0
- package/chat/chat/index.js +2 -0
- package/chat/chat/platform.d.ts +4 -0
- package/chat/chat/platform.js +46 -0
- package/chat/chat/session.d.ts +19 -0
- package/chat/chat/session.js +95 -0
- package/chat/chat/useSkipsChat.d.ts +78 -0
- package/chat/chat/useSkipsChat.js +498 -0
- package/chat/chat/verify.d.ts +35 -0
- package/chat/chat/verify.js +84 -0
- package/chat/helpers/cable.d.ts +34 -0
- package/chat/helpers/cable.js +88 -0
- package/chat/helpers/cableFrames.d.ts +40 -0
- package/chat/helpers/cableFrames.js +70 -0
- package/chat/helpers/index.d.ts +4 -1
- package/chat/helpers/index.js +4 -1
- package/chat/helpers/message.d.ts +1 -1
- package/chat/helpers/skipsMessage.d.ts +52 -0
- package/chat/helpers/skipsMessage.js +129 -0
- package/chat/helpers/thread.d.ts +12 -1
- package/chat/helpers/thread.js +16 -1
- package/chat/index.d.ts +1 -0
- package/chat/index.js +1 -0
- package/chat/ui/Conversation.d.ts +29 -3
- package/chat/ui/Conversation.js +37 -12
- package/chat/ui/index.d.ts +0 -1
- package/chat/ui/index.js +0 -1
- package/chat/ui/parts.d.ts +1 -1
- package/package.json +9 -1
- package/chat/ui/CheckIn.d.ts +0 -25
- package/chat/ui/CheckIn.js +0 -24
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AppState } from 'react-native';
|
|
3
|
+
import { cableUrl, createTypingSignal, openCable, EMPTY_THREAD, pendingId, storedIds, withFailed, withoutMessage, withPending, withReceived, withReceivedAll, withSent, } from "../helpers/index.js";
|
|
4
|
+
import { ChatApiError, ChatClient } from "./client.js";
|
|
5
|
+
import { clearSession, loadSession, saveReadThrough, saveSession, SESSION_IDLE_MS, } from "./session.js";
|
|
6
|
+
import { platformSocket, platformStore } from "./platform.js";
|
|
7
|
+
import { ChatVerifier } from "./verify.js";
|
|
8
|
+
// A handler of the app's that throws is the app's to fix. It is logged, and nothing it was told about changes.
|
|
9
|
+
function tellApp(call) {
|
|
10
|
+
try {
|
|
11
|
+
call();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
console.error('member app handler failed', error);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function memberSession(member) {
|
|
18
|
+
const { contact, conversationId } = member;
|
|
19
|
+
return conversationId === null ? null : { contact, conversation: { id: conversationId } };
|
|
20
|
+
}
|
|
21
|
+
// A missed read receipt or typing signal changes nothing on the screen, and the next one goes out anyway.
|
|
22
|
+
async function bestEffort(signal) {
|
|
23
|
+
try {
|
|
24
|
+
await signal;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Let go.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const NOT_TYPING = { typing: false, count: 0 };
|
|
31
|
+
const systemNow = () => Date.now();
|
|
32
|
+
function heldLine(line) {
|
|
33
|
+
return { line, step: 'choosing', unreachable: false };
|
|
34
|
+
}
|
|
35
|
+
function openingLineFrom(opening) {
|
|
36
|
+
const text = opening?.trim() ?? '';
|
|
37
|
+
return { id: text.length > 0 ? pendingId() : null, text };
|
|
38
|
+
}
|
|
39
|
+
// The Skips App no longer serves the stored conversation (deleted, or the inbox changed).
|
|
40
|
+
function isForgotten(error) {
|
|
41
|
+
return error instanceof ChatApiError && error.status === 404;
|
|
42
|
+
}
|
|
43
|
+
// The conversation's life on the page: pick up the one this browser already has or
|
|
44
|
+
// open one when the first line goes out, keep the socket up, and send what the visitor
|
|
45
|
+
// types. The opening line shows at once, pending, so the visitor sees it before Skips
|
|
46
|
+
// has even been reached.
|
|
47
|
+
export function useSkipsChat({ baseUrl, inboxIdentifier, careTeamName, pageAddress, fetchFn = fetch, createSocket = platformSocket, store: givenStore, opening, verifyUrl, idleMs = SESSION_IDLE_MS, ...changing }) {
|
|
48
|
+
const [store] = useState(() => givenStore ?? platformStore());
|
|
49
|
+
// What an app may hand over afresh on every render, read where it is needed, so a new but equal one never
|
|
50
|
+
// starts the conversation over.
|
|
51
|
+
const latest = useRef(changing);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
latest.current = changing;
|
|
54
|
+
});
|
|
55
|
+
const now = useCallback(() => (latest.current.now ?? systemNow)(), []);
|
|
56
|
+
const [openingLine] = useState(() => openingLineFrom(opening));
|
|
57
|
+
// Asked once, on the first render: this browser has no conversation in this inbox, so its visitor is welcomed.
|
|
58
|
+
const [welcomed] = useState(() => changing.member === undefined &&
|
|
59
|
+
verifyUrl !== undefined &&
|
|
60
|
+
loadSession(store, inboxIdentifier, now()) === null);
|
|
61
|
+
const [welcome, setWelcome] = useState(() => welcomed && openingLine.id !== null ? heldLine(openingLine.text) : null);
|
|
62
|
+
const [thread, setThread] = useState(() => openingLine.id === null || welcomed
|
|
63
|
+
? EMPTY_THREAD
|
|
64
|
+
: withPending(EMPTY_THREAD, openingLine.id, openingLine.text, now()));
|
|
65
|
+
// Whether lines go straight to Skips: at once without a welcome, and once the visitor is through one.
|
|
66
|
+
const through = !welcomed || welcome?.step === 'through';
|
|
67
|
+
// The contact the code service made for the visitor, kept in the page for the conversation opened on it.
|
|
68
|
+
const contactRef = useRef(null);
|
|
69
|
+
const askingRef = useRef(false);
|
|
70
|
+
const [status, setStatus] = useState('starting');
|
|
71
|
+
// The live feed has dropped and is not back yet.
|
|
72
|
+
const [reconnecting, setReconnecting] = useState(false);
|
|
73
|
+
// The feed of this conversation has dropped at some point, so the one live now is not its first.
|
|
74
|
+
const droppedRef = useRef(false);
|
|
75
|
+
const [skipsTyping, setSkipsTyping] = useState(NOT_TYPING);
|
|
76
|
+
const sessionRef = useRef(null);
|
|
77
|
+
const sessionPromiseRef = useRef(null);
|
|
78
|
+
// A conversation this page started, which the member's app hears of once its first line is in.
|
|
79
|
+
const startedRef = useRef(null);
|
|
80
|
+
// Closes the live feed for good.
|
|
81
|
+
const closeFeedRef = useRef(null);
|
|
82
|
+
const idleTimerRef = useRef(undefined);
|
|
83
|
+
// When the visitor last used the chat, while there is a conversation on the page.
|
|
84
|
+
const lastUsedRef = useRef(null);
|
|
85
|
+
const client = useMemo(() => new ChatClient({ baseUrl, inboxIdentifier, careTeamName, fetchFn }), [baseUrl, careTeamName, fetchFn, inboxIdentifier]);
|
|
86
|
+
const verifier = useMemo(() => new ChatVerifier({ url: verifyUrl ?? '', fetchFn }), [fetchFn, verifyUrl]);
|
|
87
|
+
// The conversation leaves the page: no session to stamp, and nothing more from the feed.
|
|
88
|
+
const leave = useCallback(() => {
|
|
89
|
+
sessionRef.current = null;
|
|
90
|
+
closeFeedRef.current?.();
|
|
91
|
+
closeFeedRef.current = null;
|
|
92
|
+
}, []);
|
|
93
|
+
const endSharedSignIn = useCallback(async () => {
|
|
94
|
+
try {
|
|
95
|
+
await verifier.signOut();
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
console.error('sign-out not sent', error);
|
|
99
|
+
}
|
|
100
|
+
}, [verifier]);
|
|
101
|
+
// Signed out: the conversation leaves the page first, so a tap that lands before the page goes cannot save the
|
|
102
|
+
// chat again, and what the tab kept goes with it.
|
|
103
|
+
const signOut = useCallback(() => {
|
|
104
|
+
leave();
|
|
105
|
+
lastUsedRef.current = null;
|
|
106
|
+
setReconnecting(false);
|
|
107
|
+
clearTimeout(idleTimerRef.current);
|
|
108
|
+
clearSession(store, inboxIdentifier);
|
|
109
|
+
// A visitor's sign-in the member app shares goes too, or the next visit would come straight back in. One the
|
|
110
|
+
// service does not take stays until it runs out. A member's sign-in is the app's own, so it stays.
|
|
111
|
+
if (verifyUrl !== undefined && !latest.current.member)
|
|
112
|
+
void endSharedSignIn();
|
|
113
|
+
latest.current.onSignedOut?.();
|
|
114
|
+
}, [endSharedSignIn, inboxIdentifier, leave, store, verifyUrl]);
|
|
115
|
+
// Signs the chat out if it has sat unused past its limit, and says whether it did. The timer below does the same
|
|
116
|
+
// while the page runs, but a phone stops a page's timers in the background, so the clock is asked too.
|
|
117
|
+
const signedOutIfIdle = useCallback(() => {
|
|
118
|
+
const last = lastUsedRef.current;
|
|
119
|
+
if (last === null || now() - last <= idleMs)
|
|
120
|
+
return false;
|
|
121
|
+
signOut();
|
|
122
|
+
return true;
|
|
123
|
+
}, [idleMs, now, signOut]);
|
|
124
|
+
// The visitor used the chat just now, so its idle limit starts again: for what the tab keeps, and for the
|
|
125
|
+
// conversation on the page. Left unused that long with the page still open, the chat signs itself out, so a
|
|
126
|
+
// conversation is not left readable on a screen nobody is at. A reply from Skips is not the visitor.
|
|
127
|
+
const used = useCallback(() => {
|
|
128
|
+
const session = sessionRef.current;
|
|
129
|
+
if (!session || latest.current.member || signedOutIfIdle())
|
|
130
|
+
return;
|
|
131
|
+
const at = now();
|
|
132
|
+
lastUsedRef.current = at;
|
|
133
|
+
saveSession(store, inboxIdentifier, session, at);
|
|
134
|
+
clearTimeout(idleTimerRef.current);
|
|
135
|
+
idleTimerRef.current = setTimeout(signOut, idleMs);
|
|
136
|
+
}, [idleMs, inboxIdentifier, now, signOut, signedOutIfIdle, store]);
|
|
137
|
+
const markSeen = useCallback((session) => {
|
|
138
|
+
void bestEffort(client.seen(session.contact, session.conversation));
|
|
139
|
+
}, [client]);
|
|
140
|
+
// The conversation fetched again when its live feed comes back. The feed is left as it is, and it is what keeps
|
|
141
|
+
// the conversation current, so a fetch that fails here is let go. A conversation the Skips App has forgotten leaves nothing to be signed
|
|
142
|
+
// in to.
|
|
143
|
+
const refresh = useCallback(async (session) => {
|
|
144
|
+
try {
|
|
145
|
+
const history = await client.listMessages(session.contact, session.conversation);
|
|
146
|
+
setThread((current) => withReceivedAll(current, history));
|
|
147
|
+
markSeen(session);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (isForgotten(error))
|
|
151
|
+
signOut();
|
|
152
|
+
}
|
|
153
|
+
}, [client, markSeen, signOut]);
|
|
154
|
+
// The conversation's live feed, which brings itself back after a drop, a silent one included (openCable).
|
|
155
|
+
const connect = useCallback((session) => {
|
|
156
|
+
const { id } = session.conversation;
|
|
157
|
+
closeFeedRef.current = openCable(cableUrl(baseUrl), [session.contact.pubsubToken], {
|
|
158
|
+
onEvent: (event) => {
|
|
159
|
+
if (event.conversationId !== id)
|
|
160
|
+
return;
|
|
161
|
+
if (event.kind === 'message') {
|
|
162
|
+
setThread((current) => withReceived(current, event.message));
|
|
163
|
+
if (!event.message.mine)
|
|
164
|
+
markSeen(session);
|
|
165
|
+
}
|
|
166
|
+
else if (event.kind === 'typing') {
|
|
167
|
+
setSkipsTyping((current) => ({ typing: event.typing, count: current.count + 1 }));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
onState: (state) => {
|
|
171
|
+
// A feed the chat closed itself, signing out or leaving the page, is not one to bring back.
|
|
172
|
+
if (sessionRef.current !== session)
|
|
173
|
+
return;
|
|
174
|
+
if (state === 'down') {
|
|
175
|
+
droppedRef.current = true;
|
|
176
|
+
setReconnecting(true);
|
|
177
|
+
}
|
|
178
|
+
else if (state === 'live') {
|
|
179
|
+
setReconnecting(false);
|
|
180
|
+
// What Skips sent while the feed was down never comes over the new one. The first feed of a
|
|
181
|
+
// conversation has nothing to catch up on.
|
|
182
|
+
if (droppedRef.current)
|
|
183
|
+
void refresh(session);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
// The Skips App no longer takes the conversation's key, so there is nothing left to be signed in to.
|
|
187
|
+
onKeysRefused: signOut,
|
|
188
|
+
},
|
|
189
|
+
// The feed's own clock, for how long it has heard nothing.
|
|
190
|
+
{ careTeamName, createSocket, now: systemNow });
|
|
191
|
+
}, [baseUrl, careTeamName, createSocket, markSeen, refresh, signOut]);
|
|
192
|
+
// One conversation per page, however many sends race for it.
|
|
193
|
+
const ensureSession = useCallback(() => {
|
|
194
|
+
if (sessionRef.current)
|
|
195
|
+
return Promise.resolve(sessionRef.current);
|
|
196
|
+
sessionPromiseRef.current ??= (async () => {
|
|
197
|
+
try {
|
|
198
|
+
const { member } = latest.current;
|
|
199
|
+
const contact = member?.contact ?? contactRef.current ?? (await client.createContact());
|
|
200
|
+
const conversation = await client.createConversation(contact, pageAddress);
|
|
201
|
+
startedRef.current = conversation.id;
|
|
202
|
+
const session = { contact, conversation };
|
|
203
|
+
sessionRef.current = session;
|
|
204
|
+
used();
|
|
205
|
+
connect(session);
|
|
206
|
+
return session;
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
sessionPromiseRef.current = null;
|
|
210
|
+
}
|
|
211
|
+
})();
|
|
212
|
+
return sessionPromiseRef.current;
|
|
213
|
+
}, [client, connect, pageAddress, used]);
|
|
214
|
+
// The member's conversation, or the one this browser already has, with its history. A forgotten one this
|
|
215
|
+
// browser kept is dropped.
|
|
216
|
+
const resume = useCallback(async () => {
|
|
217
|
+
const { member } = latest.current;
|
|
218
|
+
const stored = member ? memberSession(member) : loadSession(store, inboxIdentifier, now());
|
|
219
|
+
if (!stored)
|
|
220
|
+
return;
|
|
221
|
+
try {
|
|
222
|
+
const history = await client.listMessages(stored.contact, stored.conversation);
|
|
223
|
+
sessionRef.current = stored;
|
|
224
|
+
setThread((current) => withReceivedAll(current, history));
|
|
225
|
+
used();
|
|
226
|
+
markSeen(stored);
|
|
227
|
+
connect(stored);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (member || !isForgotten(error))
|
|
231
|
+
throw error;
|
|
232
|
+
clearSession(store, inboxIdentifier);
|
|
233
|
+
}
|
|
234
|
+
}, [client, connect, inboxIdentifier, markSeen, now, store, used]);
|
|
235
|
+
// The page before the oldest line on the screen. Once one comes back empty there is nothing older to ask for.
|
|
236
|
+
const [olderDone, setOlderDone] = useState(false);
|
|
237
|
+
const loadOlder = useCallback(async () => {
|
|
238
|
+
const session = sessionRef.current;
|
|
239
|
+
const before = storedIds(thread)[0];
|
|
240
|
+
if (!session || before === undefined || olderDone)
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
const older = await client.listMessages(session.contact, session.conversation, before);
|
|
244
|
+
if (!older.length)
|
|
245
|
+
setOlderDone(true);
|
|
246
|
+
setThread((current) => withReceivedAll(current, older));
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Scrolling up again asks again.
|
|
250
|
+
}
|
|
251
|
+
}, [client, olderDone, thread]);
|
|
252
|
+
// The care team sees the visitor typing, and stop when they send or clear the box.
|
|
253
|
+
const signal = useMemo(() => createTypingSignal((on) => {
|
|
254
|
+
const session = sessionRef.current;
|
|
255
|
+
if (session)
|
|
256
|
+
void bestEffort(client.typing(session.contact, session.conversation, on));
|
|
257
|
+
}), [client]);
|
|
258
|
+
useEffect(() => () => {
|
|
259
|
+
signal.stop();
|
|
260
|
+
}, [signal]);
|
|
261
|
+
const typed = useCallback((text) => {
|
|
262
|
+
signal.typed(text);
|
|
263
|
+
}, [signal]);
|
|
264
|
+
// The welcome follows the visitor's first line when it goes out again, so it stays the first line on the page.
|
|
265
|
+
const follow = useCallback((from, to) => {
|
|
266
|
+
setWelcome((current) => current?.step === 'through' && current.lineId === from ? { ...current, lineId: to } : current);
|
|
267
|
+
}, []);
|
|
268
|
+
const deliver = useCallback(async (id, text, picked = null) => {
|
|
269
|
+
let delivered;
|
|
270
|
+
try {
|
|
271
|
+
const session = await ensureSession();
|
|
272
|
+
delivered = { session, stored: await client.sendMessage(session.contact, session.conversation, text, picked) };
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
setThread((current) => withFailed(current, id));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const { session, stored } = delivered;
|
|
279
|
+
setThread((current) => withSent(current, id, stored));
|
|
280
|
+
used();
|
|
281
|
+
// Told once the line is in, each on its own, so one handler that throws keeps neither the other nor the line.
|
|
282
|
+
const { member } = latest.current;
|
|
283
|
+
const conversationId = session.conversation.id;
|
|
284
|
+
if (startedRef.current === conversationId) {
|
|
285
|
+
startedRef.current = null;
|
|
286
|
+
tellApp(() => member?.onStarted?.(conversationId));
|
|
287
|
+
}
|
|
288
|
+
tellApp(() => member?.onSent?.(conversationId, stored));
|
|
289
|
+
}, [client, ensureSession, used]);
|
|
290
|
+
const start = useCallback(async () => {
|
|
291
|
+
try {
|
|
292
|
+
await resume();
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
setStatus('unreachable');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
setStatus('ready');
|
|
299
|
+
if (openingLine.id !== null && !welcomed) {
|
|
300
|
+
await deliver(openingLine.id, openingLine.text);
|
|
301
|
+
}
|
|
302
|
+
}, [deliver, openingLine, resume, welcomed]);
|
|
303
|
+
const tryAgain = useCallback(() => {
|
|
304
|
+
setStatus('starting');
|
|
305
|
+
void start();
|
|
306
|
+
}, [start]);
|
|
307
|
+
// A line, a photo or PDF, or both. Only the welcome's own line waits for it, and the welcome has no picker.
|
|
308
|
+
const send = useCallback(async (text, picked = null) => {
|
|
309
|
+
const trimmed = text.trim();
|
|
310
|
+
if (!through) {
|
|
311
|
+
setWelcome(heldLine(trimmed));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
signal.stop();
|
|
315
|
+
const id = pendingId();
|
|
316
|
+
setThread((current) => withPending(current, id, trimmed, now(), picked ?? undefined));
|
|
317
|
+
await deliver(id, trimmed, picked);
|
|
318
|
+
}, [deliver, now, signal, through]);
|
|
319
|
+
// One request to the code service at a time: a second press while one is out does nothing.
|
|
320
|
+
const ask = useCallback(async (request) => {
|
|
321
|
+
if (askingRef.current)
|
|
322
|
+
return;
|
|
323
|
+
askingRef.current = true;
|
|
324
|
+
try {
|
|
325
|
+
await request();
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
askingRef.current = false;
|
|
329
|
+
}
|
|
330
|
+
}, []);
|
|
331
|
+
const sendCode = useCallback((email) => ask(async () => {
|
|
332
|
+
try {
|
|
333
|
+
await verifier.requestCode(email);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
setWelcome((current) => current && { ...current, unreachable: true });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
setWelcome((current) => current && {
|
|
340
|
+
line: current.line,
|
|
341
|
+
step: 'code',
|
|
342
|
+
email,
|
|
343
|
+
noMatch: false,
|
|
344
|
+
ranOut: false,
|
|
345
|
+
unreachable: false,
|
|
346
|
+
codes: (current.step === 'code' ? current.codes : 0) + 1,
|
|
347
|
+
});
|
|
348
|
+
}), [ask, verifier]);
|
|
349
|
+
// The visitor is through on the contact the service made with the email they proved is theirs. Their line goes
|
|
350
|
+
// to Skips on that contact, and every line after it.
|
|
351
|
+
const goThrough = useCallback(async (line, email, named, contact, signedIn = false) => {
|
|
352
|
+
contactRef.current = contact;
|
|
353
|
+
const id = pendingId();
|
|
354
|
+
setWelcome({ line, step: 'through', email, named, signedIn, unreachable: false, lineId: id });
|
|
355
|
+
setThread((current) => withPending(current, id, line, now()));
|
|
356
|
+
await deliver(id, line);
|
|
357
|
+
}, [deliver, now]);
|
|
358
|
+
// A sign-in this browser already has, from an earlier chat or the member app, takes the visitor straight through.
|
|
359
|
+
// Without one, or with the service not answering, they give their email, and the code step says if it is down.
|
|
360
|
+
const continueHere = useCallback((line) => {
|
|
361
|
+
void ask(async () => {
|
|
362
|
+
const signedIn = await verifier.signedIn().catch(() => null);
|
|
363
|
+
if (signedIn !== null) {
|
|
364
|
+
await goThrough(line, signedIn.email, false, signedIn.contact, true);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
setWelcome({ line, step: 'email', unreachable: false });
|
|
368
|
+
});
|
|
369
|
+
}, [ask, goThrough, verifier]);
|
|
370
|
+
// For the right code the service hands back the contact of a visitor the Skips App knows. One it does not know
|
|
371
|
+
// is asked their name first.
|
|
372
|
+
const checkCode = useCallback((step, code) => ask(async () => {
|
|
373
|
+
const { line, email, codes } = step;
|
|
374
|
+
let checked;
|
|
375
|
+
try {
|
|
376
|
+
checked = await verifier.checkCode(email, code);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
setWelcome((current) => current && { ...current, unreachable: true });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (checked.result === 'noMatch') {
|
|
383
|
+
setWelcome({ ...step, noMatch: true, unreachable: false });
|
|
384
|
+
}
|
|
385
|
+
else if (checked.result === 'nameNeeded') {
|
|
386
|
+
setWelcome({ line, step: 'name', email, code, codes, unreachable: false });
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
await goThrough(line, email, false, checked.contact);
|
|
390
|
+
}
|
|
391
|
+
}), [ask, goThrough, verifier]);
|
|
392
|
+
// The name goes back with the code that was right. A code that has run out since sends the visitor back to the
|
|
393
|
+
// code step for a new one.
|
|
394
|
+
const giveName = useCallback((step, name) => ask(async () => {
|
|
395
|
+
const { line, email, code, codes } = step;
|
|
396
|
+
let contact;
|
|
397
|
+
try {
|
|
398
|
+
contact = await verifier.checkCodeWithName(email, code, name);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
setWelcome((current) => current && { ...current, unreachable: true });
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (contact === null) {
|
|
405
|
+
setWelcome({
|
|
406
|
+
line,
|
|
407
|
+
step: 'code',
|
|
408
|
+
email,
|
|
409
|
+
noMatch: false,
|
|
410
|
+
ranOut: true,
|
|
411
|
+
codes,
|
|
412
|
+
unreachable: false,
|
|
413
|
+
});
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
await goThrough(line, email, true, contact);
|
|
417
|
+
}), [ask, goThrough, verifier]);
|
|
418
|
+
// Nothing has been made for the email being changed: its code was never entered, or the Skips App does not know
|
|
419
|
+
// it and no name went back with the code. A request already out to the code service finishes first.
|
|
420
|
+
const changeEmail = useCallback((step) => {
|
|
421
|
+
if (askingRef.current)
|
|
422
|
+
return;
|
|
423
|
+
setWelcome({ line: step.line, step: 'email', email: step.email, unreachable: false });
|
|
424
|
+
}, []);
|
|
425
|
+
// A failed line goes out again as a fresh pending one.
|
|
426
|
+
const retry = useCallback(async (failed) => {
|
|
427
|
+
const id = pendingId();
|
|
428
|
+
follow(failed.id, id);
|
|
429
|
+
setThread((current) => withPending(withoutMessage(current, failed.id), id, failed.text, now(), failed.file));
|
|
430
|
+
await deliver(id, failed.text, failed.file ?? null);
|
|
431
|
+
}, [deliver, follow, now]);
|
|
432
|
+
const [choosing, setChoosing] = useState(null);
|
|
433
|
+
const [choiceFailed, setChoiceFailed] = useState(null);
|
|
434
|
+
// One answer only comes from the shared Choices, whose buttons are off while `choosing` is set. The answer is kept on the
|
|
435
|
+
// question first, so Skips reads it from there when the visitor's line arrives, and a reload shows the question
|
|
436
|
+
// answered. The line then passes the question, which takes its buttons away.
|
|
437
|
+
const choose = useCallback(async (question, answer) => {
|
|
438
|
+
const session = sessionRef.current;
|
|
439
|
+
if (!session)
|
|
440
|
+
return;
|
|
441
|
+
setChoosing(answer.value);
|
|
442
|
+
setChoiceFailed(null);
|
|
443
|
+
try {
|
|
444
|
+
await client.choose(session.contact, session.conversation, question.id, answer);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
setChoiceFailed(question.id);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
setChoosing(null);
|
|
452
|
+
}
|
|
453
|
+
if (sessionRef.current !== session)
|
|
454
|
+
return;
|
|
455
|
+
await send(answer.title);
|
|
456
|
+
}, [client, send]);
|
|
457
|
+
// What is on the visitor's screen is read, so the header's chat button counts only the replies after it.
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (sessionRef.current === null || latest.current.member)
|
|
460
|
+
return;
|
|
461
|
+
saveReadThrough(store, inboxIdentifier, Math.max(0, ...thread.messages.map((m) => m.id)));
|
|
462
|
+
}, [inboxIdentifier, store, thread]);
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
void start();
|
|
465
|
+
return () => {
|
|
466
|
+
leave();
|
|
467
|
+
clearTimeout(idleTimerRef.current);
|
|
468
|
+
};
|
|
469
|
+
}, [leave, start]);
|
|
470
|
+
// A page or app brought back to the front is checked then, and not only at the visitor's next tap.
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const watching = AppState.addEventListener('change', signedOutIfIdle);
|
|
473
|
+
return () => {
|
|
474
|
+
watching.remove();
|
|
475
|
+
};
|
|
476
|
+
}, [signedOutIfIdle]);
|
|
477
|
+
return {
|
|
478
|
+
thread,
|
|
479
|
+
status,
|
|
480
|
+
send,
|
|
481
|
+
retry,
|
|
482
|
+
tryAgain,
|
|
483
|
+
reconnecting,
|
|
484
|
+
loadOlder,
|
|
485
|
+
typed,
|
|
486
|
+
stillHere: used,
|
|
487
|
+
welcome,
|
|
488
|
+
continueHere,
|
|
489
|
+
sendCode,
|
|
490
|
+
checkCode,
|
|
491
|
+
giveName,
|
|
492
|
+
changeEmail,
|
|
493
|
+
skipsTyping,
|
|
494
|
+
choose,
|
|
495
|
+
choosing,
|
|
496
|
+
choiceFailed,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type ContactSession } from './client.ts';
|
|
2
|
+
export interface ChatVerifierOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
fetchFn?: typeof fetch;
|
|
5
|
+
}
|
|
6
|
+
export declare const CODE_LENGTH = 6;
|
|
7
|
+
export declare const NO_MATCH = 401;
|
|
8
|
+
interface ContactMade {
|
|
9
|
+
result: 'contact';
|
|
10
|
+
contact: ContactSession;
|
|
11
|
+
}
|
|
12
|
+
interface NameNeeded {
|
|
13
|
+
result: 'nameNeeded';
|
|
14
|
+
}
|
|
15
|
+
interface NoMatch {
|
|
16
|
+
result: 'noMatch';
|
|
17
|
+
}
|
|
18
|
+
export type CodeCheck = ContactMade | NameNeeded | NoMatch;
|
|
19
|
+
export interface SignedIn {
|
|
20
|
+
email: string;
|
|
21
|
+
contact: ContactSession;
|
|
22
|
+
}
|
|
23
|
+
export declare class ChatVerifier {
|
|
24
|
+
private readonly url;
|
|
25
|
+
private readonly fetchFn;
|
|
26
|
+
constructor({ url, fetchFn }: ChatVerifierOptions);
|
|
27
|
+
private post;
|
|
28
|
+
requestCode(email: string): Promise<void>;
|
|
29
|
+
private check;
|
|
30
|
+
signedIn(): Promise<SignedIn | null>;
|
|
31
|
+
signOut(): Promise<void>;
|
|
32
|
+
checkCode(email: string, code: string): Promise<CodeCheck>;
|
|
33
|
+
checkCodeWithName(email: string, code: string, name: string): Promise<ContactSession | null>;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The step that proves a visitor reads the email they gave: a code is sent to it, and for the
|
|
2
|
+
// right code the service makes their contact and hands it back, in the inbox their conversation
|
|
3
|
+
// belongs in. The Skips App takes a contact in those inboxes only from that service, which holds
|
|
4
|
+
// their keys. A visitor the Skips App does not know is asked for a name first: the service then
|
|
5
|
+
// says so, leaves the code good, and makes the contact when the same code comes back with a name.
|
|
6
|
+
import { ChatApiError, contactSessionFrom, isRecord } from "./client.js";
|
|
7
|
+
export const CODE_LENGTH = 6;
|
|
8
|
+
function isCode(code) {
|
|
9
|
+
return code.length === CODE_LENGTH && /^\d+$/.test(code);
|
|
10
|
+
}
|
|
11
|
+
// The service answers 401 for a code that is wrong, late, used or unknown, and says no more.
|
|
12
|
+
export const NO_MATCH = 401;
|
|
13
|
+
function asksForName(body) {
|
|
14
|
+
return isRecord(body) && body.name_needed === true;
|
|
15
|
+
}
|
|
16
|
+
export class ChatVerifier {
|
|
17
|
+
url;
|
|
18
|
+
fetchFn;
|
|
19
|
+
constructor({ url, fetchFn = fetch }) {
|
|
20
|
+
this.url = url.replace(/\/$/, '');
|
|
21
|
+
this.fetchFn = fetchFn;
|
|
22
|
+
}
|
|
23
|
+
post(path, body) {
|
|
24
|
+
// Called detached: window.fetch throws when any other object is its this.
|
|
25
|
+
const { fetchFn } = this;
|
|
26
|
+
return fetchFn(`${this.url}${path}`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async requestCode(email) {
|
|
33
|
+
const response = await this.post('/code', { email });
|
|
34
|
+
if (!response.ok)
|
|
35
|
+
throw new ChatApiError(response.status, 'the code was not sent');
|
|
36
|
+
}
|
|
37
|
+
// An entry that is not a code cannot match, so it is not sent and does not use up a try. With a name the
|
|
38
|
+
// service makes the contact whoever the visitor is, so its asking for one again is an answer in another shape.
|
|
39
|
+
async check(email, code, name) {
|
|
40
|
+
if (!isCode(code))
|
|
41
|
+
return { result: 'noMatch' };
|
|
42
|
+
const response = await this.post('/check', name === undefined ? { email, code } : { name, email, code });
|
|
43
|
+
if (response.status === NO_MATCH)
|
|
44
|
+
return { result: 'noMatch' };
|
|
45
|
+
if (!response.ok)
|
|
46
|
+
throw new ChatApiError(response.status, 'the code was not checked');
|
|
47
|
+
const body = await response.json().catch(() => null);
|
|
48
|
+
if (name === undefined && asksForName(body))
|
|
49
|
+
return { result: 'nameNeeded' };
|
|
50
|
+
const contact = contactSessionFrom(body);
|
|
51
|
+
if (!contact)
|
|
52
|
+
throw new ChatApiError(response.status, 'unexpected contact shape');
|
|
53
|
+
return { result: 'contact', contact };
|
|
54
|
+
}
|
|
55
|
+
// The service reads the sign-in from a cookie the page cannot see, and answers NO_MATCH when there is none it takes.
|
|
56
|
+
async signedIn() {
|
|
57
|
+
const response = await this.post('/session', {});
|
|
58
|
+
if (response.status === NO_MATCH)
|
|
59
|
+
return null;
|
|
60
|
+
if (!response.ok)
|
|
61
|
+
throw new ChatApiError(response.status, 'the sign-in was not checked');
|
|
62
|
+
const body = await response.json().catch(() => null);
|
|
63
|
+
const contact = contactSessionFrom(body);
|
|
64
|
+
if (!contact || !isRecord(body) || typeof body.email !== 'string') {
|
|
65
|
+
throw new ChatApiError(response.status, 'unexpected contact shape');
|
|
66
|
+
}
|
|
67
|
+
return { email: body.email, contact };
|
|
68
|
+
}
|
|
69
|
+
// Ends the sign-in the service keeps for this browser, which the member app shares.
|
|
70
|
+
async signOut() {
|
|
71
|
+
const response = await this.post('/signout', {});
|
|
72
|
+
if (!response.ok)
|
|
73
|
+
throw new ChatApiError(response.status, 'the sign-out was not sent');
|
|
74
|
+
}
|
|
75
|
+
// The contact of a visitor the Skips App knows, or word that it needs their name.
|
|
76
|
+
checkCode(email, code) {
|
|
77
|
+
return this.check(email, code);
|
|
78
|
+
}
|
|
79
|
+
// The contact of a visitor who was asked for their name, or null if the code no longer matches.
|
|
80
|
+
async checkCodeWithName(email, code, name) {
|
|
81
|
+
const checked = await this.check(email, code, name);
|
|
82
|
+
return checked.result === 'contact' ? checked.contact : null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ChatEvent } from './cableFrames.ts';
|
|
2
|
+
export declare const STALE_MS = 10000;
|
|
3
|
+
export declare const FIRST_RETRY_MS = 1000;
|
|
4
|
+
export declare const MAX_RETRY_MS = 30000;
|
|
5
|
+
export type CableState = 'connecting' | 'live' | 'down';
|
|
6
|
+
export interface CableOptions {
|
|
7
|
+
careTeamName: string;
|
|
8
|
+
createSocket: (url: string) => Socket;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
export interface CableHandlers {
|
|
12
|
+
onEvent: (event: ChatEvent) => void;
|
|
13
|
+
onState: (state: CableState) => void;
|
|
14
|
+
onKeysRefused: () => void;
|
|
15
|
+
}
|
|
16
|
+
/** The socket address for a Skips App origin, such as https://app.joinskips.com. */
|
|
17
|
+
export declare const cableUrl: (origin: string) => string;
|
|
18
|
+
/** The wait before the nth reconnect, doubling up to MAX_RETRY_MS. */
|
|
19
|
+
export declare const retryDelay: (attempt: number) => number;
|
|
20
|
+
export interface SocketMessage {
|
|
21
|
+
data: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface Socket extends Pick<WebSocket, 'send' | 'close'> {
|
|
24
|
+
onopen: ((event: unknown) => void) | null;
|
|
25
|
+
onmessage: ((event: SocketMessage) => void) | null;
|
|
26
|
+
onclose: ((event: unknown) => void) | null;
|
|
27
|
+
onerror: ((event: unknown) => void) | null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Opens the feed for the member's keys, the Skips App's pubsub tokens, on sockets the app makes with createSocket.
|
|
31
|
+
* now is the app's clock, which the stale check reads.
|
|
32
|
+
* Returns a function that closes it for good.
|
|
33
|
+
*/
|
|
34
|
+
export declare function openCable(url: string, keys: string[], handlers: CableHandlers, { careTeamName, createSocket, now }: CableOptions): () => void;
|