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