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
⚠️ {{ error }}
loading…
@@ -447,6 +497,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c