The site editor gains a messaging block: enabled toggle, contact email, and label/subject fields with a language selector that switches them to editing per-language overrides (the manual-override UI for the strings auto translators fill). The visitor dialog titles itself with the localized label and shows a mailto link (localized subject prefilled) when an email is configured; pagerite.js hides the corner icon from visitors when messaging is disabled.
188 lines
6.8 KiB
JavaScript
188 lines
6.8 KiB
JavaScript
// 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.
|
|
import { createApp, reactive, watch } from 'vue'
|
|
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
|
|
import ContactOverlay from './ContactOverlay.vue'
|
|
|
|
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'
|
|
|
|
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
|
|
// 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: '',
|
|
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 = assets['pagerite:contact-email'] || ''
|
|
}
|
|
|
|
// 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 with every registration/resync and after every accepted
|
|
// message; one solution burns one message. 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 token = ''
|
|
let solverPromise = null
|
|
let powSolution = null // Promise<string> solving the current challenge
|
|
let powAborter = null
|
|
let unsent = null // last message frame, retried once if its solution was rejected
|
|
|
|
// The solution for the currently outstanding challenge; rides out a
|
|
// challenge replacement (reconnect) by continuing with the new solve.
|
|
async function currentSolution() {
|
|
for (;;) {
|
|
const p = powSolution
|
|
if (!p) return ''
|
|
try {
|
|
return await p
|
|
} catch {
|
|
if (p === powSolution) return '' // failed without a replacement
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendMessage(md, name = '', email = '') {
|
|
unsent = { md, name, email }
|
|
const pow = await currentSolution()
|
|
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
|
|
}
|
|
|
|
async function onPow(msg) {
|
|
powAborter?.abort() // superseded challenge: stop the stale solve
|
|
const ac = (powAborter = new AbortController())
|
|
solverPromise ??= import(/* @vite-ignore */ SOLVER_URL)
|
|
const { solve } = await solverPromise
|
|
powSolution = solve(msg.challenge, ac.signal)
|
|
powSolution.catch(() => {}) // aborts are expected
|
|
// A rejected send (stale or already burned solution) is retried once
|
|
// the fresh challenge is solved; a second rejection drops it.
|
|
if (msg.required && unsent) {
|
|
const { md, name, email } = unsent
|
|
unsent = null
|
|
const pow = await currentSolution()
|
|
if (pow) bridge.send({ token, msg: md, pow, ...(name && { name, email }) })
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
function onThread(thread) {
|
|
state.thread = thread
|
|
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
|
|
if (viewing) markSeen()
|
|
else refreshBadges()
|
|
}
|
|
|
|
export function open(br, { admin = false } = {}) {
|
|
bridge = br
|
|
if (admin) state.viewerAdmin = true
|
|
if (!app) {
|
|
token = ensureToken()
|
|
app = createApp(ContactOverlay, {
|
|
state,
|
|
token,
|
|
onSend: ({ md, name, email }) => sendMessage(md, name, email),
|
|
onClose: () => {
|
|
state.open = false
|
|
refreshBadges()
|
|
},
|
|
})
|
|
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)
|
|
}
|
|
state.mode = admin ? 'admin' : 'visitor'
|
|
readContactStrings()
|
|
state.open = true
|
|
if (!admin) markSeen()
|
|
else refreshBadges()
|
|
}
|