The admin inbox leaves the short-lived /_m page and joins the visitor panel as one overlay (ContactOverlay shell over paskia's shared blur backdrop, centered dialog, full-screen on mobile): the corner ✉️ icon opens the visitor thread for visitors and the wider sidebar inbox for admins, while {contact:} trigger buttons always open the visitor side so admins can test both. The icon highlights unread state without extra connections: visitors via localStorage seen/last-admin timestamps, admins via the contact_unread count added to the /_api/settings probe, both kept live by the bundle once loaded. MessagesView owns its admin socket only while the overlay is open.
458 lines
12 KiB
Vue
458 lines
12 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'
|
|
|
|
// 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
|
|
|
|
// 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 ? 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)
|
|
threads.value = [thread, ...rest].sort((a, b) => lastT(b).localeCompare(lastT(a)))
|
|
}
|
|
|
|
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
|
|
} else if (msg?.type === 'thread') {
|
|
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) {
|
|
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 }"
|
|
@click="selected = t.id"
|
|
>
|
|
<span class="dot" :class="{ unread: t.unread }"></span>
|
|
<span class="peek">
|
|
<strong>{{ t.name || t.id.slice(0, 8) }}</strong>
|
|
{{ 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="Reply…" @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 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>
|