From 79351154057ad539ffb120d019ea5e9f97e3ceb9 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 29 Sep 2026 02:32:55 +0000 Subject: [PATCH] contact messages: close conversation, live sessions, notification click-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Visitors can close a conversation (panel ⟲ button): the thread is archived server-side for the admin inbox (excluded from the unread count, replies rejected, sorted last), the local session keys are wiped and the token rotated, so the next message starts an all-new thread. A live conversation is flagged in localStorage and resumed at page load (no panel click needed); admins get a background /_api/ws/analytics watch whenever messaging is enabled, keeping the icon badge live and notifying new visitor messages (MessagesView owns notifications while the inbox is open). Notification policy: permission is asked only right after the first message of a fresh thread; denied is never re-asked, a dismissed prompt comes up again on the next fresh thread. Clicking a notification focuses the tab and opens the thread/inbox at what was new. --- AGENTS.md | 2 +- docs/messages.md | 44 +++++-- frontend/src/ContactOverlay.vue | 18 ++- frontend/src/MessagesView.vue | 38 ++++-- frontend/src/contact-main.js | 227 ++++++++++++++++++++++++++++---- frontend/src/notify.js | 18 +-- frontend/src/pagerite.js | 46 +++++-- pagerite/data.py | 4 + pagerite/messages.py | 54 ++++++-- 9 files changed, 373 insertions(+), 78 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3946b37..c992f4d 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). Both sides notify new messages via the browser Notification API (`notify.js`), permission asked on first send. + - `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, and at page load when a message session is live: 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 while open — a background `watchAdmin` connection keeps the badge and notifications live at all times). A visitor's open conversation is flagged in localStorage and resumed on page load; closing it archives the thread server-side and rotates the token. Both sides notify new messages via the browser Notification API (`notify.js`), permission asked after the first message of a fresh thread. - `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 48302fe..3608bc2 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -62,10 +62,13 @@ 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. +`frontend/src/notify.js`): permission is asked at one entry point only — +right after the first message of a fresh thread (a denied permission is +permanent in the browser and never re-asked; a dismissed prompt comes up +again on the next fresh thread). With permission granted the visitor is +notified of new admin replies while not viewing the thread and the admin +of new visitor messages; clicking a notification focuses the tab and +opens the thread/inbox at what was new. The two strings localize like titles: translations live in the chunk-keyed `Data.trans` store (`i18n.site_string` looks them up at @@ -97,7 +100,17 @@ scale-and-shake nudge animation, `has-unread` in pagerite.css) on trigger buttons and the corner ✉️ icon when an admin reply arrives while the panel is closed — including on a fresh page load before any socket activity (pagerite.js reads the pair directly; the bundle keeps it live once -loaded). +loaded). A live conversation is also flagged +(`pagerite-contact-session`): while set, page loads register the session +right away (no panel click needed), so a granted notification permission +reports new replies immediately. + +**Closing a conversation**: the panel's ⟲ button sends `{token, close}` — +the server marks the thread `closed` (archived in the admin inbox, +excluded from the unread count, replies rejected, the token's pushes +stopped) — then wipes the local session keys (token, seen, lastadmin, +session flag) and rotates the token, so the next message starts an +all-new thread. The panel rides the public `/_ws` activity socket via a bridge in `pagerite.js` (`register`/`send`, sharing its queue and reconnect pacing — @@ -118,9 +131,11 @@ no extra connection). Frames **to** the server: 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. +- `{token, close}` — end the conversation (archived for admins; above). -Frames **from** the server: `{type: "thread", id, created, unread, name, -email, messages: [{id, t, admin, html, page}]}` — a full thread snapshot +Frames **from** the server: `{type: "thread", id, created, unread, +closed, name, email, messages: [{id, t, admin, html, page}]}` — a full +thread snapshot (replace/upsert by `id`); `{type: "pow", challenge, required}` — a fresh single-use PoW challenge (`required` means the last message was rejected for an empty quota and should be re-sent once re-proven); `{type: "pow", @@ -151,20 +166,25 @@ Editors open the inbox from the same ✉️ corner icon (the trigger buttons always open the visitor side, so both can be tested side by side): the same overlay, wider to fit the thread list, served by the existing `/_api/ws/analytics` socket (admin-gated like all of `/_api`) which -`MessagesView` connects only while the overlay is open. Analytics payloads +`MessagesView` connects while the overlay is open — and a background +watch in `contact-main.js` (`watchAdmin`) connects at every page load +while messaging is enabled, keeping the icon badge live and notifying +new visitor messages without waiting for the inbox (MessagesView owns +notifications while open). Analytics payloads keep their type-less snapshot shape; messaging frames always carry a `type` field, and that is how each client picks out its own frames (the analytics viewer ignores them). On connect the server sends -`{type: "threads", threads: [...]}` (100 most recently active); new +`{type: "threads", threads: [...]}` (100 most recently active, archived +last); new messages and replies push single-thread updates. Admin→server frames: `{type: "reply", id, text}` and `{type: "read", id}`. Replies store the SSO `remote-user` name for audit but are labeled with the currently configured **site name** in the wire payload (substituted at read time) and mark the thread read; `unread` on a thread means its last message is the visitor's and newer than the admin's last read. The icon's -unread marker (any unanswered thread) is seeded by the `/_api/settings` -probe's `contact_unread` on every page load and kept live by the inbox -while it is open. +unread marker (any unanswered thread, archived ones excluded) is seeded by the `/_api/settings` +probe's `contact_unread` on every page load and kept live by the watch +and the inbox. ## Message format and the XSS model diff --git a/frontend/src/ContactOverlay.vue b/frontend/src/ContactOverlay.vue index 2ebf18f..223c77f 100644 --- a/frontend/src/ContactOverlay.vue +++ b/frontend/src/ContactOverlay.vue @@ -11,11 +11,10 @@ import MessagesView from './MessagesView.vue' const props = defineProps({ state: { type: Object, required: true }, - token: { type: String, required: true }, // Visitor-side PoW quota re-grant for the composer's upload retries. reauthorize: { type: Function, default: null }, }) -const emit = defineEmits(['send', 'close']) +const emit = defineEmits(['send', 'close', 'close-thread']) const admin = computed(() => props.state.mode === 'admin') @@ -46,12 +45,19 @@ onUnmounted(() => removeEventListener('keydown', onKey))

{{ admin ? 'Messages' : state.label }}

{{ state.email }} +
@@ -117,6 +123,9 @@ onUnmounted(() => removeEventListener('keydown', onKey)) } .contact-panel .close { margin-left: auto; +} +.contact-panel .close, +.contact-panel .close-thread { padding: 0 0.3rem; border: none; background: none; @@ -124,7 +133,8 @@ onUnmounted(() => removeEventListener('keydown', onKey)) font-size: 1.2rem; cursor: pointer; } -.contact-panel .close:hover { +.contact-panel .close:hover, +.contact-panel .close-thread:hover { color: var(--text, CanvasText); } diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue index 00c0227..7ea90fa 100644 --- a/frontend/src/MessagesView.vue +++ b/frontend/src/MessagesView.vue @@ -47,7 +47,7 @@ const connNote = computed(() => : '', ) -const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 1 : 0), 0)) +const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread && !t.closed ? 1 : 0), 0)) watch(unreadTotal, (n) => { props.state.adminUnread = n }, { immediate: true }) function lastT(t) { @@ -56,16 +56,22 @@ function lastT(t) { function upsertThread(thread) { const rest = threads.value.filter((t) => t.id !== thread.id) - threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a))) + // Most recently active first, visitor-closed (archived) threads last. + threads.value = [thread, ...rest].sort( + (a, b) => (a.closed - b.closed) || 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)) + // prev undefined = a brand-new thread, which should notify. Not when the + // thread is already on screen. + if (!last || last.admin || prev === last.id || t.id === selected.value) return + notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () => { + selected.value = t.id + }) } function wsSend(obj) { @@ -286,12 +292,13 @@ function send(md) { :key="t.id" type="button" class="thread-row" - :class="{ active: t.id === selected }" + :class="{ active: t.id === selected, archived: t.closed }" @click="selected = t.id" > - + {{ t.name || t.id.slice(0, 8) }} + archived {{ preview(t) }} @@ -317,7 +324,11 @@ function send(md) {
- +

select a thread

@@ -379,6 +390,17 @@ function send(md) { color: var(--text, CanvasText); font-weight: 600; } +.thread-row.archived { + opacity: 0.6; +} +.thread-row .archived-tag { + font-style: normal; + font-size: 0.7rem; + border: 1px solid var(--line); + border-radius: 0.6em; + padding: 0 0.4em; + color: var(--muted); +} .thread-row time { flex-shrink: 0; color: var(--muted); diff --git a/frontend/src/contact-main.js b/frontend/src/contact-main.js index 31e3d2d..7b3100a 100644 --- a/frontend/src/contact-main.js +++ b/frontend/src/contact-main.js @@ -1,22 +1,33 @@ // Messaging overlay entry: imported on demand by pagerite.js when a -// {contact:} trigger button or the corner ✉️ icon is clicked. The icon -// opens the admin inbox for admins, the visitor thread for visitors; the -// trigger buttons always open the visitor thread (so admins can test both -// sides). The visitor thread rides the activity socket through the bridge -// pagerite.js hands over ({send, register}) — no extra connection; the -// admin inbox connects its own admin-gated socket only while open -// (MessagesView). The session token is a localStorage random; the server -// stores only its hash. +// {contact:} trigger button or the corner ✉️ icon is clicked — and right +// at page load when there is a live message session (a visitor's open +// conversation, flagged in localStorage, resumes checking for replies +// without waiting for the panel; for admins the always-on inbox watch +// keeps the badge and notifications live while messaging is enabled). +// The icon opens the admin inbox for admins, the visitor thread for +// visitors; the trigger buttons always open the visitor thread (so +// admins can test both sides). The visitor thread rides the activity +// socket through the bridge pagerite.js hands over ({send, register}) — +// no extra connection; the admin inbox connects its own admin-gated +// socket (MessagesView while open, the background watch otherwise). The +// session token is a localStorage random; the server stores only its +// hash. Closing a conversation archives the thread server-side, wipes +// the local session keys and rotates the token, so the next message +// starts an all-new thread. import { createApp, reactive, watch } from 'vue' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import ContactOverlay from './ContactOverlay.vue' import { askNotifyPermission, messageText, notifyMessage } from './notify.js' +import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect.js' const TOKEN_KEY = 'pagerite-contact-token' const SEEN_KEY = 'pagerite-contact-seen' // The newest admin reply seen, so the corner icon's unread dot survives // page loads without any socket activity (pagerite.js reads it too). const LASTADMIN_KEY = 'pagerite-contact-lastadmin' +// An open conversation: set while the visitor has a live thread, so page +// loads resume checking for replies immediately (cleared on close). +const SESSION_KEY = 'pagerite-contact-session' let app = null const state = reactive({ @@ -26,6 +37,10 @@ const state = reactive({ unread: false, // visitor: an unread admin reply adminUnread: 0, // admin: unanswered threads viewerAdmin: false, // set once the inbox has been opened: icon badge = adminUnread + // The session token (a localStorage random; the server stores only its + // hash). Reactive in state because closing a conversation rotates it + // and the composer's uploads send it as a header. + token: '', // Localized site contact strings (pagerite:contact-* asset config, // refreshed on every open — they change with the page language): // label doubles as the visitor dialog's title; email gets a mailto @@ -99,7 +114,6 @@ function markSeen() { // easypow package, so both sides always match. const SOLVER_URL = '/_contact/easypow.js' // backend-served; kept in a variable so Rollup leaves the import dynamic let bridge = null -let token = '' let solverPromise = null let powSolution = null // Promise solving the current challenge let powAborter = null @@ -112,15 +126,17 @@ async function submitProof() { if (!p) return try { const pow = await p - if (pow && p === powSolution) bridge.send({ token, pow }) + if (pow && p === powSolution) bridge.send({ token: state.token, pow }) } catch { /* aborted: a newer solve is in flight */ } } function sendMessage(md, name = '', email = '') { - // First send doubles as the notification-permission gesture. - askNotifyPermission() + // The first message of a fresh thread doubles as the + // notification-permission gesture (denied is never re-asked; a + // dismissed prompt comes up again on the next fresh thread). + if (!localStorage.getItem(SESSION_KEY)) askNotifyPermission() unsent = { md, name, email, retried: false } - bridge.send({ token, msg: md, ...(name && { name, email }) }) + bridge.send({ token: state.token, msg: md, ...(name && { name, email }) }) } function onPow(msg) { @@ -133,7 +149,7 @@ function onPow(msg) { if (unsent && !unsent.retried) { const { md, name, email } = unsent unsent.retried = true - bridge.send({ token, msg: md, ...(name && { name, email }) }) + bridge.send({ token: state.token, msg: md, ...(name && { name, email }) }) } return } @@ -158,7 +174,7 @@ function onEmail(msg) { function requestQuota() { return new Promise((resolve) => { quotaWaiters.push(resolve) - bridge.send({ token }) // re-registration: a fresh challenge follows + bridge.send({ token: state.token }) // re-registration: a fresh challenge follows }) } @@ -181,6 +197,9 @@ 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) + // A thread with messages is a live session: page loads resume checking + // it right away (cleared when the conversation is closed). + if (thread.messages.length) localStorage.setItem(SESSION_KEY, '1') const lastAdmin = [...thread.messages].reverse().find((m) => m.admin) if (lastAdmin) localStorage.setItem(LASTADMIN_KEY, lastAdmin.t) const viewing = state.open && state.mode === 'visitor' @@ -189,36 +208,74 @@ function onThread(thread) { if (lastAdmin && lastAdmin.t > notifyBaseline) { const fresh = Boolean(notifyBaseline) notifyBaseline = lastAdmin.t - if (fresh && !viewing) notifyMessage(state.label, messageText(lastAdmin.html)) + if (fresh && !viewing) { + notifyMessage(state.label, messageText(lastAdmin.html), () => open(bridge)) + } } if (viewing) markSeen() else refreshBadges() } -export function open(br, { admin = false } = {}) { +// The visitor ended the conversation: archive it server-side (the admin +// inbox keeps it), wipe the local session and rotate the token, so the +// next message starts an all-new thread. The panel stays open on the +// fresh empty state. +function closeConversation() { + if (!confirm('Close this conversation? A fresh thread starts with your next message.')) return + bridge.send({ token: state.token, close: true }) + for (const key of [TOKEN_KEY, SEEN_KEY, LASTADMIN_KEY, SESSION_KEY]) { + localStorage.removeItem(key) + } + state.token = ensureToken() // TOKEN_KEY is gone: a fresh random + state.thread = null + state.unread = false + notifyBaseline = '' + unsent = null + bridge.register(state.token, onThread, onPow, onEmail) + refreshBadges() +} + +let registered = false + +// Registration doubles as the resync: the server pushes the thread state +// back, and pagerite.js re-registers on every socket reconnect. Done once +// per page (also without opening the panel — resume()), in either mode — +// the visitor thread is also the admin's own (the trigger buttons open it +// for them). +function ensureRegistered(br) { bridge = br + if (registered) return + registered = true + state.token = ensureToken() + bridge.register(state.token, onThread, onPow, onEmail) +} + +// A live session found at page load: check for replies immediately, +// without opening the panel (pagerite.js calls this when the session flag +// is set; with a granted notification permission new replies notify right +// away). +export function resume(br) { + ensureRegistered(br) +} + +export function open(br, { admin = false } = {}) { + ensureRegistered(br) if (admin) state.viewerAdmin = true if (!app) { - token = ensureToken() app = createApp(ContactOverlay, { state, - token, reauthorize: requestQuota, onSend: ({ md, name, email }) => sendMessage(md, name, email), onClose: () => { state.open = false refreshBadges() }, + onCloseThread: closeConversation, }) const el = document.createElement('div') el.id = 'contact-root' document.body.append(el) app.mount(el) - // Registration doubles as the resync: the server pushes the thread - // state back, and pagerite.js re-registers on every socket reconnect. - // Registered on first open in either mode — the visitor thread is - // also the admin's own (the trigger buttons open it for them). - bridge.register(token, onThread, onPow, onEmail) } state.mode = admin ? 'admin' : 'visitor' readContactStrings() @@ -226,3 +283,125 @@ export function open(br, { admin = false } = {}) { if (!admin) markSeen() else refreshBadges() } + + +// --- Admin watch ------------------------------------------------------------- +// For editors, while messaging is enabled on the site: a background +// /_api/ws/analytics connection keeps the corner icon's badge live and +// notifies new visitor messages right away — the admin always checks, +// without waiting for the inbox to open (pagerite.js starts the watch at +// page load). While the inbox is open MessagesView owns its own socket and +// the notifications; the watch then only tracks the badge. Reconnect +// pacing and the auth-aware close handling are the shared reconnect.js +// policy, same as MessagesView's. +let watchWs = null +let watchStarted = false +let watchTimer = null +let watchEpoch = 0 +let watchHiddenAt = 0 +const watchReconnects = reconnectPolicy() +const watchThreads = new Map() // id -> thread payload (badge + notify baseline) +const WATCH_WAKE_MS = 30_000 + +function watchBadge() { + state.adminUnread = [...watchThreads.values()].filter( + (t) => t.unread && !t.closed + ).length +} + +function watchThread(t) { + const last = t.messages[t.messages.length - 1] + const prev = watchThreads.get(t.id)?.messages?.at(-1)?.id + watchThreads.set(t.id, t) + watchBadge() + // No notification for history (the first 'threads' push baselines), the + // admin's own replies, non-message updates (prev === last.id), closed + // threads — or while the inbox is open (MessagesView notifies then). + if (!last || last.admin || prev === last.id || t.closed) return + if (state.open && state.mode === 'admin') return + notifyMessage(`New message from ${t.name || 'a visitor'}`, messageText(last.html), () => + open(bridge, { admin: true }) + ) +} + +function watchConnect() { + if (watchWs) return + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:' + const ws = (watchWs = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)) + watchConnecting(ws, 'contact-watch') + ws.onopen = () => watchReconnects.opened() + ws.onmessage = (ev) => { + let msg = null + try { + msg = JSON.parse(ev.data) + } catch { + return + } + if (msg?.type === 'threads') { + watchThreads.clear() + for (const t of msg.threads) watchThreads.set(t.id, t) + watchBadge() + } else if (msg?.type === 'thread') { + watchThread(msg) + } + } + ws.onclose = async () => { + if (watchWs !== ws) return + watchWs = null + const gen = watchEpoch + const wait = watchReconnects.closed() + const outcome = await authReconnect() + if (gen !== watchEpoch) return + if (outcome === 'auth') watchConnect() + else if (outcome === 'retry') watchTimer = setTimeout(watchConnect, wait) + // 'abort': no session — the next auth-success restarts the watch. + } + ws.onerror = () => ws.close() +} + +function watchConnectNow() { + watchEpoch++ + clearTimeout(watchTimer) + if (watchWs) { + watchWs.onclose = null + watchWs.close() + watchWs = null + } + watchConnect() +} + +export function watchAdmin(br) { + bridge = br + if (watchStarted) return + watchStarted = true + watchTimer = setTimeout(watchConnect, socketSlot()) + addEventListener('online', () => { + if (!watchWs) watchConnectNow() + }) + addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + watchHiddenAt = Date.now() + return + } + // A long-hidden tab's socket is a zombie: reconnect to catch up. + if (watchHiddenAt && Date.now() - watchHiddenAt > WATCH_WAKE_MS) watchConnectNow() + watchHiddenAt = 0 + }) + // A completed login (re)starts the watch at once; a logout stops it. + addEventListener('message', (ev) => { + const type = ev.data?.type + if (type === 'auth-success') { + if (!watchWs) watchConnectNow() + } else if (type === 'auth-logout') { + watchEpoch++ + clearTimeout(watchTimer) + if (watchWs) { + watchWs.onclose = null + watchWs.close() + watchWs = null + } + watchThreads.clear() + state.adminUnread = 0 + } + }) +} diff --git a/frontend/src/notify.js b/frontend/src/notify.js index 3933d86..2a1e97a 100644 --- a/frontend/src/notify.js +++ b/frontend/src/notify.js @@ -1,25 +1,25 @@ // 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' +// messages. Permission is requested at one entry point only — right +// after the first message of a fresh thread (the Notification API +// requires a user gesture). A denied permission is permanent in the +// browser, so we never re-ask; a dismissed prompt stays "default" and is +// asked again on the next fresh thread. 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) { +// it focuses the site tab and runs onclick (opening the thread/inbox +// view), so the user reliably lands on what was new. +export function notifyMessage(title, body, onclick) { if (!('Notification' in window) || Notification.permission !== 'granted') return const n = new Notification(title, { body, icon: '/favicon.ico' }) n.onclick = () => { window.focus() + onclick?.() n.close() } } diff --git a/frontend/src/pagerite.js b/frontend/src/pagerite.js index cd2f662..8752f85 100644 --- a/frontend/src/pagerite.js +++ b/frontend/src/pagerite.js @@ -321,6 +321,7 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; renderAuthUi(); placeEditPen(); pingEntryOnce(); + contactAutoStart(); } // Returning to the page via history back/forward may restore a cached @@ -968,24 +969,47 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect"; }, }; + async function loadContact() { + contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]); + for (const css of (assets["pagerite:contact-css"] || "").split(",")) { + if (css && !document.querySelector(`link[href="${css}"]`)) { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = css; + link.dataset.pagerite = "contact-css"; + document.head.append(link); + } + } + return contactMod; + } + async function openContactPanel(opts) { try { - contactMod ??= await import(/* @vite-ignore */ assets["pagerite:contact-src"]); - for (const css of (assets["pagerite:contact-css"] || "").split(",")) { - if (css && !document.querySelector(`link[href="${css}"]`)) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = css; - link.dataset.pagerite = "contact-css"; - document.head.append(link); - } - } - contactMod.open(contactBridge, opts); + (await loadContact()).open(contactBridge, opts); } catch (e) { console.error("contact panel load failed:", e); } } + // Message sessions follow the page load, not the panel: a visitor with + // an open conversation (the bundle's localStorage session flag) resumes + // checking for replies right away — with a granted notification + // permission new replies notify immediately; an admin's inbox watch + // (badge + notifications) runs whenever messaging is enabled. + async function contactAutoStart() { + if (!contactEnabled) return; + const wantAdmin = isAdmin; + const wantResume = !!localStorage.getItem("pagerite-contact-session"); + if (!wantAdmin && !wantResume) return; + try { + const mod = await loadContact(); + if (wantAdmin) mod.watchAdmin(contactBridge); + if (wantResume) mod.resume(contactBridge); + } catch (e) { + console.error("contact session resume failed:", e); + } + } + addEventListener("click", (ev) => { const btn = ev.target.closest?.(".contact-trigger"); if (!btn) return; diff --git a/pagerite/data.py b/pagerite/data.py index 37c3fd8..c2ac2e1 100644 --- a/pagerite/data.py +++ b/pagerite/data.py @@ -88,6 +88,10 @@ class ContactThread(msgspec.Struct, omit_defaults=True): #: afterwards ("" until then). name: str = "" email: str = "" + #: The visitor closed the conversation (their next message starts a + #: fresh thread under a new token); archived in the admin inbox, + #: excluded from the unread count. + closed: bool = False class Node(msgspec.Struct, omit_defaults=True): diff --git a/pagerite/messages.py b/pagerite/messages.py index 554ca20..544bbf3 100644 --- a/pagerite/messages.py +++ b/pagerite/messages.py @@ -169,6 +169,7 @@ def _payload(key: bytes, thread: ContactThread) -> dict: "id": key.hex(), "created": thread.created.isoformat(), "unread": _unread(thread), + "closed": thread.closed, "name": thread.name, "email": thread.email, "messages": [ @@ -186,7 +187,7 @@ def _payload(key: bytes, thread: ContactThread) -> dict: def _empty_payload(key: bytes) -> dict: return {"type": "thread", "id": key.hex(), "created": "", "unread": False, - "name": "", "email": "", "messages": []} + "closed": False, "name": "", "email": "", "messages": []} async def _push(sockets, payload: dict) -> None: @@ -288,7 +289,9 @@ def _spend(key: bytes) -> None: async def visitor_frame(ws: WebSocket, raw: dict) -> None: """Handle one visitor frame: ``{token}`` registers/resyncs (the thread state is pushed back), ``{token, msg, fr?}`` appends a message and - broadcasts the updated thread to both sides. The PoW quota check + broadcasts the updated thread to both sides, ``{token, close}`` ends + the conversation (archived in the admin inbox; a fresh thread needs a + new token, which the client rotates itself). The PoW quota check happens in the WS handler (tracking.activity_ws) — message frames arrive here with quota remaining, spent here per accepted message.""" token = str(raw.get("token") or "") @@ -297,10 +300,31 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: return key = thread_key(token) _visitor_ws.setdefault(key, set()).add(ws) + if raw.get("close"): + thread = data.contact.get(key) + if thread and not thread.closed: + with kanta.transaction("contact:close", extra=key.hex()): + thread.closed = True + await _push(_admin_ws, _payload(key, thread)) + # No more pushes to this token: drop the socket from its registry + # and clean up its pending uploads like a disconnect would. + sockets = _visitor_ws.get(key) + if sockets is not None: + sockets.discard(ws) + if not sockets: + del _visitor_ws[key] + for digest in _pending_uploads.pop(key, ()): + file_store.delete(digest) + return text = str(raw.get("msg") or "").strip() if not text: thread = data.contact.get(key) - await ws.send_json(_payload(key, thread) if thread else _empty_payload(key)) + # A closed thread resyncs as empty: the conversation is over, and a + # stale client (same token) starts from a clean slate. + if thread and not thread.closed: + await ws.send_json(_payload(key, thread)) + else: + await ws.send_json(_empty_payload(key)) return if len(text) > MAX_MESSAGE: await ws.send_json({"type": "contact-error", "detail": "message too long"}) @@ -314,6 +338,11 @@ async def visitor_frame(ws: WebSocket, raw: dict) -> None: # The first message must introduce the visitor (name; email optional, # unvalidated). The stored identity is fixed from then on. thread = data.contact.get(key) + if thread and thread.closed: + # Defensive: the client rotates its token on close, so a message + # for a closed thread only comes from a stale client. + await ws.send_json({"type": "contact-error", "detail": "thread closed"}) + return name = email = "" if thread is None or not thread.name: name = str(raw.get("name") or "").strip()[:100] @@ -362,8 +391,8 @@ def visitor_disconnect(ws: WebSocket) -> None: def unread_count() -> int: """Threads with an unanswered visitor message — the admin inbox icon's badge, seeded by /_api/settings (no polling; live while the inbox's - socket is open).""" - return sum(1 for t in data.contact.values() if _unread(t)) + socket is open). Visitor-closed (archived) threads do not count.""" + return sum(1 for t in data.contact.values() if _unread(t) and not t.closed) def _key_from_hex(value) -> bytes | None: @@ -383,13 +412,15 @@ def _last_t(thread: ContactThread) -> datetime: async def admin_connect(ws: WebSocket) -> None: """Register an admin socket and send the inbox: all threads, most - recently active first (capped at 100).""" + recently active first, visitor-closed (archived) ones last (capped at + 100).""" _admin_ws.add(ws) - threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True)[ - :100 + threads = sorted(data.contact.items(), key=lambda kv: _last_t(kv[1]), reverse=True) + threads = [kv for kv in threads if not kv[1].closed] + [ + kv for kv in threads if kv[1].closed ] await ws.send_json( - {"type": "threads", "threads": [_payload(k, t) for k, t in threads]} + {"type": "threads", "threads": [_payload(k, t) for k, t in threads[:100]]} ) @@ -408,6 +439,11 @@ async def admin_frame(ws: WebSocket, raw: dict) -> None: if thread is None: await ws.send_json({"type": "contact-error", "detail": "no such thread"}) return + if thread.closed: + await ws.send_json( + {"type": "contact-error", "detail": "the visitor closed this conversation"} + ) + return if not text or len(text) > MAX_MESSAGE: await ws.send_json({"type": "contact-error", "detail": "bad message"}) return