The conversation panel told the reader that two automatic sends were absent from the thread. Only one is now — 97 removed the "I have your message" receipt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
133 lines
5.4 KiB
JavaScript
133 lines
5.4 KiB
JavaScript
import { useEffect, useRef } from 'react'
|
|
import { baseFieldId } from '../api/config.js'
|
|
import './Conversation.css'
|
|
|
|
/**
|
|
* The WhatsApp thread, read out of the audit trail.
|
|
*
|
|
* The trail shows each turn as its own entry among twenty others, so reading a
|
|
* conversation meant scrolling a log and holding the order in your head. Here
|
|
* it is what it is: a thread, oldest first, theirs on the left and ours on the
|
|
* right.
|
|
*
|
|
* WHAT IT CAN AND CANNOT SHOW, said plainly at the foot of the panel rather
|
|
* than left for someone to discover. The console can only show what the
|
|
* workflow RECORDS, so anything sent from inside a trigger node and never
|
|
* written to a field is invisible here however certainly the customer received
|
|
* it. The acceptance confirmation used to be exactly that — the panel showed
|
|
* nothing after "I confirm", which read as though we had ignored somebody
|
|
* agreeing to buy — so 90 writes it into customer_answer and it appears.
|
|
*
|
|
* One send remains absent: the quote, which is a registered WhatsApp template
|
|
* rather than text we compose. The "I have your message" receipt used to be a
|
|
* second — it is gone from the workflow now (97), because it put a line saying
|
|
* only "an answer is coming" between every question and its answer.
|
|
*
|
|
* A thread that quietly omitted any of this would be worse than one that says
|
|
* which parts it holds.
|
|
*/
|
|
function turnsFrom(rows) {
|
|
if (!Array.isArray(rows)) return []
|
|
const out = []
|
|
// Sorted on (timestamp, id). One submission writes three rows in the same
|
|
// second — the trigger commit, its repeat, and the settle — so a timestamp
|
|
// alone leaves their order to the sort's stability and puts a reply before
|
|
// the message it answers whenever the two land in the same second.
|
|
const ordered = [...rows].sort((a, b) => {
|
|
const t = String(a.created_at).localeCompare(String(b.created_at))
|
|
return t !== 0 ? t : (Number(a.id) || 0) - (Number(b.id) || 0)
|
|
})
|
|
for (const r of ordered) {
|
|
const fields = Array.isArray(r.fields) && r.fields.length
|
|
? r.fields
|
|
: Object.entries(r.data || {}).map(([k, v]) => ({ field_id: k, value: v }))
|
|
for (const f of fields) {
|
|
const base = baseFieldId(f.field_id)
|
|
const v = typeof f.value === 'string' ? f.value.trim() : ''
|
|
if (!v) continue
|
|
if (base === 'customer_reply') out.push({ side: 'them', text: v, at: r.created_at, key: r.id + '-in' })
|
|
if (base === 'customer_answer') out.push({ side: 'us', text: v, at: r.created_at, key: r.id + '-out' })
|
|
}
|
|
}
|
|
// One submission is recorded three times — the trigger commit, its repeat,
|
|
// and the settle — so the same sentence would print three times.
|
|
//
|
|
// De-duped against the PREVIOUS turn only, not against the whole thread. A
|
|
// customer who asks the same thing twice because the first went unanswered
|
|
// has said it twice, and a thread that silently showed it once would hide
|
|
// exactly the impatience an operator needs to see. Only an immediate repeat
|
|
// of the same side and the same words is the platform talking to itself.
|
|
return out.filter((t, i) => {
|
|
const prev = out[i - 1]
|
|
return !(prev && prev.side === t.side && prev.text === t.text)
|
|
})
|
|
}
|
|
|
|
function at(ts) {
|
|
const d = new Date(ts)
|
|
if (isNaN(d)) return ''
|
|
return d.toLocaleString('en-IN', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
export default function Conversation({ rows, name, onClose }) {
|
|
const ref = useRef(null)
|
|
const restore = useRef(null)
|
|
const turns = turnsFrom(rows)
|
|
|
|
useEffect(() => {
|
|
restore.current = document.activeElement
|
|
ref.current?.focus()
|
|
const onKey = (e) => { if (e.key === 'Escape') onClose() }
|
|
document.addEventListener('keydown', onKey)
|
|
const prev = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey)
|
|
document.body.style.overflow = prev
|
|
if (restore.current instanceof HTMLElement) restore.current.focus()
|
|
}
|
|
}, [onClose])
|
|
|
|
return (
|
|
<div className="prose__scrim" onClick={onClose} role="presentation">
|
|
<div
|
|
className="prose__dlg conv"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="WhatsApp conversation"
|
|
tabIndex={-1}
|
|
ref={ref}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="prose__head">
|
|
<h3>WhatsApp with {name || 'the customer'}</h3>
|
|
<button type="button" onClick={onClose} aria-label="Close">
|
|
<svg viewBox="0 0 14 14" aria-hidden="true">
|
|
<path d="M3.5 3.5l7 7M10.5 3.5l-7 7" fill="none" stroke="currentColor"
|
|
strokeWidth="1.6" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="conv__body">
|
|
{turns.length ? turns.map((t) => (
|
|
<div key={t.key} className={'bub bub--' + t.side}>
|
|
<p>{t.text}</p>
|
|
<span>{at(t.at)}</span>
|
|
</div>
|
|
)) : (
|
|
<p className="conv__empty">Nothing has been exchanged on WhatsApp yet.</p>
|
|
)}
|
|
</div>
|
|
|
|
<p className="conv__note">
|
|
Shows the customer's messages and everything written back to them,
|
|
including the acceptance confirmation. One automatic send is not here:
|
|
the quote, which goes out as a registered WhatsApp template rather than
|
|
text we compose.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|