Skip to main content

SMS Bulk Composer — Datastar / HATEOAS

The SMS composer's live cost/segment/preview logic is server-owned. The client is "dumb": on debounced textarea input (and recipient / sms_type changes) it POSTs the form to a Server-Sent-Events endpoint; the server computes everything and returns rendered HTML for the status line and the preview panel, plus the signals that gate the Send button. Only the trivial live character count (text.length) stays client-side, driven by a Datastar signal.

This replaced a large block of duplicated JavaScript that re-implemented GSM 03.38 / UCS-2 segment counting, URL-shorten estimation, the unicode policy, per-segment cost, balance checks, and the entire status-line composition — all of which is fundamentally the server's knowledge. There is now one authority.

Components

FileRole
jethro-sms/src/sms_statusline.phpPure PHP core + renderStatusline() / renderPreviewPanel() renderers.
calls/call_sms_statusline.class.phpSSE endpoint for the live cost/segment/preview pipeline.
include/sse.phpSSE helper (sseStart(), ssePatchElements(), ssePatchSignals()). Still needed — Datastar v1.0.2 only supports signal patching via SSE events.
resources/js/datastar.min.jsVendored Datastar v1.0.2 ESM bundle.
include/jethro_sms.phpprintTextbox() / printBulkSmsForm() / printSmsModal() — carries Datastar attributes, ids, initial server render.
templates/head.template.phpLoads Datastar <script type="module"> inside the PERM_SENDSMS + SMS-feature block.
resources/js/jethro-sms.jsSend-flow + message-history text filters + delivery polling. All maths, preview rendering, registration AJAX, cancel AJAX, sender-select toggling, and message-attribution filtering moved to Datastar.
jethro-sms/tests/statusline/*.phpUnit tests for the pure core + renderers. Replaced old browser-only jethro-sms-test.js.
calls/call_sms_sendernum.class.phpSender-number registration / OTP validation — returns ID'd HTML for Datastar morph.
calls/call_sms_cancel.class.phpCancel scheduled SMS — returns ID'd <span> for Datastar morph.
calls/call_sms_info.class.phpDelivery status lookup — returns HTML with Datastar data-on:click cancel links.

Vendored Datastar v1.0.2 (release tag v1.0.2, bundles/datastar.js), recorded in a header comment in resources/js/datastar.min.js and in the vendored-files table in AGENTS.md.

The SSE event names are the v1.x names: datastar-patch-elements and datastar-patch-signals (renamed from the v0.x datastar-merge-fragments / datastar-merge-signals). When upgrading, re-confirm these names against the new release and against include/sse.php.

SSE protocol

Call_SMS_Statusline emits a one-shot response (frames, then close):

event: datastar-patch-elements
data: elements <div id="sms-statusline-bulk" class="smscharactercount soft">...</div>

event: datastar-patch-elements
data: elements <div id="sms-preview-panel" class="sms-preview-panel">...</div>

event: datastar-patch-signals
data: signals {"smsSendBlocked": false, "smsBlockReason": ""}

Default element patch mode is morph, matched by top-level element id. The morph targets are #sms-statusline-bulk / #sms-preview-panel-bulk for the bulk form and #sms-statusline / #sms-preview-panel for the modal; the client sends the ids in hidden statusline_id / preview_id form fields. sseStart() flushes output buffers and sets X-Accel-Buffering: no so nginx/php-fpm don't buffer the stream (see nginx/CLAUDE_NGINX.md).

Signals

Statusline / preview pipeline (SSE)

SignalSourceMeaning
$smsmessage / $smsmessagebulkdata-bind on textareaLive char count (data-text="$smsmessage.length + ' chars'")
$smspreview / $smspreviewbulkdata-bind on preview checkboxShow/hide the preview panel wrapper
$smsSendBlockedSSE datastar-patch-signalstrue when blocked (unicode policy or over budget)
$smsBlockReasonSSE datastar-patch-signalsHuman-readable block reason
$editingPidPencil button data-on:clickWhich preview row is in edit mode
$smsoverride_<PID>data-bind on override textareaPer-recipient override text

UI toggles (client-only, no SSE)

SignalSourceApplies to
$saveasnote / $saveasnotebulkdata-bind on "Create Note" checkboxdata-show on note subject/action-date fields
$schedulesend / $schedulesendbulkdata-bind on "Schedule Send" checkboxdata-show on the datetime picker
$smssender / $smssenderbulkdata-bind on sender <select>data-show="$smssender == '_USER_MOBILE_'" on registration-wrapper

Message-list filters (client-only, no SSE)

SignalSourceApplies to
$showMultiSmsdata-bind on #show-multi-sms checkboxdata-show on .sms-multi message rows
$showSentSms / $showFailedSms / …data-bind on status filter checkboxesPer-row data-show derived from each message's delivery status
$filterSender / $filterRecipient / $filterBody / $filterDateFrom / $filterDateTo / $filterSingleOnlydata-bind on admin SMS history filtersPer-row data-show via el.dataset.* comparisons (no server round-trip, so no debounce)

Client wiring — SSE (statusline pipeline)

  • The composer <textarea> carries data-bind:<signal> (live count) and data-on:input__debounce.300ms="@post('?call=sms_statusline', {contentType: 'form'})".
  • The sms_type radios carry data-on:change posting to the same endpoint.
  • {contentType: 'form'} submits the enclosing <form> so sender, sms_type, personid[] and message_overrides[...] ride along.

Client wiring — HTML morph (cancel, register, OTP)

Cancel links, sender-number registration, and OTP validation use a simpler pattern: the element carries a data-on:click="@post(...)" attribute, the server endpoint returns text/html with an ID'd element, and Datastar morphs it into the existing DOM by id. No SSE frames, no JSON parsing, no jQuery .html() injection.

FlowTriggerEndpointResponse
Canceldata-on:click="@post('?call=sms_cancel&sms_id=N')"call_sms_cancel<span id="sms-cancel-N">…</span>
Registerdata-on:click="@post('?call=sms_sendernum&action=register&number=…&label=…')"call_sms_sendernum<div id="sms-register-label">…</div>
OTP verifydata-on:click="@post('?call=sms_sendernum&action=validate&number=…&label=…&otp=' + $otp)"call_sms_sendernum<div id="sms-register-label">…</div>

The modal body is a <form onsubmit="return false"> with a hidden personid field that the modal-open handler in jethro-sms.js populates.

Delivery-status polling (?call=sms_info)

Scheduled/pending deliveries render polling spans (renderSmsDeliveryStatusIndicator() / renderSmsDeliveryStatusIcon() in include/jethro_sms.php) that @get('?call=sms_info&id=N') on a data-on-interval; the response morphs the whole batch's status spans by id, re-arming each with a fresh interval. Three throttles keep this from storming:

  1. Interval backoffsmsScheduledPollIntervalSecs() shrinks the interval approaching the send time (tenth of remaining, floor 2s, cap 300s), grows it again at the same rate once past due, and stops polling (static span) once more than an hour overdue. Without the past-due branch, stale "scheduled" deliveries polled at the 2s floor forever (see docs/sms/improvements/54-scheduled-poll-backoff.md).
  2. Thundering-herd gate — only the lowest-ID scheduled delivery per sms_id performs the upstream lookup; sibling polls return empty.
  3. Session-cached status mapCall_SMS_Info caches the listRecentDeliveries() result (as plain arrays) in SessionSmsCache for 10s, coalescing a page of pollers into one upstream call.

Per-recipient overrides

Overrides are server-rendered in renderPreviewPanel(): each row has a message <span> and an edit <textarea> bound to $smsoverride_<PID> and named message_overrides[PID]. The pencil button sets $editingPid to toggle the swap (data-show); on blur the textarea posts back and the server recomputes the cost line (no segment/cost maths runs client-side). Because the override textareas are real form fields, the unchanged send path (?call=sms) picks them up via serialize() (bulk) or a DOM sweep (modal).

Encoding decision — UTF-16 counting vs code-point display

This was a genuine ambiguity, resolved as follows and pinned by jethro-sms/tests/statusline/test_nongsm_detection.php and test_gsm_length.php.

The old JS counted UTF-16 code units (String.length, charAt()), so an astral character (e.g. an emoji 😊, a surrogate pair) counts as 2. The PHP port replicates this exactly for all length/segment/policy maths: utf16Length() converts to UTF-16BE and divides the byte length by 2, and getNonGsmChars() iterates UTF-16 code units (surrogate halves are always non-GSM). This keeps the billed segment count identical to the old client.

For human-readable display of the offending characters (the "Remove special characters (…)" / "Unicode characters are not allowed: …" messages), a separate getNonGsmDisplayChars() iterates by code point (mb_str_split), so an emoji renders as itself rather than as two U+FFFD replacement characters. This is a deliberate, minor divergence from the old JS display (which showed two lone surrogates). Counting parity is preserved; only the cosmetic display improved.

Current implementation state

Converted to Datastar

AreaMechanismJS removed
Cost/segment/preview pipeline@post → SSE → morph + signals~400 lines (all maths, render)
Save-as-note toggledata-bind + data-show9 lines
Schedule-send toggledata-bind + data-show (visibility); JS keeps datetime default6 lines
Cancel linkdata-on:click="@post(...)" → server HTML morph14 lines
Sender registrationdata-on:click="@post(...)" → server HTML morph26 lines
OTP validationdata-on:click + signal binding → server HTML morph27 lines
Sender select toggledata-bind on select + data-show comparison24 lines
Show-multi-sms filterdata-bind on checkbox + data-show on rows9 lines
Status filtersPer-status data-bind on checkboxes + per-row data-show~30 lines (entire function removed)
initMessageFilters / initSmsHistoryFilters duplicates in jethro.jsRemoved82 lines
Admin SMS history filters (initSmsHistoryFilters in jethro-sms.js)Per-row data-show reading el.dataset.* against filter signals35 lines

Still jQuery (by design)

AreaReason
Send / note-creation flow (?call=sms)Preserved for stability — the risky path was not disturbed
Delivery polling ([data-delivery-id])Fires on page load; Datastar v1.0.2 has no data-on:load
Schedule datetime defaultNeeds new Date() — inherently client-side

What stayed unchanged

The send / note-creation flow is untouched — the Send button still POSTs ?call=sms and renders the JSON result. Only the live preview/statusline and supporting UI (toggles, registration, filters) moved to Datastar.

See also

  • docs/docs/developer/reference/sms/SMS_ARCHITECTURE.md — the wider SMS subsystem.
  • docs/docs/developer/reference/sms/character-counting.mdx — GSM/UCS-2 segment maths.