From 67c19230b4228b3cb63c6cd69873eccdcc051dcd Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 27 Sep 2026 18:19:03 +0000 Subject: [PATCH] contact messages: visitor identity, relative timestamps, alternating headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/messages.md | 30 +++++++------ frontend/src/Composer.vue | 13 +++++- frontend/src/ContactPanel.vue | 80 +++++++++++++++++++++++++++++------ frontend/src/MessagesView.vue | 56 ++++++++++++++++-------- frontend/src/contact-main.js | 14 +++--- pagerite/data.py | 4 ++ pagerite/messages.py | 33 +++++++++++++-- 7 files changed, 173 insertions(+), 57 deletions(-) diff --git a/docs/messages.md b/docs/messages.md index bc196b8..4ee4058 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -49,12 +49,14 @@ no extra connection). Frames **to** the server: `pagerite.js` re-sends this on every socket (re)connect, which is the resync mechanism (the socket idle-closes after 5 min and reconnects on activity). -- `{token, msg, pow, fr}` — append a message; `msg` is the Markdown-subset - source, `pow` the solved proof-of-work (below), `fr` the page path it - was sent from (stored for context). +- `{token, msg, pow, fr, name?, email?}` — append a message; `msg` is the + Markdown-subset source, `pow` the solved proof-of-work (below), `fr` the + page path it was sent from (stored for context). The **first** message + of a thread must carry `name` and `email` introducing the visitor; the + identity is stored on the thread and fixed from then on. -Frames **from** the server: `{type: "thread", id, created, unread, -messages: [{id, t, admin, html, page}]}` — a full thread snapshot +Frames **from** the server: `{type: "thread", id, created, unread, name, +email, messages: [{id, t, admin, html, page}]}` — a full thread snapshot (replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh single-use PoW challenge (`required` means the last message was rejected for a missing/stale solution and should be re-sent once solved); or @@ -142,18 +144,18 @@ server restart (the pending set is RAM-only) still linger — no GC. `Data.contact: dict[bytes, ContactThread]` in the kanta database, keyed by the 12-byte blake3 hash of the session token (never the token itself). A -thread is `created`, `admin_read`, and `messages: dict[str, -ContactMessage]` keyed by a sortable timestamp-random id (dict-keyed, so a -save's database diff touches only the appended message). Each message -stores `t`, the Markdown source `md`, its strict rendering `html`, `admin` -("" = visitor, else the SSO name) and `page`. Appends are single -transactions (`contact` / `contact:reply` / `contact:read`). +thread is `created`, `admin_read`, the visitor's self-entered +`name`/`email` (required with the first message, fixed afterwards) and +`messages: dict[str, ContactMessage]` keyed by a sortable timestamp-random +id (dict-keyed, so a save's database diff touches only the appended +message). Each message stores `t`, the Markdown source `md`, its strict +rendering `html`, `admin` ("" = visitor, else the SSO name) and `page`. +Appends are single transactions (`contact` / `contact:reply` / +`contact:read`). ## Limits and deliberate gaps (MVP) - Message source ≤ 8000 chars; uploads ≤ 20 MB and ≤ 10 pending per session; payloads cap at 100 threads × last 200 messages. - No thread moderation (delete/block), no orphan-upload GC for files - orphaned by a server restart, panel strings are English-only, and the - admin inbox lists threads by token-hash prefix rather than enriched - visitor identity. + orphaned by a server restart, and panel strings are English-only. diff --git a/frontend/src/Composer.vue b/frontend/src/Composer.vue index ccbac9e..32f32b9 100644 --- a/frontend/src/Composer.vue +++ b/frontend/src/Composer.vue @@ -14,6 +14,10 @@ const props = defineProps({ // whose uploads go to the /_api file store instead. token: { type: String, default: '' }, autofocus: { type: Boolean, default: false }, + // When true, send (button and Ctrl+Enter) is inert and the draft is + // kept — e.g. the contact panel's first-message name/email fields are + // still empty. + sendDisabled: { type: Boolean, default: false }, }) const emit = defineEmits(['send']) @@ -109,7 +113,7 @@ function insertUpload({ path, image, name }) { function send() { const md = serializeRich(box.value) - if (!md || uploading.value) return + if (!md || uploading.value || props.sendDisabled) return emit('send', md) box.value.innerHTML = '' } @@ -142,7 +146,7 @@ defineExpose({ focus: () => box.value?.focus() }) uploading… - +

{{ error }}

box.value?.focus() }) .composer-bar button:hover { background: color-mix(var(--muted) 15%, transparent); } +.composer-bar button:disabled { + opacity: 0.4; + cursor: default; + background: none; +} .composer-bar .uploading { margin-left: 0.4rem; color: var(--muted); diff --git a/frontend/src/ContactPanel.vue b/frontend/src/ContactPanel.vue index 1b2f07e..fbae0f5 100644 --- a/frontend/src/ContactPanel.vue +++ b/frontend/src/ContactPanel.vue @@ -3,7 +3,8 @@ // socket (via the bridge pagerite.js hands to contact-main), with the // composer shared with the admin inbox. state is contact-main's reactive // store ({open, thread, unread}). -import { nextTick, ref, watch } from 'vue' +import { computed, nextTick, reactive, ref, watch } from 'vue' +import { formatWhen } from './analytics/format.js' import Composer from './Composer.vue' const props = defineProps({ @@ -22,8 +23,36 @@ async function scrollDown() { watch(() => props.state.thread?.messages?.length, scrollDown) watch(() => props.state.open, (open) => { if (open) scrollDown() }) -function fmtTime(t) { - return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) +// Relative timestamps like in analytics; the full date is on hover. +const fmtTime = (t) => formatWhen(t) +const fmtFull = (t) => + new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) + +const NAME_KEY = 'pagerite-contact-name' +const EMAIL_KEY = 'pagerite-contact-email' + +// Name and email are asked with the first message; from then on the +// thread's stored identity labels the visitor's messages. +const intro = reactive({ + name: localStorage.getItem(NAME_KEY) || '', + email: localStorage.getItem(EMAIL_KEY) || '', +}) +const showIntro = computed(() => !props.state.thread?.name) +// Sending stays disabled until both intro fields look filled. +const introInvalid = computed( + () => showIntro.value && (!intro.name.trim() || !intro.email.trim().includes('@')), +) + +function onSend(md) { + if (showIntro.value) { + const name = intro.name.trim() + const email = intro.email.trim() + localStorage.setItem(NAME_KEY, name) + localStorage.setItem(EMAIL_KEY, email) + emit('send', { md, name, email }) + return + } + emit('send', { md }) } @@ -38,19 +67,27 @@ function fmtTime(t) {

Send us a message — the reply appears here.

-
+
- {{ m.admin || 'you' }} - ⌂ - + {{ m.admin || state.thread?.name || 'you' }} +
- +
+ + +
+ @@ -143,15 +180,32 @@ function fmtTime(t) { color: var(--muted); font-size: 0.75rem; } +.contact-msg.mine header { + justify-content: flex-end; +} .contact-msg header .who { font-weight: 600; color: var(--text, CanvasText); } -.contact-msg header .page { - color: var(--muted); - text-decoration: none; + +.contact-intro { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; } -.contact-msg header .page:hover { - color: var(--accent); +.contact-intro input { + flex: 1; + min-width: 8rem; + padding: 0.3rem 0.5rem; + border: 1px solid var(--line); + border-radius: 0.3rem; + background: var(--bg, Canvas); + color: var(--text, CanvasText); + font: inherit; + font-size: 0.9rem; +} +.contact-intro input:focus { + border-color: var(--accent); + outline: none; } diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue index 3655932..5d0d033 100644 --- a/frontend/src/MessagesView.vue +++ b/frontend/src/MessagesView.vue @@ -5,6 +5,7 @@ // server's strict-markdown rendering — injected verbatim, never user // markup (pagerite/messages.py). import { computed, nextTick, ref, watch } from 'vue' +import { formatWhen } from './analytics/format.js' import Composer from './Composer.vue' const props = defineProps({ @@ -56,9 +57,10 @@ function preview(t) { return (who + (div.textContent || '')).slice(0, 80) } -function fmtTime(t) { - return new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) -} +// Relative timestamps like elsewhere in analytics; full date on hover. +const fmtTime = (t) => formatWhen(t) +const fmtFull = (t) => + new Date(t).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) function send(md) { if (thread.value) emit('reply', { id: thread.value.id, text: md }) @@ -79,20 +81,28 @@ function send(md) { > - {{ t.id.slice(0, 8) }} + {{ t.name || t.id.slice(0, 8) }} {{ preview(t) }} - +
+
+ {{ thread.name }} + {{ thread.email }} +
-
+
- {{ m.admin || 'visitor' }} - {{ m.page }} - + {{ m.admin || thread.name || 'visitor' }} +
@@ -183,6 +193,22 @@ function send(md) { margin-top: 2rem; } +.thread-head { + display: flex; + gap: 0.6rem; + align-items: baseline; + padding-bottom: 0.25rem; + border-bottom: 1px solid var(--line); + font-size: 0.9rem; +} +.thread-head a { + color: var(--muted); + text-decoration: none; +} +.thread-head a:hover { + color: var(--accent); +} + .contact-msg { padding: 0.55rem 0; } @@ -215,19 +241,13 @@ function send(md) { color: var(--muted); font-size: 0.75rem; } +.contact-msg.mine header { + justify-content: flex-end; +} .contact-msg header .who { font-weight: 600; color: var(--text, CanvasText); } -.contact-msg header .page { - color: var(--muted); - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -.contact-msg header .page:hover { - color: var(--accent); -} @media (max-width: 48rem) { .messages-view { diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index 90b2fba..13f9f71 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -41,7 +41,7 @@ let token = '' let solverPromise = null let powSolution = null // Promise solving the current challenge let powAborter = null -let unsent = null // last message text, retried once if its solution was rejected +let unsent = null // last message frame, retried once if its solution was rejected // The solution for the currently outstanding challenge; rides out a // challenge replacement (reconnect) by continuing with the new solve. @@ -57,10 +57,10 @@ async function currentSolution() { } } -async function sendMessage(md) { - unsent = md +async function sendMessage(md, name = '', email = '') { + unsent = { md, name, email } const pow = await currentSolution() - if (pow) bridge.send({ token, msg: md, pow }) + if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) }) } async function onPow(msg) { @@ -73,10 +73,10 @@ async function onPow(msg) { // A rejected send (stale or already burned solution) is retried once // the fresh challenge is solved; a second rejection drops it. if (msg.required && unsent) { - const md = unsent + const { md, name, email } = unsent unsent = null const pow = await currentSolution() - if (pow) bridge.send({ token, msg: md, pow }) + if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) }) } } @@ -105,7 +105,7 @@ export function open(br) { app = createApp(ContactPanel, { state, token, - onSend: (md) => sendMessage(md), + onSend: ({ md, name, email }) => sendMessage(md, name, email), onClose: () => { state.open = false refreshBadges() diff --git a/pagerite/data.py b/pagerite/data.py index fc06ad5..9757536 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -83,6 +83,10 @@ class ContactThread(msgspec.Struct, omit_defaults=True): #: When an admin last looked at the thread; visitor messages newer #: than this are unread in the admin inbox. admin_read: datetime | None = None + #: The visitor's self-entered identity, captured with their first + #: message and fixed afterwards ("" until then). + name: str = "" + email: str = "" class Node(msgspec.Struct, omit_defaults=True): diff --git a/pagerite/messages.py b/pagerite/messages.py index c4b8df3..7700416 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -152,6 +152,8 @@ def _payload(key: bytes, thread: ContactThread) -> dict: "id": key.hex(), "created": thread.created.isoformat(), "unread": _unread(thread), + "name": thread.name, + "email": thread.email, "messages": [ { "id": mid, @@ -167,7 +169,7 @@ def _payload(key: bytes, thread: ContactThread) -> dict: def _empty_payload(key: bytes) -> dict: return {"type": "thread", "id": key.hex(), "created": "", "unread": False, - "messages": []} + "name": "", "email": "", "messages": []} async def _push(sockets, payload: dict) -> None: @@ -191,13 +193,24 @@ async def _broadcast(key: bytes, thread: ContactThread) -> None: await _push(_admin_ws, payload) -def _append(key: bytes, msg: ContactMessage, action: str, user: str | None) -> None: +def _append( + key: bytes, + msg: ContactMessage, + action: str, + user: str | None, + name: str = "", + email: str = "", +) -> None: """Append a message to the thread (created on first use), in one - transaction so the database diff is just the new entry.""" + transaction so the database diff is just the new entry. A visitor's + name/email are recorded alongside their first message.""" with kanta.transaction(action, user=user, extra=key.hex()): thread = data.contact.get(key) if thread is None: thread = data.contact[key] = ContactThread(created=msg.t) + if name and not thread.name: + thread.name = name + thread.email = email thread.messages[_new_id(msg.t)] = msg @@ -224,6 +237,18 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: if len(text) > MAX_MESSAGE: await ws.send_json({"type": "contact-error", "detail": "message too long"}) return + # The first message must introduce the visitor (name + email); the + # stored identity is fixed from then on. + thread = data.contact.get(key) + name = email = "" + if thread is None or not thread.name: + name = str(raw.get("name") or "").strip()[:100] + email = str(raw.get("email") or "").strip()[:200] + if not name or "@" not in email: + await ws.send_json( + {"type": "contact-error", "detail": "name and email required"} + ) + return now = datetime.now(UTC) _append( key, @@ -235,6 +260,8 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: ), "contact", None, + name, + email, ) # Files the message references are no longer pending cleanup. if pending := _pending_uploads.get(key):