From bbb11bf17e29849168c610e39e6055ec0d5cf93c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 26 Sep 2026 01:14:52 +0000 Subject: [PATCH] contact messages: admin inbox on /_a The analytics viewer gains a messages tab over the same socket: frames with a type field are inbox payloads (thread list on connect, live thread upserts), the type-less snapshot stays analytics. MessagesView shows the thread list with unread dots and the selected thread with the shared composer for replies; opening a thread marks it read server-side. The header badge counts unread threads. --- frontend/src/AnalyticsView.vue | 83 ++++++++++- frontend/src/MessagesView.vue | 250 +++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 6 deletions(-) create mode 100644 frontend/src/MessagesView.vue diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index 0b79ba0..fe2fa2f 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -2,7 +2,9 @@ // Analytics viewer rendered as a normal page inside #main. Receives live // analytics data over /_api/ws/analytics (admin-gated by the auth proxy) and // renders totals, smoothed visit/views curves, a transition map, and recent -// visit/crawler tables. Read-only. +// visit/crawler tables. The same socket serves the contact inbox (the +// "messages" tab, MessagesView): visitor threads arrive with the snapshot +// and replies go back down it (docs/messages.md). // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { fetchJson } from 'paskia' @@ -31,6 +33,7 @@ import VisitorCharts from './VisitorCharts.vue' import { VIEW_W } from './analytics/chart.js' import { authReconnect, reconnectPolicy, socketSlot, watchConnecting } from './reconnect' import ConnNote from './ConnNote.vue' +import MessagesView from './MessagesView.vue' // Same centering margin as the charts, so the totals row's left edge // aligns with the chart svg above the natural width. @@ -70,6 +73,28 @@ const hashRange = location.hash.slice(1) const range = ref(RANGES[hashRange] ? hashRange : 'week') let rangePinned = Boolean(RANGES[hashRange]) +// The contact inbox rides the same socket (docs/messages.md): frames with +// a `type` field are messaging payloads, the type-less snapshot is +// analytics. The header switches between the two views. +const tab = ref('analytics') +const threads = ref([]) +const unreadTotal = computed(() => threads.value.reduce((n, t) => n + (t.unread ? 1 : 0), 0)) + +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 onReply({ id, text }) { wsSend({ type: 'reply', id, text }) } +function onRead(id) { wsSend({ type: 'read', id }) } + function connect() { if (ws || !mounted) return conn.value = 'connecting' @@ -84,7 +109,22 @@ function connect() { } ws.onmessage = (event) => { try { - data.value = JSON.parse(event.data) + const msg = JSON.parse(event.data) + // Contact-messaging frames carry a type field (docs/messages.md); + // the type-less snapshot is analytics data. + if (msg?.type === 'threads') { + threads.value = msg.threads + return + } + if (msg?.type === 'thread') { + upsertThread(msg) + return + } + if (msg?.type === 'contact-error') { + console.warn('contact:', msg.detail) + return + } + data.value = msg if (!rangePinned) { rangePinned = true const starts = (data.value?.visits || []) @@ -300,7 +340,15 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c

Analytics

-
+ +

⚠️ {{ error }}

loading…

+
@@ -484,7 +535,8 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c margin-left: auto; } -.ranges button { +.ranges button, +.tabs button { padding: 0.2rem 0.7rem; font: inherit; font-size: 0.9rem; @@ -495,13 +547,32 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c cursor: pointer; } -.ranges button:hover { color: var(--text); } +.ranges button:hover, +.tabs button:hover { color: var(--text); } -.ranges button.active { +.ranges button.active, +.tabs button.active { color: var(--text); border-color: var(--accent); } +.tabs { + display: flex; + gap: 0.25rem; +} + +.tabs .badge { + display: inline-block; + min-width: 1.1em; + margin-left: 0.3em; + padding: 0 0.25em; + border-radius: 1em; + background: var(--accent); + color: var(--bg, Canvas); + font-size: 0.8em; + text-align: center; +} + .close { padding: 0 0.3rem; background: none; diff --git a/frontend/src/MessagesView.vue b/frontend/src/MessagesView.vue new file mode 100644 index 0000000..4322466 --- /dev/null +++ b/frontend/src/MessagesView.vue @@ -0,0 +1,250 @@ + + + + +