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.
-