@pmeweb/plugin-chat 1.2.1 → 1.3.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/INSTALL.md +91 -19
- package/README.md +55 -3
- package/dist/trigger.iife.js +1 -1
- package/dist/widget.js +2 -2
- package/manifest.json +3 -1
- package/package.json +1 -1
- package/src/build.d.ts +22 -7
- package/src/build.js +118 -35
- package/src/strings.js +4 -2
- package/src/trigger.js +19 -9
- package/src/widget.css +27 -9
- package/src/widget.js +196 -40
- package/src/worker.js +1 -1
package/src/widget.js
CHANGED
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
// a visitor's question drawn from it (demoQuestion) and a canned answer, with the composer disabled
|
|
38
38
|
// and a hint saying why. The client sees their face and colours in the « Contenu » preview.
|
|
39
39
|
import css from './widget.css?inline'
|
|
40
|
-
import { isCanonical } from '@pmeweb/loader'
|
|
41
|
-
import { addScript as loadTurnstile,
|
|
40
|
+
import { isCanonical, onApproach } from '@pmeweb/loader'
|
|
41
|
+
import { addScript as loadTurnstile, tokenReady } from '@pmeweb/turnstile'
|
|
42
42
|
import { CLOSE, MAX_TEXT } from './protocol.js'
|
|
43
43
|
import { fill, langOf, stringsFor } from './strings.js'
|
|
44
44
|
|
|
@@ -49,6 +49,12 @@ export const GOAL = 'chat-lead'
|
|
|
49
49
|
export const MAX_PHOTOS = 3
|
|
50
50
|
export const PHOTO_EDGE = 1600
|
|
51
51
|
export const PHOTO_QUALITY = 0.85
|
|
52
|
+
/** How long after the page has painted (and the widget was imported) Turnstile starts, unasked. */
|
|
53
|
+
export const WARM_MS = 3000
|
|
54
|
+
/** A first start slower than this shows « Un instant… » under the typing dots. */
|
|
55
|
+
export const SLOW_START_MS = 8000
|
|
56
|
+
/** How close to the bottom of the thread (px) still counts as « at the bottom ». */
|
|
57
|
+
export const PIN_SLACK = 40
|
|
52
58
|
const TYPING_SEND_MS = 3000
|
|
53
59
|
const TYPING_SHOW_MS = 8000
|
|
54
60
|
|
|
@@ -90,14 +96,58 @@ function el(doc, tag, attrs = {}, text) {
|
|
|
90
96
|
return e
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
// TURNSTILE (operator, 2026-11-13). Loaded only when the panel opened, the challenge had no time to
|
|
100
|
+
// observe the visitor and often showed its « Vérifiez que vous êtes humain » box. So it now starts
|
|
101
|
+
// early, but never in a test run: the trigger imports this file on the visitor's first sign of life
|
|
102
|
+
// on the page (a scroll, a pointer moving, a press, a key), and mountChat then starts api.js through
|
|
103
|
+
// @pmeweb/loader onApproach on the chat root (fixed on screen): once the page has painted (`load`,
|
|
104
|
+
// first contentful paint, LCP, and this file's own import, whichever is latest) plus WARM_MS, or at
|
|
105
|
+
// once on a press or a focus in the chat, or when the panel opens. Lighthouse never interacts, so it
|
|
106
|
+
// never imports this file and never fetches api.js; a visitor who reads for a few seconds before
|
|
107
|
+
// opening the panel usually finds the token minted.
|
|
108
|
+
//
|
|
109
|
+
// THE WIDGETS RENDER HIDDEN, in the closed panel (a display:none iframe still runs: measured with
|
|
110
|
+
// Cloudflare's test keys, a token in ~1.9 s). Each sits in a row that is COLLAPSED until Cloudflare
|
|
111
|
+
// itself asks for the visitor: its `before-interactive` and `error` callbacks open the row, its
|
|
112
|
+
// success callback collapses it again (so « Succès ! » goes away), and a later expiry that needs a
|
|
113
|
+
// click opens it again. The rule « never hide the box with a guessed rule » holds: the row follows
|
|
114
|
+
// Cloudflare's callbacks, never the iframe's size. Implicit rendering only scans the page once, when
|
|
115
|
+
// api.js runs, so a widget added after a contact form already loaded api.js is rendered here.
|
|
116
|
+
//
|
|
117
|
+
// THE TOKEN: Turnstile refreshes an unused one before its 300 s expiry (refresh-expired: auto); after
|
|
118
|
+
// each post the widget is reset (a token is single-use), only while its row is not asking: a reset
|
|
119
|
+
// would wipe the box the visitor is solving. `reset(el)` names our widget, never every widget on the
|
|
120
|
+
// page (@pmeweb/turnstile rule 4). Its own resetToken is not used: it keys on the box's height, and
|
|
121
|
+
// a solved box keeps its height inside our collapsed row.
|
|
122
|
+
const defaultTurnstile = (win, widgets) => {
|
|
123
|
+
let polling = false
|
|
124
|
+
const render = () => {
|
|
125
|
+
const ts = win.turnstile
|
|
126
|
+
if (!ts?.render) return false
|
|
127
|
+
for (const w of widgets) if (!w.childElementCount) try { ts.render(w) } catch { /* left to tokenReady's timeout */ }
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
const load = () => {
|
|
131
|
+
loadTurnstile(win.document)
|
|
132
|
+
if (polling || render()) return
|
|
133
|
+
polling = true
|
|
134
|
+
let n = 0
|
|
135
|
+
const tick = () => { if (!render() && ++n < 600) win.setTimeout(tick, 100) }
|
|
136
|
+
win.setTimeout(tick, 100)
|
|
137
|
+
}
|
|
138
|
+
for (const w of widgets) { const f = w.closest('form'); if (f) f.pmwTurnstileLoad = load }
|
|
139
|
+
return {
|
|
140
|
+
load,
|
|
141
|
+
async token(form) {
|
|
142
|
+
await tokenReady(form, { win })
|
|
143
|
+
return form.querySelector('input[name="cf-turnstile-response"]')?.value ?? ''
|
|
144
|
+
},
|
|
145
|
+
reset(form) {
|
|
146
|
+
const w = form.querySelector('.cf-turnstile')
|
|
147
|
+
if (w && !w.closest('[data-ask]')) win.turnstile?.reset(w)
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
}
|
|
101
151
|
|
|
102
152
|
export function mountChat(root, deps = {}) {
|
|
103
153
|
if (root.pmwChat) return root.pmwChat
|
|
@@ -105,7 +155,8 @@ export function mountChat(root, deps = {}) {
|
|
|
105
155
|
const doc = win.document
|
|
106
156
|
const WS = deps.WebSocket ?? win.WebSocket
|
|
107
157
|
const fetchImpl = deps.fetch ?? ((...a) => win.fetch(...a))
|
|
108
|
-
const
|
|
158
|
+
const widgets = []
|
|
159
|
+
let turnstile = deps.turnstile ?? null
|
|
109
160
|
const uuid = deps.uuid ?? (() => win.crypto.randomUUID())
|
|
110
161
|
const shrink = deps.shrink ?? ((file) => shrinkPhoto(win, file))
|
|
111
162
|
// A data: URL, not a blob: one: site CSPs allow `img-src data:` (boilerplate worker), not blob:.
|
|
@@ -164,19 +215,37 @@ export function mountChat(root, deps = {}) {
|
|
|
164
215
|
const state = el(doc, 'p', { class: 'pmw-chat__state', role: 'status' })
|
|
165
216
|
const announcer = el(doc, 'p', { class: 'pmw-chat__sr', 'aria-live': 'polite' })
|
|
166
217
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
218
|
+
// A widget in its row (see « TURNSTILE » above). Implicit rendering reads callbacks as global
|
|
219
|
+
// function names, one set per widget (`c` the composer, `f` the contact form).
|
|
220
|
+
const widget = (key) => {
|
|
221
|
+
const row = el(doc, 'div', { class: 'pmw-chat__check' })
|
|
222
|
+
const inner = el(doc, 'div', { class: 'pmw-chat__check-in' })
|
|
223
|
+
const cb = `pmwChatTurnstile${key}`
|
|
224
|
+
const ask = (on) => () => { row.toggleAttribute('data-ask', on); follow() }
|
|
225
|
+
win[`${cb}Ask`] = ask(true)
|
|
226
|
+
win[`${cb}Error`] = ask(true)
|
|
227
|
+
win[`${cb}Done`] = ask(false)
|
|
228
|
+
const w = el(doc, 'div', {
|
|
229
|
+
class: 'cf-turnstile pmw-turnstile', 'data-sitekey': cfg.siteKey ?? '', 'data-action': 'chat', 'data-theme': 'light',
|
|
230
|
+
'data-language': lang, 'data-appearance': 'interaction-only', 'data-size': 'flexible',
|
|
231
|
+
'data-callback': `${cb}Done`, 'data-before-interactive-callback': `${cb}Ask`, 'data-error-callback': `${cb}Error`,
|
|
232
|
+
})
|
|
233
|
+
widgets.push(w)
|
|
234
|
+
inner.append(w)
|
|
235
|
+
row.append(inner)
|
|
236
|
+
return row
|
|
237
|
+
}
|
|
171
238
|
const composer = el(doc, 'form', { class: 'pmw-chat__composer', novalidate: true })
|
|
172
239
|
const input = el(doc, 'textarea', { id: 'pmw-chat-input', name: 'message', rows: '1', maxlength: String(MAX_TEXT), placeholder: s.messageLabel, autocomplete: 'off', disabled: demo, 'aria-describedby': demo ? 'pmw-chat-demo-hint' : null })
|
|
173
240
|
const send = el(doc, 'button', { type: 'submit', class: 'pmw-chat__send', disabled: demo }, s.send)
|
|
174
241
|
// One rounded field holds the photo button, the text and the send button; hints and errors go under it.
|
|
175
242
|
const box = el(doc, 'div', { class: 'pmw-chat__box' })
|
|
176
243
|
box.append(el(doc, 'label', { class: 'pmw-chat__sr', for: 'pmw-chat-input' }, s.messageLabel), input, send)
|
|
244
|
+
// The check row sits ABOVE the field: the composer is pinned to the panel's bottom, so the row
|
|
245
|
+
// opening or collapsing never moves the field the visitor is typing in.
|
|
246
|
+
if (!demo) composer.append(widget('c'))
|
|
177
247
|
composer.append(box)
|
|
178
248
|
if (demo) composer.append(el(doc, 'p', { class: 'pmw-chat__hint', id: 'pmw-chat-demo-hint' }, s.demoHint))
|
|
179
|
-
else composer.append(widget(composer))
|
|
180
249
|
let picker = null
|
|
181
250
|
const photoError = el(doc, 'p', { class: 'pmw-chat__error pmw-chat__photo-error', role: 'alert' })
|
|
182
251
|
if (cfg.photos === true) {
|
|
@@ -210,11 +279,49 @@ export function mountChat(root, deps = {}) {
|
|
|
210
279
|
field('need', 'textarea', { rows: '3', maxlength: '2000' }),
|
|
211
280
|
fbError, el(doc, 'button', { type: 'submit', class: 'pmw-chat__send' }, s.fallbackSend),
|
|
212
281
|
)
|
|
213
|
-
if (!demo) fallback.
|
|
282
|
+
if (!demo) fallback.insertBefore(widget('f'), fbError)
|
|
214
283
|
const body = el(doc, 'div', { class: 'pmw-chat__body' })
|
|
215
284
|
body.append(log, state, ended, fallback)
|
|
216
285
|
panel.append(head, body, composer, announcer)
|
|
217
286
|
root.append(panel)
|
|
287
|
+
turnstile ??= defaultTurnstile(win, widgets)
|
|
288
|
+
|
|
289
|
+
// ---- following the thread ---------------------------------------------------------------
|
|
290
|
+
// PINNED (operator, 2026-11-13): while the visitor is at the bottom (within PIN_SLACK), every
|
|
291
|
+
// change that adds height (a streamed fragment, the stored message replacing it, the typing dots,
|
|
292
|
+
// a photo once decoded, the takeover line, the end block, the composer growing or its check row
|
|
293
|
+
// opening) keeps the thread at the bottom. A visitor who scrolled UP to read stays where they are;
|
|
294
|
+
// reaching the bottom again pins it again. Sending a message is explicit: it always pins. The
|
|
295
|
+
// scroll is smooth, one per animation frame however many fragments land in it (a stream would
|
|
296
|
+
// otherwise stack dozens), and instant under prefers-reduced-motion and when the panel opens.
|
|
297
|
+
// Our own scrolls only ever move down, so a scroll that moves UP away from the bottom is the
|
|
298
|
+
// visitor's.
|
|
299
|
+
let pinned = true
|
|
300
|
+
let lastTop = 0
|
|
301
|
+
let followAt = 0
|
|
302
|
+
const gap = () => body.scrollHeight - body.scrollTop - body.clientHeight
|
|
303
|
+
body.addEventListener('scroll', () => {
|
|
304
|
+
const t = body.scrollTop
|
|
305
|
+
if (gap() <= PIN_SLACK) pinned = true
|
|
306
|
+
else if (t < lastTop) pinned = false
|
|
307
|
+
lastTop = t
|
|
308
|
+
}, { passive: true })
|
|
309
|
+
const nextFrame = (f) => (win.requestAnimationFrame ? win.requestAnimationFrame(f) : win.setTimeout(f, 16))
|
|
310
|
+
function follow(force = false, instant = false) {
|
|
311
|
+
if (force) pinned = true
|
|
312
|
+
if (!pinned || followAt) return
|
|
313
|
+
followAt = nextFrame(() => {
|
|
314
|
+
followAt = 0
|
|
315
|
+
if (!pinned) return
|
|
316
|
+
const still = instant || !!win.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
|
317
|
+
body.scrollTo({ top: body.scrollHeight, behavior: still ? 'instant' : 'smooth' })
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
if (typeof win.ResizeObserver === 'function') {
|
|
321
|
+
const ro = new win.ResizeObserver(() => follow())
|
|
322
|
+
ro.observe(log)
|
|
323
|
+
ro.observe(composer)
|
|
324
|
+
}
|
|
218
325
|
|
|
219
326
|
// ---- state ------------------------------------------------------------------------------
|
|
220
327
|
let session = null
|
|
@@ -269,18 +376,37 @@ export function mountChat(root, deps = {}) {
|
|
|
269
376
|
// scan back stops at a line already there or at an owner message, so one takeover gets one line
|
|
270
377
|
// however often the frames repeat it; the bot answering again and a person taking over again
|
|
271
378
|
// gets another.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
379
|
+
//
|
|
380
|
+
// THE HAND-BACK (operator, 2026-11-13) is its mirror: where the owner gives the conversation back
|
|
381
|
+
// (« Rendre à l’assistant »), one line « L’assistant virtuel a repris la conversation », always
|
|
382
|
+
// the « assistant virtuel » wording (never the bot's name alone: it must not pass for a person).
|
|
383
|
+
// It goes before the first bot message that follows a person (live, and on replay), or at the end
|
|
384
|
+
// when the mode turns back to bot before the bot speaks. Its scan back stops at a hand-back line
|
|
385
|
+
// already there or at a bot message (nothing to mark), and inserts only past an owner message or a
|
|
386
|
+
// takeover line: a conversation the bot never left gets none. A takeover after it gets its own line.
|
|
387
|
+
const lastLine = () => (typing.parentNode === log ? typing.previousElementSibling : log.lastElementChild)
|
|
388
|
+
const isA = (x, c) => x.classList.contains(c)
|
|
389
|
+
function separator(cls, dotCls, text, at) {
|
|
390
|
+
const sep = el(doc, 'li', { class: cls, role: 'separator', 'aria-label': text })
|
|
280
391
|
const label = el(doc, 'span', { class: 'pmw-chat__relay-label', 'aria-hidden': 'true' })
|
|
281
|
-
label.append(el(doc, 'span', { class:
|
|
392
|
+
label.append(el(doc, 'span', { class: dotCls }), text)
|
|
282
393
|
sep.append(label)
|
|
283
394
|
log.insertBefore(sep, at ?? (typing.parentNode === log ? typing : null))
|
|
395
|
+
follow()
|
|
396
|
+
}
|
|
397
|
+
function relay(at, by) {
|
|
398
|
+
for (let x = at ? at.previousElementSibling : lastLine(); x; x = x.previousElementSibling) {
|
|
399
|
+
if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) return
|
|
400
|
+
if (isA(x, 'pmw-chat__msg--bot') || isA(x, 'pmw-chat__handback')) break
|
|
401
|
+
}
|
|
402
|
+
separator('pmw-chat__relay', 'pmw-chat__relay-dot', by ? fill(s.relay, { name: by }) : s.relayGeneric, at)
|
|
403
|
+
}
|
|
404
|
+
function handback(at) {
|
|
405
|
+
for (let x = at ? at.previousElementSibling : lastLine(); ; x = x.previousElementSibling) {
|
|
406
|
+
if (!x || isA(x, 'pmw-chat__handback') || isA(x, 'pmw-chat__msg--bot')) return
|
|
407
|
+
if (isA(x, 'pmw-chat__relay') || isA(x, 'pmw-chat__msg--owner')) break
|
|
408
|
+
}
|
|
409
|
+
separator('pmw-chat__handback', 'pmw-chat__relay-dot pmw-chat__relay-dot--back', s.handback, at)
|
|
284
410
|
}
|
|
285
411
|
const find = (id) => [...log.querySelectorAll('li[data-id]')].find((li) => li.dataset.id === id)
|
|
286
412
|
const setText = (li, m, text) => {
|
|
@@ -292,6 +418,14 @@ export function mountChat(root, deps = {}) {
|
|
|
292
418
|
typing.hidden = true
|
|
293
419
|
win.clearTimeout(typingTimer)
|
|
294
420
|
}
|
|
421
|
+
/** The dots, until a reply hides them; `ms` also hides them after that long without one. */
|
|
422
|
+
function showTyping(ms) {
|
|
423
|
+
typing.hidden = false
|
|
424
|
+
log.append(typing)
|
|
425
|
+
win.clearTimeout(typingTimer)
|
|
426
|
+
if (ms) typingTimer = win.setTimeout(hideTyping, ms)
|
|
427
|
+
follow()
|
|
428
|
+
}
|
|
295
429
|
function render(m, { announce }) {
|
|
296
430
|
if (seen.has(m.seq)) return
|
|
297
431
|
seen.add(m.seq)
|
|
@@ -300,6 +434,7 @@ export function mountChat(root, deps = {}) {
|
|
|
300
434
|
if (!li) {
|
|
301
435
|
li = line(m.id, m.role)
|
|
302
436
|
if (m.role === 'owner') relay(li, m.by || ownerName)
|
|
437
|
+
else if (m.role === 'bot') handback(li)
|
|
303
438
|
}
|
|
304
439
|
li.classList.remove('is-streaming', 'is-pending', 'is-failed')
|
|
305
440
|
li.querySelector('.pmw-chat__retry')?.remove()
|
|
@@ -313,20 +448,20 @@ export function mountChat(root, deps = {}) {
|
|
|
313
448
|
if (announce) announcer.textContent = fill(s.said, { name: speaker(m), text: m.text })
|
|
314
449
|
if (panel.hidden) root.setAttribute('data-unread', '')
|
|
315
450
|
}
|
|
316
|
-
|
|
451
|
+
follow()
|
|
317
452
|
}
|
|
318
453
|
function delta(id, text) {
|
|
319
454
|
hideTyping()
|
|
320
455
|
let li = find(id)
|
|
321
456
|
if (!li) {
|
|
322
457
|
li = line(id, 'bot')
|
|
458
|
+
handback(li)
|
|
323
459
|
li.classList.add('is-streaming')
|
|
324
460
|
setText(li, { role: 'bot' }, '')
|
|
325
461
|
}
|
|
326
462
|
li.lastChild.textContent += text
|
|
327
|
-
|
|
463
|
+
follow()
|
|
328
464
|
}
|
|
329
|
-
const scroll = () => { log.scrollTop = log.scrollHeight }
|
|
330
465
|
if (demo) {
|
|
331
466
|
setText(line('demo-question', 'visitor'), { role: 'visitor' }, demoQuestion(opener, lang))
|
|
332
467
|
const answer = line('demo-answer', 'bot')
|
|
@@ -375,6 +510,8 @@ export function mountChat(root, deps = {}) {
|
|
|
375
510
|
ws = sock
|
|
376
511
|
sock.onopen = () => {
|
|
377
512
|
attempt = 0
|
|
513
|
+
// A first start: the dots stay until the reply, for TYPING_SHOW_MS at most, like the service's.
|
|
514
|
+
if (slowTimer) { notSlow(); showTyping(TYPING_SHOW_MS) }
|
|
378
515
|
showState('')
|
|
379
516
|
sock.send(JSON.stringify({ type: 'hello', since: lastSeq }))
|
|
380
517
|
for (const entry of pending.values()) transmit(entry)
|
|
@@ -389,6 +526,7 @@ export function mountChat(root, deps = {}) {
|
|
|
389
526
|
for (const entry of pending.values()) if (entry.tries >= MAX_TRIES) failed(entry)
|
|
390
527
|
if (e.code >= 4000 && e.code < 5000) return refused(e.code)
|
|
391
528
|
if (over) return
|
|
529
|
+
notSlow()
|
|
392
530
|
showState(s.reconnecting)
|
|
393
531
|
const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]
|
|
394
532
|
attempt++
|
|
@@ -408,12 +546,14 @@ export function mountChat(root, deps = {}) {
|
|
|
408
546
|
ended.hidden = false
|
|
409
547
|
composer.hidden = true
|
|
410
548
|
for (const entry of pending.values()) failed(entry)
|
|
549
|
+
follow()
|
|
411
550
|
}
|
|
412
551
|
function status(m) {
|
|
413
552
|
mode = m.mode === 'human' ? 'human' : 'bot'
|
|
414
553
|
ownerName = typeof m.ownerName === 'string' ? m.ownerName : null
|
|
415
554
|
header()
|
|
416
555
|
if (mode === 'human') relay(null, ownerName)
|
|
556
|
+
else handback(null)
|
|
417
557
|
if (m.closed) end(s.ended, true)
|
|
418
558
|
}
|
|
419
559
|
function frame(m) {
|
|
@@ -428,11 +568,7 @@ export function mountChat(root, deps = {}) {
|
|
|
428
568
|
return delta(m.id, m.text)
|
|
429
569
|
case 'typing':
|
|
430
570
|
if (m.role === 'visitor') return
|
|
431
|
-
|
|
432
|
-
log.append(typing)
|
|
433
|
-
win.clearTimeout(typingTimer)
|
|
434
|
-
typingTimer = win.setTimeout(hideTyping, TYPING_SHOW_MS)
|
|
435
|
-
return scroll()
|
|
571
|
+
return showTyping(TYPING_SHOW_MS)
|
|
436
572
|
case 'status':
|
|
437
573
|
return status(m)
|
|
438
574
|
case 'fallback':
|
|
@@ -473,9 +609,19 @@ export function mountChat(root, deps = {}) {
|
|
|
473
609
|
const error = res?.status === 403 ? s.turnstile : res?.status === 429 ? s.tooMany : null
|
|
474
610
|
return { ok: false, error, cap: res?.status === 409 && data?.error === 'cap' }
|
|
475
611
|
}
|
|
612
|
+
// THE FIRST START (operator, 2026-11-13) reads like any reply: the typing dots at once, no
|
|
613
|
+
// « Connexion… ». Only a start slower than SLOW_START_MS adds « Un instant… »; the socket's open
|
|
614
|
+
// clears it, a drop says « Connexion perdue, reconnexion… » as before. Nothing is started before
|
|
615
|
+
// the visitor writes: pmeweb-chat counts every /start toward the visitor's conversations of the day
|
|
616
|
+
// (src/ipLimit.ts there), so a panel opened and never used must not spend one. Only the token is
|
|
617
|
+
// prepared ahead (« TURNSTILE » above).
|
|
618
|
+
let slowTimer = null
|
|
619
|
+
const notSlow = () => { win.clearTimeout(slowTimer); slowTimer = null }
|
|
476
620
|
function begin() {
|
|
477
621
|
if (starting || session) return starting
|
|
478
|
-
showState(
|
|
622
|
+
showState('')
|
|
623
|
+
showTyping()
|
|
624
|
+
slowTimer = win.setTimeout(() => showState(s.oneMoment), SLOW_START_MS)
|
|
479
625
|
starting = post(composer, {}).then((r) => {
|
|
480
626
|
starting = null
|
|
481
627
|
if (r.ok && r.data.conversationId && r.data.visitorKey) {
|
|
@@ -485,6 +631,8 @@ export function mountChat(root, deps = {}) {
|
|
|
485
631
|
connect()
|
|
486
632
|
return
|
|
487
633
|
}
|
|
634
|
+
notSlow()
|
|
635
|
+
hideTyping()
|
|
488
636
|
for (const entry of pending.values()) failed(entry)
|
|
489
637
|
if (r.error) return showState(r.error)
|
|
490
638
|
showState('')
|
|
@@ -532,9 +680,12 @@ export function mountChat(root, deps = {}) {
|
|
|
532
680
|
const id = uuid()
|
|
533
681
|
const li = line(id, 'visitor')
|
|
534
682
|
li.classList.add('is-pending')
|
|
535
|
-
|
|
683
|
+
const img = el(doc, 'img', { class: 'pmw-chat__photo', src, alt: s.photoAlt })
|
|
684
|
+
// Its height is known only once decoded: re-pin then (follow() is a no-op if they scrolled up).
|
|
685
|
+
img.addEventListener('load', () => follow(), { once: true })
|
|
686
|
+
li.insertBefore(img, li.lastChild)
|
|
536
687
|
setText(li, { role: 'visitor' }, '')
|
|
537
|
-
|
|
688
|
+
follow(true)
|
|
538
689
|
if (!session) await begin()
|
|
539
690
|
let res = null
|
|
540
691
|
if (session) {
|
|
@@ -580,7 +731,7 @@ export function mountChat(root, deps = {}) {
|
|
|
580
731
|
entry.li.classList.add('is-pending')
|
|
581
732
|
setText(entry.li, { role: 'visitor' }, entry.text)
|
|
582
733
|
pending.set(entry.id, entry)
|
|
583
|
-
|
|
734
|
+
follow(true)
|
|
584
735
|
if (!session) begin()
|
|
585
736
|
else if (isOpen()) transmit(entry)
|
|
586
737
|
else connect()
|
|
@@ -616,7 +767,9 @@ export function mountChat(root, deps = {}) {
|
|
|
616
767
|
mode = 'bot'
|
|
617
768
|
ownerName = null
|
|
618
769
|
header()
|
|
619
|
-
for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay')]) li.remove()
|
|
770
|
+
for (const li of [...log.querySelectorAll('li[data-id], .pmw-chat__relay, .pmw-chat__handback')]) li.remove()
|
|
771
|
+
hideTyping()
|
|
772
|
+
notSlow()
|
|
620
773
|
ended.hidden = true
|
|
621
774
|
composer.hidden = false
|
|
622
775
|
input.focus()
|
|
@@ -644,7 +797,7 @@ export function mountChat(root, deps = {}) {
|
|
|
644
797
|
root.removeAttribute('data-unread')
|
|
645
798
|
if (session) connect()
|
|
646
799
|
;(demo ? closeBtn : composer.hidden ? (fallback.hidden ? restart : fallback.querySelector('input')) : input)?.focus()
|
|
647
|
-
|
|
800
|
+
follow(true, true)
|
|
648
801
|
}
|
|
649
802
|
function close() {
|
|
650
803
|
panel.hidden = true
|
|
@@ -652,6 +805,9 @@ export function mountChat(root, deps = {}) {
|
|
|
652
805
|
bubble?.focus()
|
|
653
806
|
}
|
|
654
807
|
|
|
808
|
+
// The early start (« TURNSTILE » above). Off the real hosts there is nothing to start.
|
|
809
|
+
if (!demo) onApproach(root, () => turnstile.load(), { win, settleMs: WARM_MS })
|
|
810
|
+
|
|
655
811
|
root.pmwChat = { open: show, close, panel }
|
|
656
812
|
return root.pmwChat
|
|
657
813
|
}
|
package/src/worker.js
CHANGED
|
@@ -32,7 +32,7 @@ import { verifyTurnstile } from '@pmeweb/turnstile/worker'
|
|
|
32
32
|
import { RPC_HEADER, RPC_VERSION } from './protocol.js'
|
|
33
33
|
|
|
34
34
|
/** This package's version, reported by the health route (a test holds it equal to package.json). */
|
|
35
|
-
export const VERSION = '1.
|
|
35
|
+
export const VERSION = '1.3.1'
|
|
36
36
|
export { RPC_VERSION }
|
|
37
37
|
|
|
38
38
|
const SERVICE = 'https://pmeweb-chat.internal'
|