- The first message of a thread must carry name + email introducing the
visitor; the panel shows the two fields until the thread has an
identity (send stays disabled while they look empty, so the draft is
never lost), prefilled from and saved to localStorage. The identity
is stored on ContactThread (msgspec defaults — old databases decode
unchanged) and fixed afterwards; it labels the visitor's messages
instead of 'you', and the admin inbox lists threads by name with the
email as a mailto in the thread header.
- Timestamps use the analytics relative formatter (formatWhen), with
the full date on hover.
- Message headers alternate alignment (own messages right, theirs
left) as the visual side cue; the sent-from page link is dropped
from the display (kept as stored metadata).
- Composer gains a sendDisabled prop for the intro gate.
The serializer treated only DIV/P as blocks: a formatting element
directly under the editable root (a bolded segment of an undivided
line is a top-level <b>) became a separate PLAIN block, losing both
the formatting and the paragraph structure. Top-level inline runs now
accumulate into one paragraph, and style-based formatting (Firefox's
font-weight/font-style spans) maps back to markers.
Attachments: the upload allowlist rejected everything but a handful of
types with a console-only 415. The logic inverts — raster images get
derivatives, script carriers (svg/html/js/xml...) are refused, and any
other file stores as a download chip. Upload failures now show in the
composer, and toolbar buttons keep the contenteditable selection on
click (mousedown.prevent).
The composer is a constrained contenteditable (plain-text paste,
toolbar formatting, upload chips for images/attachments) serialized to
a Markdown subset by richtext.js — the wire format is text and the
server re-renders it, so no user markup ever reaches a page. Uploads go
to /_contact/files with the session token (admins share the component
via /_api/files). ContactPanel shows the thread pushed live over the
activity socket: pagerite.js lazy-loads the bundle on trigger clicks,
routes socket pushes to it, re-registers the session token on every
reconnect (the resync), and dots trigger buttons on unread replies.