contact messages: mail server setting, browser notifications for new messages
The mail server field is plumbing only (mailbridge URL in settings + editor UI); actual email sending is deferred. Browser notifications: permission is asked on the user's first send, then the visitor is notified of admin replies while not viewing the thread and the admin of new visitor messages while the inbox is open.
This commit is contained in:
1 parent
38b08679c6
commit
f8da552cfe
8 files changed
+121
-22
No files matched your search
@@ -32,7 +32,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
|
||||
- `main.js` — Vue editor app entry.
|
||||
- `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`).
|
||||
- `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown).
|
||||
- `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected only while open).
|
||||
- `contact-main.js` + `ContactOverlay.vue` — messaging overlay entry and shell (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button or the corner ✉️ icon is clicked: the visitor thread (ContactPanel.vue, riding the `/_ws` activity socket through pagerite.js's bridge — register/send, shared queue and reconnect pacing) or, for editors, the admin inbox (MessagesView.vue: thread list, replies and read marking over `/_api/ws/analytics`, connected only while open). Both sides notify new messages via the browser Notification API (`notify.js`), permission asked on first send.
|
||||
- `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly.
|
||||
- `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state.
|
||||
- `pagerite.js` — public page entry.
|
||||
|
||||
+12
-3
@@ -13,7 +13,7 @@ existing WebSockets; there is no REST polling anywhere.
|
||||
count and the `/_contact/files` upload route.
|
||||
- `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`)
|
||||
and the site settings (`contact_label` — which doubles as the on/off
|
||||
switch — `contact_email`, `contact_subject`).
|
||||
switch — `contact_email`, `contact_subject`, `contact_mailserver`).
|
||||
- `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
|
||||
@@ -55,8 +55,17 @@ trigger buttons, no visitor corner icon, sends rejected; admins keep the
|
||||
inbox for existing threads) — `contact_email` (a mailto link in the
|
||||
visitor dialog: the recipient is addressed `Brand <email>`, the link
|
||||
text the bare address, the 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.
|
||||
(defaults to the label). `contact_mailserver` is a mailbridge URL
|
||||
(`smtp://user:pass@host:port`, a bare `host[:port]`, or a backend URL
|
||||
like `sendgrid:API-KEY`) reserved for email notifications — the actual
|
||||
sending is not implemented yet. Saves re-render the page in place, so
|
||||
button and dialog strings update without a reload.
|
||||
|
||||
Both sides also get browser notifications (the Notification API,
|
||||
`frontend/src/notify.js`): permission is asked when the user sends their
|
||||
first message, then the visitor is notified of new admin replies while
|
||||
not viewing the thread and the admin of new visitor messages while the
|
||||
inbox is open; clicking a notification focuses the tab.
|
||||
|
||||
The two strings localize like titles: translations live in the
|
||||
chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at
|
||||
|
||||
@@ -13,6 +13,7 @@ import { formatWhen } from './analytics/format.js'
|
||||
import Composer from './Composer.vue'
|
||||
import ConnNote from './ConnNote.vue'
|
||||
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
|
||||
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||
|
||||
// The shared store (contact-main): adminUnread mirrors the inbox's unread
|
||||
// count onto the corner icon's badge.
|
||||
@@ -31,6 +32,8 @@ const reconnects = reconnectPolicy()
|
||||
let mounted = false
|
||||
let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail
|
||||
let hiddenAt = 0
|
||||
// 'threads' full-state pushes are the baseline, history never notifies.
|
||||
const seenLastMsg = new Map()
|
||||
|
||||
// The inbox is live data over its socket: while it is connecting, waiting
|
||||
// to reconnect or logged out, say so (ConnNote) instead of showing a
|
||||
@@ -56,6 +59,15 @@ function upsertThread(thread) {
|
||||
threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a)))
|
||||
}
|
||||
|
||||
function notifyThread(t) {
|
||||
const last = t.messages[t.messages.length - 1]
|
||||
const prev = seenLastMsg.get(t.id)
|
||||
seenLastMsg.set(t.id, last?.id || '')
|
||||
// prev undefined = a brand-new thread, which should notify.
|
||||
if (!last || last.admin || prev === last.id) return
|
||||
notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html))
|
||||
}
|
||||
|
||||
function wsSend(obj) {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
|
||||
}
|
||||
@@ -81,7 +93,9 @@ function connect() {
|
||||
// The type-less snapshot is analytics data, not for this view.
|
||||
if (msg?.type === 'threads') {
|
||||
threads.value = msg.threads
|
||||
for (const t of msg.threads) seenLastMsg.set(t.id, lastMsg(t)?.id || '')
|
||||
} else if (msg?.type === 'thread') {
|
||||
notifyThread(msg)
|
||||
upsertThread(msg)
|
||||
} else if (msg?.type === 'contact-error') {
|
||||
console.warn('contact:', msg.detail)
|
||||
@@ -256,6 +270,8 @@ function preview(t) {
|
||||
const fmtTime = (t) => formatWhen(t)
|
||||
|
||||
function send(md) {
|
||||
// First reply doubles as the notification-permission gesture.
|
||||
askNotifyPermission()
|
||||
if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md })
|
||||
}
|
||||
</script>
|
||||
|
||||
+40
-18
@@ -62,17 +62,22 @@ const brand = ref('')
|
||||
const theme = ref('')
|
||||
|
||||
// --- Messaging (contact) ---------------------------------------------------
|
||||
// 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.
|
||||
// Contact messaging settings: a two-row grid (labels above, the flag
|
||||
// selector and the boxes below). 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 and mail server are global: the
|
||||
// email's reveal after the PoW solve is server-side, and the mail
|
||||
// server (a mailbridge URL, docs/messages.md; empty = no email) is
|
||||
// reserved for email notifications.
|
||||
const contactEmail = ref('')
|
||||
const contactLabel = ref('')
|
||||
const contactSubject = ref('')
|
||||
const contactMailserver = ref('')
|
||||
const contactLabelOrig = ref('')
|
||||
const contactSubjectOrig = ref('')
|
||||
// Password managers offer credential autofill on these boxes no matter
|
||||
@@ -184,6 +189,7 @@ async function loadSettings() {
|
||||
transition.value = s.transition || 'cube'
|
||||
transitionOptions.value = s.transitions || []
|
||||
contactEmail.value = s.contact_email || ''
|
||||
contactMailserver.value = s.contact_mailserver || ''
|
||||
contactLabelOrig.value = s.contact_label || ''
|
||||
contactSubjectOrig.value = s.contact_subject || ''
|
||||
if (!translating.value) {
|
||||
@@ -360,6 +366,7 @@ async function saveSettings(opts = {}) {
|
||||
brand_html: brandHtml.value,
|
||||
transition: transition.value,
|
||||
contact_email: contactEmail.value,
|
||||
contact_mailserver: contactMailserver.value,
|
||||
// The originals: while a translation override is being edited the
|
||||
// input refs hold the override, not the original.
|
||||
contact_label: contactLabelOrig.value,
|
||||
@@ -775,7 +782,7 @@ onUnmounted(() => {
|
||||
<LangSelect
|
||||
v-model="lang"
|
||||
:options="contactLangOptions"
|
||||
title="Language of the label/subject strings (auto translators fill these too; this edits the manual override)"
|
||||
title="Language of the button label and email subject strings (edits the translation override)"
|
||||
/>
|
||||
</div>
|
||||
<label class="field">
|
||||
@@ -783,8 +790,8 @@ onUnmounted(() => {
|
||||
<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"
|
||||
:placeholder="translating ? contactLabelOrig : ''"
|
||||
title="The {contact:} button label and visitor dialog title — empty disables messaging"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
@@ -800,7 +807,7 @@ onUnmounted(() => {
|
||||
v-model="contactSubject"
|
||||
class="text-input"
|
||||
:placeholder="translating ? contactSubjectOrig : ''"
|
||||
title="Subject line for the mailto link (empty = the button label)"
|
||||
title="Subject line for the mailto link — empty = the button label"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
@@ -815,8 +822,7 @@ onUnmounted(() => {
|
||||
<input
|
||||
v-model="contactEmail"
|
||||
class="text-input"
|
||||
placeholder="name@example.com"
|
||||
title="Contact email — a mailto link in the visitor dialog, revealed only after the proof of work is solved (empty = no link)"
|
||||
title="A mailto link in the visitor dialog, revealed after the visitor solves the proof of work — empty = no link"
|
||||
:disabled="!!translating"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
@@ -827,6 +833,22 @@ onUnmounted(() => {
|
||||
@input="debounce('contact-email', () => saveSettings())"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">mail server</span>
|
||||
<input
|
||||
v-model="contactMailserver"
|
||||
class="text-input"
|
||||
title="Server for email notifications — smtp://user:pass@host:port, a bare host[:port], or a backend URL like sendgrid:API-KEY — empty = no email"
|
||||
:disabled="!!translating"
|
||||
:readonly="pmGuard"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
@pointerdown="pmUnlock"
|
||||
@focus="pmUnlock"
|
||||
@input="debounce('contact-mailserver', () => saveSettings())"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -964,15 +986,15 @@ onUnmounted(() => {
|
||||
article editor's flag selector and the boxes on the second, so the
|
||||
flag centers with the boxes and every label sits directly above its
|
||||
own field (contact button label = the enable switch, email subject,
|
||||
the mailto "our email" last, where the planned SMTP address joins
|
||||
it). The flag column sizes to the flag; when only one language is
|
||||
configured the selector renders nothing and the column collapses.
|
||||
then the email group: our email + mail server). The flag column sizes
|
||||
to the flag; when only one language is configured the selector renders
|
||||
nothing and the column collapses.
|
||||
The boxes are readonly until first interaction (+ vendor ignore
|
||||
attributes): password managers offer credential autofill on them no
|
||||
matter the autocomplete setting. */
|
||||
.msg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: auto repeat(4, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
gap: 0.15rem 0.6rem;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { createApp, reactive, watch } from 'vue'
|
||||
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
||||
import ContactOverlay from './ContactOverlay.vue'
|
||||
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
|
||||
|
||||
const TOKEN_KEY = 'pagerite-contact-token'
|
||||
const SEEN_KEY = 'pagerite-contact-seen'
|
||||
@@ -116,6 +117,8 @@ async function submitProof() {
|
||||
}
|
||||
|
||||
function sendMessage(md, name = '', email = '') {
|
||||
// First send doubles as the notification-permission gesture.
|
||||
askNotifyPermission()
|
||||
unsent = { md, name, email, retried: false }
|
||||
bridge.send({ token, msg: md, ...(name && { name, email }) })
|
||||
}
|
||||
@@ -173,6 +176,8 @@ export function refreshBadges() {
|
||||
icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread)
|
||||
}
|
||||
|
||||
let notifyBaseline = '' // the first push after load is history, only baselines
|
||||
|
||||
function onThread(thread) {
|
||||
state.thread = thread
|
||||
unsent = null // the send landed (a thread push acknowledges it)
|
||||
@@ -180,6 +185,12 @@ function onThread(thread) {
|
||||
if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t)
|
||||
const viewing = state.open && state.mode === 'visitor'
|
||||
state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing
|
||||
// ISO timestamps compare lexicographically.
|
||||
if (lastAdmin && lastAdmin.t > notifyBaseline) {
|
||||
const fresh = Boolean(notifyBaseline)
|
||||
notifyBaseline = lastAdmin.t
|
||||
if (fresh && !viewing) notifyMessage(state.label, messageText(lastAdmin.html))
|
||||
}
|
||||
if (viewing) markSeen()
|
||||
else refreshBadges()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Browser notifications for contact messaging (docs/messages.md): the
|
||||
// visitor is notified of admin replies, the admin of new visitor
|
||||
// messages. Permission is requested from the user's first send (the
|
||||
// Notification API requires a user gesture), at most once ever — a
|
||||
// dismissed prompt leaves the permission "default", and the flag stops
|
||||
// us from nagging on every later send.
|
||||
const ASKED_KEY = 'pagerite-notify-asked'
|
||||
|
||||
export function askNotifyPermission() {
|
||||
if (!('Notification' in window) || Notification.permission !== 'default') return
|
||||
if (localStorage.getItem(ASKED_KEY)) return
|
||||
localStorage.setItem(ASKED_KEY, '1')
|
||||
Notification.requestPermission()
|
||||
}
|
||||
|
||||
// Fire a notification (a no-op without a granted permission). Clicking
|
||||
// it focuses the site tab.
|
||||
export function notifyMessage(title, body) {
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return
|
||||
const n = new Notification(title, { body, icon: '/favicon.ico' })
|
||||
n.onclick = () => {
|
||||
window.focus()
|
||||
n.close()
|
||||
}
|
||||
}
|
||||
|
||||
// The visible text of a server-rendered strict-markdown message (safe to
|
||||
// parse: the stored html is markdown-it output, never user markup).
|
||||
export function messageText(html) {
|
||||
const div = document.createElement('div')
|
||||
div.innerHTML = html
|
||||
return (div.textContent || '').trim()
|
||||
}
|
||||
@@ -306,6 +306,7 @@ async def get_settings(lang: str | None = None) -> dict:
|
||||
"contact_label": data.contact_label,
|
||||
"contact_email": data.contact_email,
|
||||
"contact_subject": data.contact_subject,
|
||||
"contact_mailserver": data.contact_mailserver,
|
||||
# The site default primary language: the front page's resolved
|
||||
# setting (every page may override it, inherited down the tree).
|
||||
"primary_lang": i18n.primary_lang(data.menu, ""),
|
||||
@@ -334,6 +335,7 @@ class SettingsIn(BaseModel):
|
||||
contact_label: str | None = None
|
||||
contact_email: str | None = None
|
||||
contact_subject: str | None = None
|
||||
contact_mailserver: str | None = None
|
||||
|
||||
|
||||
@router.put("/_api/settings", status_code=204)
|
||||
@@ -376,6 +378,8 @@ async def put_settings(
|
||||
data.contact_email = settings.contact_email.strip()
|
||||
if settings.contact_subject is not None:
|
||||
data.contact_subject = settings.contact_subject.strip()
|
||||
if settings.contact_mailserver is not None:
|
||||
data.contact_mailserver = settings.contact_mailserver.strip()
|
||||
if settings.translate_langs is not None:
|
||||
# Any language may be a target — including the site default
|
||||
# (an article in another language can be translated INTO it);
|
||||
|
||||
@@ -224,6 +224,10 @@ class Data(msgspec.Struct):
|
||||
#: Subject for the mailto link; empty = the (localized) label.
|
||||
#: Translated like contact_label.
|
||||
contact_subject: str = ""
|
||||
#: Mail server for email notifications (docs/messages.md): a mailbridge
|
||||
#: URL — a bare host[:port] or smtp://user:pass@host:port for plain
|
||||
#: SMTP, or a backend URL like sendgrid:API-KEY. Empty = no email.
|
||||
contact_mailserver: str = ""
|
||||
|
||||
|
||||
def node_markdown(data: Data, node: Node) -> str | None:
|
||||
|
||||
Reference in new issue
Block a user