Files
pagerite/frontend/src/contact-main.js
T
LeoVasanko dd82005f6c contact messages: settings UI, localized dialog title, mailto link
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.
2026-09-28 06:33:14 +00:00

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()
}