diff --git a/AGENTS.md b/AGENTS.md index 7826759..3946b37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/messages.md b/docs/messages.md index d6b60fa..48302fe 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -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 `, 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 diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue index 66e7933..00c0227 100644 --- a/frontend/src/MessagesView.vue +++ b/frontend/src/MessagesView.vue @@ -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 }) } diff --git a/frontend/src/SiteEditor.vue b/frontend/src/SiteEditor.vue index 551137e..bf5e2e3 100644 --- a/frontend/src/SiteEditor.vue +++ b/frontend/src/SiteEditor.vue @@ -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(() => { + @@ -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; } diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index db0bfd4..31e3d2d 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -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() } diff --git a/frontend/src/notify.js b/frontend/src/notify.js new file mode 100644 index 0000000..3933d86 --- /dev/null +++ b/frontend/src/notify.js @@ -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() +} diff --git a/pagerite/api.py b/pagerite/api.py index a117cd0..bddef77 100644 --- a/pagerite/api.py +++ b/pagerite/api.py @@ -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); diff --git a/pagerite/data.py b/pagerite/data.py index 71a2c4a..37c3fd8 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -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: