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):