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:
1 parent
67c19230b4
commit
f4d1f0ac58
5 files changed
+22
-27
No files matched your search
+5
-3
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user