Visitor contact messaging: in site communication system with email readiness #3

Merged
LeoVasanko merged 58 commits from contact-messages into main 2026-09-29 03:01:00 +00:00
8 changed files with 119 additions and 114 deletions
Showing only changes of commit 8d577dc56e - Show all commits

No files matched your search

+23 -19
View File
@@ -12,8 +12,8 @@ existing WebSockets; there is no REST polling anywhere.
thread store ops, wire payloads, both socket protocols, the admin unread
count and the `/_contact/files` upload route.
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
and the site settings (`contact_enabled`, `contact_label`,
`contact_email`, `contact_subject`).
and the site settings (`contact_label` — which doubles as the on/off
switch — `contact_email`, `contact_subject`).
- `pagerite/tracking.py` — the sockets the protocol rides (see below).
- `pagerite/api.py` — `/_api/settings` carries `contact_unread` (the admin
icon badge seed — the probe runs on every page load, no polling) and the
@@ -36,40 +36,44 @@ A lone `{contact: Label}` line in article Markdown (the block-directive
machinery of `markdown.py`) renders
`<button class="contact-trigger">Label</button>`. An empty label falls
back to the configured site label (`Data.contact_label`, localized —
below), ultimately the built-in "Contact us". `pagerite.js`
below; "Contact us" appears only as the inert preview's placeholder when
nothing is configured). `pagerite.js`
click-delegates these buttons and lazy-loads
the contact bundle; the editor preview renders the same button inert, and
description/card mining resolves the tag to nothing. A per-button label
lives in the chunk, so translated pages localize it like any other text.
Feeds render the tag as its literal source (no directive context). When
messaging is disabled the tag renders nothing at all.
messaging is disabled (no site label configured) the tag renders nothing
at all.
## Site settings and localization
The site editor's messaging block holds the feature's configuration
(`/_api/settings`): `contact_enabled` (off = no trigger buttons, no
visitor corner icon, sends rejected; admins keep the inbox for existing
threads), `contact_email` (a mailto link in the visitor dialog, its
subject prefilled) and the `contact_label`/`contact_subject` strings
(the label doubles as the dialog's title and the buttons' default; the
subject defaults to the label).
The site editor's messaging row holds the feature's configuration
(`/_api/settings`): `contact_label` — the {contact:} buttons' label and
the visitor dialog's title, doubling as the on/off switch (empty = no
trigger buttons, no visitor corner icon, sends rejected; admins keep the
inbox for existing threads) — `contact_email` (a mailto link in the
visitor dialog, its subject prefilled) and `contact_subject` (defaults
to the label). Saves re-render the page in place, so button and dialog
strings update without a reload.
The two strings localize like titles: translations live in the
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
render time — the *effective* label, so the built-in default localizes
too) and the translator service picks them up automatically
render time) and the translator service picks them up automatically
(`pending_items` emits them as title-kind fragments; nav-mode jobs carry
them as extra top-level list items). Manual overrides are edited in the
same messaging block: its language selector switches the label/subject
fields to a translation, saved via `PUT /_api/settings?lang=` into the
same messaging row: the flag selector (the article editor's LangSelect)
switches the label/subject fields to a translation, saved via
`PUT /_api/settings?lang=` into the
same store (`i18n.set_string_translation`; empty or the original's text
drops the override). The localized strings reach the client through the
page's asset config (`pagerite:contact-label` / `-subject`, absent
entirely when disabled — pagerite.js reads that as "visitors get no
icon"); `contact-main.js` re-reads them on every open, so a language
switch is reflected immediately. The email address itself is never in
the config: it is revealed over the messaging socket only after the
proof of work is solved.
icon"); `contact-main.js` re-reads them on every open and on page swaps,
so a language switch or a settings save is reflected immediately. The
email address itself is never in the config: it is revealed over the
messaging socket only after the proof of work is solved (and only when
configured).
## Visitor side
+52 -60
View File
@@ -62,15 +62,14 @@ const brand = ref('')
const theme = ref('')
// --- Messaging (contact) ---------------------------------------------------
// Site-wide contact settings: the enabled toggle (combined with the
// section header) and mailto address are global; the label (the
// {contact:} buttons' default and the visitor dialog's title) and the
// email subject are per-language strings — the LangSelect (the article
// editor's flag dropdown, on the shared shell-wide editorLang) switches
// the two text fields between the original and a translation override
// (stored in the chunk-keyed trans store; auto translators fill the same
// store, so this is the manual-override UI).
const contactEnabled = ref(true)
// One-line contact settings. The label (the {contact:} buttons' label and
// the visitor dialog's title) doubles as the on/off switch: empty =
// disabled. The label and email subject are per-language strings — the
// LangSelect (the article editor's flag dropdown, on the shared shell-wide
// editorLang) switches the two fields between the original and a
// translation override (stored in the chunk-keyed trans store; auto
// translators fill the same store, so this is the manual-override UI).
// The email is global; its reveal after the PoW solve is server-side.
const contactEmail = ref('')
const contactLabel = ref('')
const contactSubject = ref('')
@@ -135,10 +134,18 @@ async function saveContact(tag = lang.value, label = '', subject = '') {
}),
})
saveError.value = res.ok ? '' : '⚠️ changes could not be saved'
if (res.ok) dropPageCache()
if (res.ok) rerender()
return
}
saveSettings()
if (await saveSettings()) rerender()
}
// The rendered {contact:} buttons are server HTML: after a save the page
// re-renders (like a theme change), updating buttons and dialog strings
// without a reload.
async function rerender() {
dropPageCache()
loadPlain(path.value)
}
// Theme options come from the backend (theme folders on disk, see GET
// /_api/settings), so added themes need no frontend changes.
@@ -171,7 +178,6 @@ async function loadSettings() {
]
transition.value = s.transition || 'cube'
transitionOptions.value = s.transitions || []
contactEnabled.value = s.contact_enabled !== false
contactEmail.value = s.contact_email || ''
contactLabelOrig.value = s.contact_label || ''
contactSubjectOrig.value = s.contact_subject || ''
@@ -348,7 +354,6 @@ async function saveSettings(opts = {}) {
custom_css: customCss.value,
brand_html: brandHtml.value,
transition: transition.value,
contact_enabled: contactEnabled.value,
contact_email: contactEmail.value,
// The originals: while a translation override is being edited the
// input refs hold the override, not the original.
@@ -363,6 +368,7 @@ async function saveSettings(opts = {}) {
} else {
saveError.value = '⚠️ changes could not be saved'
}
return res.ok
}
async function onThemeChange() {
@@ -758,51 +764,34 @@ onUnmounted(() => {
</section>
<section class="block">
<div class="block-head">
<div class="block-head msg-head">
<LangSelect
v-model="lang"
:options="contactLangOptions"
title="Language of the label/subject strings (auto translators fill these too; this edits the manual override)"
/>
<label
class="msg-toggle"
title="Visitor messaging ({contact:} buttons, corner icon)"
>
<input v-model="contactEnabled" type="checkbox" @change="saveSettings()" />
<span class="field-label">messaging</span>
</label>
</div>
<div class="field-grid">
<label class="field">
<span class="field-label">email</span>
<input
v-model="contactEmail"
class="text-input"
title="Contact email — a mailto link in the visitor dialog (empty = no link)"
:disabled="!!translating"
@input="debounce('contact-email', () => saveSettings())"
/>
</label>
<label class="field">
<span class="field-label">label</span>
<input
v-model="contactLabel"
class="text-input"
title="Default {contact:} button label and visitor dialog title (empty = built-in default)"
:placeholder="translating ? contactLabelOrig : 'Contact us'"
@input="onContactInput"
/>
</label>
<label class="field">
<span class="field-label">subject</span>
<input
v-model="contactSubject"
class="text-input"
title="Subject for the mailto link (empty = the label)"
:placeholder="translating ? contactSubjectOrig : ''"
@input="onContactInput"
/>
</label>
<input
v-model="contactLabel"
class="text-input"
:placeholder="translating ? contactLabelOrig : 'Contact us'"
title="Messaging: the {contact:} button label and visitor dialog title — empty disables messaging"
@input="onContactInput"
/>
<input
v-model="contactEmail"
class="text-input"
placeholder="email"
title="Contact email — a mailto link in the visitor dialog, revealed only after the proof of work is solved (empty = no link)"
:disabled="!!translating"
@input="debounce('contact-email', () => saveSettings())"
/>
<input
v-model="contactSubject"
class="text-input"
:placeholder="translating ? contactSubjectOrig : 'subject'"
title="Subject for the mailto link (empty = the label)"
@input="onContactInput"
/>
</div>
</section>
@@ -936,13 +925,16 @@ onUnmounted(() => {
font-size: 1rem;
}
/* The messaging block's single-line header: the article editor's flag
selector, then the enabled toggle fused with the section label. */
.msg-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
cursor: pointer;
/* The messaging block: one line — the article editor's flag selector,
then small text boxes (label = the enable switch, email, subject). */
.msg-head {
flex-wrap: wrap;
}
.msg-head .text-input {
flex: 1 1 7rem;
min-width: 5rem;
font-size: 0.85rem;
}
.text-input {
+5
View File
@@ -54,6 +54,11 @@ function readContactStrings() {
// PoW is solved and stays valid for the session.
}
// Page swaps (fetch navigation, editor re-renders after settings saves)
// carry fresh per-language config: keep the dialog strings in sync
// without reopening.
addEventListener('pagerite:page-fetched', readContactStrings)
// The panel is main content, not a sidebar: while open it holds paskia's
// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
// at 1100, below the auth iframe's 9999).
+3 -8
View File
@@ -302,7 +302,7 @@ async def get_settings(lang: str | None = None) -> dict:
# Unanswered contact threads: the corner ✉️ icon's badge for
# admins (this probe runs on every page load; no polling).
"contact_unread": messages.unread_count(),
"contact_enabled": data.contact_enabled,
# Messaging is enabled by configuring a label (empty = off).
"contact_label": data.contact_label,
"contact_email": data.contact_email,
"contact_subject": data.contact_subject,
@@ -314,7 +314,7 @@ async def get_settings(lang: str | None = None) -> dict:
tag = i18n.base_tag(lang or "")
if tag and tag != out["primary_lang"]:
for field in ("contact_label", "contact_subject"):
original = getattr(data, field) or ("Contact us" if field == "contact_label" else "")
original = getattr(data, field)
out[f"{field}_tr"] = (
data.trans.get(chunk_key(original), {}).get(tag, "") if original else ""
)
@@ -331,7 +331,6 @@ class SettingsIn(BaseModel):
transition: str = "cube"
translate_langs: list[str] | None = None # None keeps the current set
translate_keys: dict[str, str] | None = None # None keeps the current keys
contact_enabled: bool | None = None # None keeps the current setting
contact_label: str | None = None
contact_email: str | None = None
contact_subject: str | None = None
@@ -357,9 +356,7 @@ async def put_settings(
value = getattr(settings, field)
if value is None:
continue
original = getattr(data, field) or (
"Contact us" if field == "contact_label" else ""
)
original = getattr(data, field)
if original:
changed |= i18n.set_string_translation(
data, tag, original, value.strip()
@@ -373,8 +370,6 @@ async def put_settings(
data.theme = settings.theme
data.custom_css = settings.custom_css
data.transition = settings.transition
if settings.contact_enabled is not None:
data.contact_enabled = settings.contact_enabled
if settings.contact_label is not None:
data.contact_label = settings.contact_label.strip()
if settings.contact_email is not None:
+8 -9
View File
@@ -210,17 +210,16 @@ class Data(msgspec.Struct):
#: Visitor contact threads (docs/messages.md), keyed by the blake3
#: hash of the visitor's session token (never the token itself).
contact: dict[bytes, ContactThread] = {}
#: Messaging on/off: when False, {contact:} buttons do not render,
#: visitors get no corner ✉️ icon and sends are rejected (admins keep
#: the inbox for the existing threads).
contact_enabled: bool = True
#: Site-wide default label for the {contact:} button and the visitor
#: dialog title (a per-button {contact: Label} arg wins). Empty = the
#: built-in "Contact us". Translated per language in Data.trans, keyed
#: by chunk hash like titles (i18n.site_string).
#: The {contact:} button label and visitor dialog title — and the
#: feature's on/off switch: EMPTY disables messaging (no buttons, no
#: visitor corner icon, sends rejected; admins keep the inbox). A
#: per-button {contact: Label} arg overrides it. Translated per
#: language in Data.trans, keyed by chunk hash like titles
#: (i18n.site_string).
contact_label: str = ""
#: Contact email shown as a mailto link in the visitor dialog (with
#: the subject prefilled). Empty = no link.
#: the subject prefilled), revealed over the socket only after the
#: proof of work is solved. Empty = no link.
contact_email: str = ""
#: Subject for the mailto link; empty = the (localized) label.
#: Translated like contact_label.
+10 -3
View File
@@ -234,6 +234,12 @@ def _append(
# --- Visitor protocol (/_ws frames carrying "token") ------------------------
def enabled() -> bool:
"""Messaging on/off: the site contact label doubles as the switch —
empty disables the feature (docs/messages.md)."""
return bool(data.contact_label.strip())
def quota_left(token: str) -> int:
"""Remaining PoW-granted actions for a session token (0 for malformed
tokens — they fail validation later anyway)."""
@@ -289,9 +295,10 @@ 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
if not data.contact_enabled:
# Messaging off in the site settings: sends are rejected (the UI
# is already hidden); existing threads and admin replies stay.
if not enabled():
# Messaging off (no contact label configured): sends are rejected
# (the UI is already hidden); existing threads and admin replies
# stay.
await ws.send_json({"type": "contact-error", "detail": "messaging is disabled"})
return
# The first message must introduce the visitor (name; email optional,
+7 -7
View File
@@ -311,11 +311,11 @@ def pending_items(data: Data, lang: str) -> list[TransItem]:
walk(data.menu, "", i18n.ORIGINAL_LANGUAGE)
# Site-level messaging strings (Data.contact_label/contact_subject):
# translated like titles and looked up at render time by chunk key
# (i18n.site_string) — the EFFECTIVE label, so the built-in default
# localizes too. Skipped in the site default language, like titles in
# their article's primary language.
if data.contact_enabled and lang != i18n.primary_lang(data.menu, ""):
for text in (data.contact_label or "Contact us", data.contact_subject):
# (i18n.site_string). Skipped when messaging is off (no label — the
# label doubles as the enable switch) and in the site default
# language, like titles in their article's primary language.
if data.contact_label.strip() and lang != i18n.primary_lang(data.menu, ""):
for text in (data.contact_label, data.contact_subject):
if text and "\n" not in text:
emit(chunk_key(text), text, "", "title")
return items
@@ -548,8 +548,8 @@ class Dispatcher:
# The site-level messaging strings ride the same list (pending_items
# covers them for scoped jobs): one round trip names the menu and
# the contact chrome alike.
if self.data.contact_enabled and lang != i18n.primary_lang(self.data.menu, ""):
for text in (self.data.contact_label or "Contact us", self.data.contact_subject):
if self.data.contact_label.strip() and lang != i18n.primary_lang(self.data.menu, ""):
for text in (self.data.contact_label, self.data.contact_subject):
if text and "\n" not in text:
key = chunk_key(text)
if (
+11 -8
View File
@@ -920,7 +920,7 @@ def page_content(
directives = {
"contact": (
(lambda args, _env: _contact_tag(args, _env, data, lang))
if data.contact_enabled
if data.contact_label.strip()
else (lambda _args, _env: "")
)
}
@@ -1013,7 +1013,7 @@ def _contact_config(data: Data, lang: str) -> dict[str, str]:
chunk-keyed trans store (i18n.site_string). The email address itself
is NOT here — it is revealed over the messaging socket only after the
proof of work is solved, so scrapers cannot lift it from pages."""
label = i18n.site_string(data, lang, data.contact_label or "Contact us")
label = i18n.site_string(data, lang, data.contact_label)
return {
"label": label,
"subject": i18n.site_string(data, lang, data.contact_subject) or label,
@@ -1024,13 +1024,16 @@ def _contact_tag(args: str, _env: dict, data: Data | None = None, lang: str = ""
"""Expand a ``{contact: Label}`` directive: the button that opens the
visitor contact panel (docs/messages.md). The args are a per-button
label overriding the configured site label (Data.contact_label,
localized here). pagerite.js binds the click and lazy-loads the panel
bundle; in the editor preview it stays inert."""
localized here; "Contact us" is just the inert preview's placeholder
when nothing is configured). pagerite.js binds the click and
lazy-loads the panel bundle; in the editor preview it stays inert."""
label = args.strip()
if not label and data is not None:
label = i18n.site_string(data, lang, data.contact_label)
return str(
E.button(
E.span("✉️", class_="icon"),
args.strip()
or (_contact_config(data, lang)["label"] if data else "Contact us"),
label or "Contact us",
type="button",
class_="contact-trigger",
)
@@ -1456,7 +1459,7 @@ def render_page(
alternates,
# The page's Markdown counterpart (pages.show_page's .md mode).
f"/{path}.md",
contact=_contact_config(data, lang) if data.contact_enabled else None,
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),
@@ -1520,7 +1523,7 @@ def render_category(
favicon,
lang=lang,
alternates=alternates,
contact=_contact_config(data, lang) if data.contact_enabled else None,
contact=_contact_config(data, lang) if data.contact_label.strip() else None,
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),