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 }}
+
emit('send', p)"
/>
@@ -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