contact messages: optional unvalidated email, plain relative timestamps

Email is no longer required and its format is not checked anywhere
(placeholder notes it's optional); only the name gates the first
message. Timestamps are just the analytics relative format — no full
date on hover.
This commit is contained in:
LeoVasanko committed 2026-09-27 18:31:21 +00:00
1 parent 67c19230b4
commit f4d1f0ac58
5 files changed
+22 -27

No files matched your search

+5 -3
View File
@@ -52,8 +52,9 @@ no extra connection). Frames **to** the server:
- `{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.
of a thread must carry `name` (and optionally `email`, unvalidated)
introducing the visitor; the identity is stored on the thread and fixed
from then on.
Frames **from** the server: `{type: "thread", id, created, unread, name,
email, messages: [{id, t, admin, html, page}]}` — a full thread snapshot
@@ -145,7 +146,8 @@ 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`, the visitor's self-entered
`name`/`email` (required with the first message, fixed afterwards) and
`name`/`email` (name required, email optional; captured 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
+7 -11
View File
@@ -23,25 +23,21 @@ async function scrollDown() {
watch(() => props.state.thread?.messages?.length, scrollDown)
watch(() => props.state.open, (open) => { if (open) scrollDown() })
// Relative timestamps like in analytics; the full date is on hover.
// Relative timestamps like in analytics.
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.
// Name (and optionally 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('@')),
)
// Sending stays disabled until the name field is filled.
const introInvalid = computed(() => showIntro.value && !intro.name.trim())
function onSend(md) {
if (showIntro.value) {
@@ -78,14 +74,14 @@ function onSend(md) {
output only, never user markup. -->
<header>
<span class="who">{{ m.admin || state.thread?.name || 'you' }}</span>
<time :title="fmtFull(m.t)">{{ fmtTime(m.t) }}</time>
<time>{{ fmtTime(m.t) }}</time>
</header>
<div class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
<div v-if="showIntro" class="contact-intro">
<input v-model="intro.name" type="text" placeholder="Your name" maxlength="100">
<input v-model="intro.email" type="email" placeholder="Your email" maxlength="200">
<input v-model="intro.email" type="email" placeholder="Your email (optional)" maxlength="200">
</div>
<Composer :token="token" :send-disabled="introInvalid" autofocus @send="onSend" />
</section>
+3 -5
View File
@@ -57,10 +57,8 @@ function preview(t) {
return (who + (div.textContent || '')).slice(0, 80)
}
// Relative timestamps like elsewhere in analytics; full date on hover.
// Relative timestamps like elsewhere in analytics.
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 })
@@ -84,7 +82,7 @@ function send(md) {
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
{{ preview(t) }}
</span>
<time :title="fmtFull(lastMsg(t)?.t || t.created)">{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
</button>
</aside>
<section v-if="thread" class="thread">
@@ -102,7 +100,7 @@ function send(md) {
<!-- Server-rendered strict markdown only (see ContactPanel). -->
<header>
<span class="who">{{ m.admin || thread.name || 'visitor' }}</span>
<time :title="fmtFull(m.t)">{{ fmtTime(m.t) }}</time>
<time>{{ fmtTime(m.t) }}</time>
</header>
<div class="contact-msg-body" v-html="m.html"></div>
</div>
+3 -2
View File
@@ -83,8 +83,9 @@ 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).
#: The visitor's self-entered identity (name required, email optional
#: and unvalidated), captured with their first message and fixed
#: afterwards ("" until then).
name: str = ""
email: str = ""
+4 -6
View File
@@ -237,17 +237,15 @@ 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.
# The first message must introduce the visitor (name; email optional,
# unvalidated). 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"}
)
if not name:
await ws.send_json({"type": "contact-error", "detail": "name required"})
return
now = datetime.now(UTC)
_append(