contact messages: close conversation, live sessions, notification click-through

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.
This commit is contained in:
LeoVasanko committed 2026-09-29 02:32:55 +00:00
1 parent f8da552cfe
commit 7935115405
9 files changed
+373 -78

No files matched your search

+1 -1
View File
@@ -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.
+32 -12
View File
@@ -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
+14 -4
View File
@@ -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))
<header>
<h2>{{ admin ? 'Messages' : state.label }}</h2>
<a v-if="!admin && state.email" class="mailto" :href="mailto">{{ state.email }}</a>
<button
v-if="!admin && state.thread?.messages?.length"
type="button"
class="close-thread"
title="close this conversation — your next message starts a fresh thread"
@click="emit('close-thread')"
>⟲</button>
<button type="button" class="close" title="close" @click="emit('close')">✕</button>
</header>
<ContactPanel
v-if="!admin"
:state="state"
:token="token"
:token="state.token"
:reauthorize="reauthorize"
@send="(p) => 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);
}
+30 -8
View File
@@ -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"
>
<span class="dot" :class="{ unread: t.unread }"></span>
<span class="dot" :class="{ unread: t.unread && !t.closed }"></span>
<span class="peek">
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
<em v-if="t.closed" class="archived-tag">archived</em>
{{ preview(t) }}
</span>
<time>{{ fmtTime(lastMsg(t)?.t || t.created) }}</time>
@@ -317,7 +324,11 @@ function send(md) {
<div class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
<Composer placeholder="Reply…" @send="send" />
<Composer
:placeholder="thread.closed ? 'archived — the visitor closed this conversation' : 'Reply…'"
:send-disabled="thread.closed"
@send="send"
/>
</section>
<section v-else class="thread">
<p class="empty">select a thread</p>
@@ -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);
+203 -24
View File
@@ -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<string> 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
}
})
}
+9 -9
View File
@@ -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()
}
}
+35 -11
View File
@@ -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;
+4
View File
@@ -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):
+45 -9
View File
@@ -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