- `{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
496 lines
14 KiB
Vue
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>
|