- `{contact}` or `{contact: Label}` to add a button on article, additional access on top right corner letter emoji
- Article button styling (with custom shape for Nitro theme)
- Visitor contact panel: live thread over the activity socket, rich composer (formatting,/file uploads), PoW anti-spam gating, contact email reveal after proof
- Admin inbox in the same overlay: thread list, replies, read marking, unread badge; background watch keeps badge and notifications live
- Messaging site settings (button label, email subject, our email) with per-language overrides and auto-translation support
- Browser notifications for new messages on both sides, with click-through to the new content
- Close conversation: archives the thread for admins, starts visitors fresh
Reviewed-on: https://git.zi.fi/LeoVasanko/pagerite/pulls/3
410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
// Messaging overlay entry: imported on demand by pagerite.js when a
|
|
// {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({
|
|
open: false,
|
|
mode: 'visitor', // 'visitor' | 'admin'
|
|
thread: null,
|
|
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
|
|
// link with subject prefilled.
|
|
label: 'Contact us',
|
|
email: '',
|
|
mailtoTo: '', // RFC-encoded "Brand <email>" recipient for the mailto href
|
|
subject: '',
|
|
})
|
|
|
|
// The localized contact strings ride the page's asset config (the
|
|
// production JSON block, or pagerite:* meta tags in dev).
|
|
function readContactStrings() {
|
|
let assets = {}
|
|
const el = document.getElementById('pagerite-assets')
|
|
if (el) {
|
|
try {
|
|
assets = JSON.parse(el.textContent)
|
|
} catch { /* keep empty */ }
|
|
} else {
|
|
for (const m of document.querySelectorAll('meta[name^="pagerite:contact-"]')) {
|
|
assets[m.name] = m.content
|
|
}
|
|
}
|
|
state.label = assets['pagerite:contact-label'] || 'Contact us'
|
|
state.subject = assets['pagerite:contact-subject'] || state.label
|
|
// state.email is NOT reset here: it arrives over the socket after the
|
|
// PoW is solved and stays valid for the session.
|
|
}
|
|
|
|
// Page swaps (fetch navigation, editor re-renders after settings saves)
|
|
// carry fresh per-language config: keep the dialog strings in sync
|
|
// without reopening.
|
|
addEventListener('pagerite:page-fetched', readContactStrings)
|
|
|
|
// The panel is main content, not a sidebar: while open it holds paskia's
|
|
// shared blur backdrop (refcounted, z-index 1099; the overlay itself sits
|
|
// at 1100, below the auth iframe's 9999).
|
|
watch(() => state.open, (open) => {
|
|
if (open) holdGlobalBackdrop()
|
|
else releaseGlobalBackdrop()
|
|
})
|
|
|
|
watch(() => state.adminUnread, () => refreshBadges())
|
|
|
|
function ensureToken() {
|
|
let token = localStorage.getItem(TOKEN_KEY)
|
|
if (!token) {
|
|
token = crypto.randomUUID()
|
|
localStorage.setItem(TOKEN_KEY, token)
|
|
}
|
|
return token
|
|
}
|
|
|
|
function seenAt() {
|
|
return Number(localStorage.getItem(SEEN_KEY) || 0)
|
|
}
|
|
|
|
function markSeen() {
|
|
localStorage.setItem(SEEN_KEY, String(Date.now()))
|
|
state.unread = false
|
|
refreshBadges()
|
|
}
|
|
|
|
// PoW (easypow, docs/messages.md): the server issues a fresh single-use
|
|
// challenge on registration/resync and whenever the quota runs out. The
|
|
// solver runs in the background and the solution is submitted as soon as
|
|
// it is ready; a validated proof grants a quota of messages and uploads
|
|
// and reveals the site's contact email (never carried in the page's
|
|
// asset config). The solver module is served by the backend from the
|
|
// 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 solverPromise = null
|
|
let powSolution = null // Promise<string> solving the current challenge
|
|
let powAborter = null
|
|
let unsent = null // last message frame, retried once when its send races the quota
|
|
let quotaWaiters = [] // uploads waiting for a re-granted quota
|
|
|
|
// The solved solution goes out immediately, ahead of any send.
|
|
async function submitProof() {
|
|
const p = powSolution
|
|
if (!p) return
|
|
try {
|
|
const pow = await p
|
|
if (pow && p === powSolution) bridge.send({ token: state.token, pow })
|
|
} catch { /* aborted: a newer solve is in flight */ }
|
|
}
|
|
|
|
function sendMessage(md, name = '', email = '') {
|
|
// 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: state.token, msg: md, ...(name && { name, email }) })
|
|
}
|
|
|
|
function onPow(msg) {
|
|
if (typeof msg.quota === 'number') {
|
|
// Proof accepted, quota granted: queued uploads and the racing send
|
|
// (rejected for a stale/empty quota) continue.
|
|
const waiters = quotaWaiters
|
|
quotaWaiters = []
|
|
for (const resolve of waiters) resolve(true)
|
|
if (unsent && !unsent.retried) {
|
|
const { md, name, email } = unsent
|
|
unsent.retried = true
|
|
bridge.send({ token: state.token, msg: md, ...(name && { name, email }) })
|
|
}
|
|
return
|
|
}
|
|
powAborter?.abort() // superseded challenge: stop the stale solve
|
|
const ac = (powAborter = new AbortController())
|
|
solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
|
|
solverPromise.then(({ solve }) => {
|
|
powSolution = solve(msg.challenge, ac.signal)
|
|
powSolution.catch(() => {}) // aborts are expected
|
|
submitProof()
|
|
})
|
|
}
|
|
|
|
function onEmail(msg) {
|
|
state.email = msg.email || ''
|
|
state.mailtoTo = msg.to || '' // RFC-encoded "Brand <email>" recipient
|
|
}
|
|
|
|
// An upload hit the end of the PoW quota: re-register (the server pushes
|
|
// a fresh challenge, solved and proven in the background) and resolve
|
|
// once the new quota lands.
|
|
function requestQuota() {
|
|
return new Promise((resolve) => {
|
|
quotaWaiters.push(resolve)
|
|
bridge.send({ token: state.token }) // re-registration: a fresh challenge follows
|
|
})
|
|
}
|
|
|
|
// Unread markers: the has-unread class (a periodic nudge animation, see
|
|
// pagerite.css) on every contact trigger button and on the corner ✉️ icon
|
|
// — a new admin reply for visitors, any unanswered thread for admins.
|
|
// Called on thread pushes and (from pagerite.js) after navigations, whose
|
|
// swap rebuilds the buttons.
|
|
export function refreshBadges() {
|
|
for (const btn of document.querySelectorAll('.contact-trigger')) {
|
|
btn.classList.toggle('has-unread', state.unread)
|
|
}
|
|
const icon = document.getElementById('contact-icon')
|
|
if (!icon) return
|
|
icon.classList.toggle('has-unread', state.viewerAdmin ? state.adminUnread > 0 : state.unread)
|
|
}
|
|
|
|
let notifyBaseline = '' // the first push after load is history, only baselines
|
|
|
|
function onThread(thread) {
|
|
state.thread = thread
|
|
unsent = null // the send landed (a thread push acknowledges it)
|
|
// 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'
|
|
state.unread = Boolean(lastAdmin) && Date.parse(lastAdmin.t) > seenAt() && !viewing
|
|
// ISO timestamps compare lexicographically.
|
|
if (lastAdmin && lastAdmin.t > notifyBaseline) {
|
|
const fresh = Boolean(notifyBaseline)
|
|
notifyBaseline = lastAdmin.t
|
|
if (fresh && !viewing) {
|
|
notifyMessage(state.label, messageText(lastAdmin.html), () => open(bridge))
|
|
}
|
|
}
|
|
if (viewing) markSeen()
|
|
else refreshBadges()
|
|
}
|
|
|
|
// 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) {
|
|
app = createApp(ContactOverlay, {
|
|
state,
|
|
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)
|
|
}
|
|
state.mode = admin ? 'admin' : 'visitor'
|
|
readContactStrings()
|
|
state.open = true
|
|
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 — or an
|
|
// unread:false push, e.g. a read marking for a thread beyond the
|
|
// baseline cap), closed threads — or while the inbox is open
|
|
// (MessagesView notifies then).
|
|
if (!last || last.admin || !t.unread || 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
|
|
}
|
|
})
|
|
}
|