Files
pagerite/frontend/src/MessagesView.vue
T
LeoVasanko d3c89e8c66 Visitor contact messaging: in site communication system with email readiness (#3)
- `{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
2026-09-29 03:01:00 +00:00

496 lines
14 KiB
Vue

<script setup>
// The admin contact inbox, mounted inside the messaging overlay
// (ContactOverlay.vue) when an admin opens the corner ✉️ icon. Owns its
// connection to the admin-gated /_api/ws/analytics socket (the same
// endpoint the analytics viewer uses) for exactly as long as the overlay
// is open: frames carrying a `type` field are messaging payloads
// (threads/thread/contact-error), the type-less analytics snapshot is
// ignored. Replies and read-markings go back down the same socket
// (docs/messages.md). Message HTML is the server's strict-markdown
// rendering — injected verbatim, never user markup (pagerite/messages.py).
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { formatWhen } from './analytics/format.js'
import Composer from './Composer.vue'
import ConnNote from './ConnNote.vue'
import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { askNotifyPermission, messageText, notifyMessage } from './notify.js'
// The shared store (contact-main): adminUnread mirrors the inbox's unread
// count onto the corner icon's badge.
const props = defineProps({
state: { type: Object, required: true },
})
const threads = ref([])
const selected = ref('')
const scroll = ref(null)
let ws = null
let reconnectTimer = null
let connectWatchdog = null
const reconnects = reconnectPolicy()
let mounted = false
let epoch = 0 // bumped on every intentional (re)connect; stale async tails bail
let hiddenAt = 0
// 'threads' full-state pushes are the baseline, history never notifies.
const seenLastMsg = new Map()
// The inbox is live data over its socket: while it is connecting, waiting
// to reconnect or logged out, say so (ConnNote) instead of showing a
// silent stale view.
const conn = ref('connecting') // connecting | open | waiting | login
const retryIn = ref(0)
const connNote = computed(() =>
conn.value === 'connecting' ? 'connecting to the server…'
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
: conn.value === 'login' ? 'login required — authenticate to continue…'
: '',
)
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) {
return t.messages.length ? t.messages[t.messages.length - 1].t : t.created
}
function upsertThread(thread) {
const rest = threads.value.filter((t) => t.id !== thread.id)
// 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. 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) {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj))
}
function connect() {
if (ws || !mounted) return
conn.value = 'connecting'
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/_api/ws/analytics`)
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'messages')
ws.onopen = () => {
conn.value = 'open'
reconnects.opened()
}
ws.onmessage = (event) => {
let msg = null
try {
msg = JSON.parse(event.data)
} catch {
return
}
// The type-less snapshot is analytics data, not for this view.
if (msg?.type === 'threads') {
threads.value = msg.threads
for (const t of msg.threads) seenLastMsg.set(t.id, lastMsg(t)?.id || '')
} else if (msg?.type === 'thread') {
notifyThread(msg)
upsertThread(msg)
} else if (msg?.type === 'contact-error') {
console.warn('contact:', msg.detail)
}
}
// No error banner from onerror: it is always followed by onclose, which
// probes the cause — a lost login gets the login flow, not an error loop.
ws.onclose = onSocketClose
}
// The socket dropped. Find out why before retrying (./reconnect's
// authReconnect): an expired session fails the handshake exactly like a
// dead network, but retrying the socket loops forever — the WS API cannot
// open the login dialog. A lost session runs the paskia login flow and
// reconnects the moment it completes; an aborted login (declined, or a
// proxy offering no paskia flow) closes the inbox; anything else gets the
// paced backoff.
async function onSocketClose() {
ws = null
if (!mounted) return
const gen = epoch
const wait = reconnects.closed()
const outcome = await authReconnect(() => { conn.value = 'login' })
if (!mounted || gen !== epoch) return
if (outcome === 'auth') {
connectNow()
} else if (outcome === 'abort') {
props.state.open = false
} else {
scheduleRetry(wait)
}
}
function scheduleRetry(wait) {
clearTimeout(reconnectTimer)
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimer = setTimeout(connect, wait)
}
// Connect (or recycle) right away, skipping any pending backoff: used after
// login and when the page wakes — a socket that survived sleep still looks
// OPEN but is dead (the server gave up on it while we were asleep), and
// only a fresh connect pushes the full inbox that catches the view up.
function connectNow() {
epoch++
clearTimeout(reconnectTimer)
reconnectTimer = null
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
connect()
}
// Waking up (sleep, or a long while hidden): recycle the connection rather
// than trust a zombie socket or wait out a stale backoff. A short tab
// switch with a live socket is left alone.
const WAKE_RECYCLE_MS = 30_000
function onVisibility() {
if (document.visibilityState === 'hidden') {
hiddenAt = Date.now()
return
}
if (!mounted || conn.value === 'login') return
const longAway = hiddenAt && Date.now() - hiddenAt > WAKE_RECYCLE_MS
hiddenAt = 0
if (conn.value !== 'open' || longAway) connectNow()
}
function onOnline() {
if (mounted && conn.value !== 'open' && conn.value !== 'login') connectNow()
}
function onPageShow(ev) {
// A bfcache restore comes back with dead sockets.
if (ev.persisted && mounted) connectNow()
}
// paskia's auth iframe announces its result as a window message: a login
// completed anywhere in the page reconnects at once instead of waiting out
// the backoff timer; a logout closes the inbox — it is useless without a
// session.
function onAuthMessage(ev) {
if (!mounted) return
const type = ev.data?.type
if (type === 'auth-success') {
if (conn.value === 'open' || conn.value === 'login') return
connectNow()
} else if (type === 'auth-logout') {
epoch++
clearTimeout(reconnectTimer)
reconnectTimer = null
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
props.state.open = false
}
}
onMounted(() => {
mounted = true
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimer = setTimeout(connect, socketSlot())
addEventListener('visibilitychange', onVisibility)
addEventListener('online', onOnline)
addEventListener('pageshow', onPageShow)
addEventListener('message', onAuthMessage)
})
onUnmounted(() => {
mounted = false
clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
removeEventListener('visibilitychange', onVisibility)
removeEventListener('online', onOnline)
removeEventListener('pageshow', onPageShow)
removeEventListener('message', onAuthMessage)
if (ws) {
ws.onclose = null
ws.close()
ws = null
}
})
const thread = computed(() => threads.value.find((t) => t.id === selected.value) || null)
// Auto-open the first thread once the list arrives.
watch(
() => threads.value.length,
(n) => {
if (n && !selected.value) selected.value = threads.value[0].id
},
{ immediate: true },
)
// Opening a thread marks it read on the server (the updated payload
// comes back around and clears the dot).
watch(thread, (t) => {
if (!t) return
if (t.unread) wsSend({ type: 'read', id: t.id })
scrollDown()
})
watch(() => thread.value?.messages?.length, scrollDown)
async function scrollDown() {
await nextTick()
if (scroll.value) scroll.value.scrollTop = scroll.value.scrollHeight
}
function lastMsg(t) {
return t.messages[t.messages.length - 1] || null
}
// List-row preview: the last message's visible text (the html is
// server-rendered strict markdown, safe to parse here).
function preview(t) {
const m = lastMsg(t)
if (!m) return ''
const div = document.createElement('div')
div.innerHTML = m.html
const who = m.admin ? `${m.admin}: ` : ''
return (who + (div.textContent || '')).slice(0, 80)
}
// Relative timestamps like elsewhere in analytics.
const fmtTime = (t) => formatWhen(t)
function send(md) {
// First reply doubles as the notification-permission gesture.
askNotifyPermission()
if (thread.value) wsSend({ type: 'reply', id: thread.value.id, text: md })
}
</script>
<template>
<ConnNote :text="connNote" />
<div class="messages-view">
<aside class="thread-list">
<p v-if="!threads.length" class="empty">no messages yet</p>
<button
v-for="t in threads"
:key="t.id"
type="button"
class="thread-row"
:class="{ active: t.id === selected, archived: t.closed }"
@click="selected = t.id"
>
<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>
</button>
</aside>
<section v-if="thread" class="thread">
<header v-if="thread.name" class="thread-head">
<strong>{{ thread.name }}</strong>
<a v-if="thread.email" :href="`mailto:${thread.email}`">{{ thread.email }}</a>
</header>
<div ref="scroll" class="thread-messages">
<div
v-for="m in thread.messages"
:key="m.id"
class="contact-msg"
:class="{ mine: m.admin }"
>
<!-- Server-rendered strict markdown only (see ContactPanel). -->
<header>
<span class="who">{{ m.admin || thread.name || 'visitor' }}</span>
<time>{{ fmtTime(m.t) }}</time>
</header>
<div class="contact-msg-body" v-html="m.html"></div>
</div>
</div>
<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>
</section>
</div>
</template>
<style scoped>
.messages-view {
flex: 1;
min-height: 0;
display: flex;
gap: 1rem;
}
.thread-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 16rem;
flex-shrink: 0;
overflow-y: auto;
}
.thread-row {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border: 1px solid var(--line);
border-radius: 0.4rem;
background: none;
color: var(--text, CanvasText);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
.thread-row.active {
border-color: var(--accent);
}
.thread-row .dot {
flex-shrink: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: transparent;
}
.thread-row .dot.unread {
background: var(--accent);
}
.thread-row .peek {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--muted);
}
.thread-row .peek strong {
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);
font-size: 0.75rem;
}
.thread {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 0;
min-height: 0;
}
.thread-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 0.2rem;
}
.empty {
color: var(--muted);
text-align: center;
margin-top: 2rem;
}
.thread-head {
display: flex;
gap: 0.6rem;
align-items: baseline;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--line);
font-size: 0.9rem;
}
.thread-head a {
color: var(--muted);
text-decoration: none;
}
.thread-head a:hover {
color: var(--accent);
}
.contact-msg {
padding: 0.55rem 0;
}
.contact-msg + .contact-msg {
border-top: 1px solid var(--line);
}
.contact-msg-body {
overflow-wrap: break-word;
}
.contact-msg-body :deep(p) {
margin: 0.25em 0;
}
.contact-msg-body :deep(img) {
max-width: 100%;
border-radius: 0.3rem;
}
.contact-msg-body :deep(a.attachment) {
display: inline-block;
padding: 0.05em 0.5em;
border: 1px solid var(--line);
border-radius: 1em;
background: var(--bg, Canvas);
text-decoration: none;
white-space: nowrap;
}
.contact-msg header {
display: flex;
gap: 0.5rem;
align-items: baseline;
color: var(--muted);
font-size: 0.75rem;
}
.contact-msg.mine header {
justify-content: flex-end;
}
.contact-msg header .who {
font-weight: 600;
color: var(--text, CanvasText);
}
@media (max-width: 48rem) {
.messages-view {
flex-direction: column;
}
.thread-list {
width: 100%;
max-height: 30%;
}
}
</style>