From 4ad4e3bdff57143a2cf85cc6f573b0f748dce219 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 27 Sep 2026 18:59:22 +0000 Subject: [PATCH] contact messages: standalone /_m admin inbox, site-name reply label The inbox moves out of the analytics tab to its own page at /_m (own pens-row icon, own entry and socket lifecycle; the admin-gated /_api/ws/analytics socket keeps serving both, with typed messaging frames ignored by AnalyticsView). Admin replies are labeled with the currently configured site name in the wire payload (the SSO name stays stored for audit), and the thread view is capped to roughly the visitor panel's width. --- AGENTS.md | 4 +- docs/messages.md | 35 ++-- frontend/src/AnalyticsView.vue | 86 ++------ frontend/src/MessagesView.vue | 353 +++++++++++++++++++++++++++------ frontend/src/messages-main.js | 30 +++ frontend/src/pagerite.js | 124 +++++++----- frontend/vite.config.js | 3 +- pagerite/messages.py | 38 +++- pagerite/state.py | 10 + pagerite/tracking.py | 3 +- pagerite/views.py | 58 ++++++ 11 files changed, 535 insertions(+), 209 deletions(-) create mode 100644 frontend/src/messages-main.js diff --git a/AGENTS.md b/AGENTS.md index 0f41a68..a01be2c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`; `store_attachment` keeps non-media uploads as inert `{hash}.file` blobs), file routes (`/_api/files`, `/_f/` — media by hash, attachments only via `/_f/{hash}/{filename}`, all forced-download + nosniff, `/_themes/`, `/_fonts/`, favicon settings). - `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`. - `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md). Both sockets also carry the contact-message protocol (docs/messages.md): `/_ws` routes token-bearing visitor frames and gates sends with a per-connection single-use easypow challenge, the analytics socket the admin inbox. - - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). + - `messages.py` — visitor contact threads (docs/messages.md): strict message rendering (`html=False` markdown-it — threads never carry user markup), thread store ops, the visitor/admin socket protocols (PoW validation lives in `tracking.activity_ws` as a per-connection local; browser solver served at `/_contact/easypow.js`), the `/_m` admin inbox page route, and the public `/_contact/files` upload route (pending-upload quota, unreferenced files deleted when the session's last socket closes). - `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via ``, and llms.txt links point at the `.md` URLs. - `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `` and the sitemap, recorded in analytics. - `data.py` — msgspec Structs for the kanta database. @@ -31,10 +31,10 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke - `frontend/src/` — Vue editor and public-page JS entries. - `main.js` — Vue editor app entry. - `analytics-main.js` — analytics page entry (mounts `AnalyticsView` at `/_a`). + - `messages-main.js` + `MessagesView.vue` — admin contact inbox page at `/_m` (docs/messages.md): thread list, replies and read marking over `/_api/ws/analytics`; owns its socket lifecycle like AnalyticsView. - `langselect-main.js` + `LangSelector.vue` — public language selector, imported on demand by pagerite.js on pages with more than one hreflang alternate (the editors' `LangSelect` flag dropdown). - `contact-main.js` + `ContactPanel.vue` — public contact panel (docs/messages.md), imported on demand by pagerite.js when a `{contact:}` trigger button is clicked; rides the `/_ws` activity socket through pagerite.js's bridge (register/send, shared queue and reconnect pacing) — no extra connection. - `Composer.vue` + `richtext.js` — the message composer shared by the contact panel and the admin inbox: constrained contenteditable (plain-text paste, toolbar formatting, upload chips) serialized to a Markdown subset that the server re-renders strictly. - - `MessagesView.vue` — the admin contact inbox (messages tab of `AnalyticsView.vue` on `/_a`): thread list, replies and read marking over `/_api/ws/analytics`. - `store.js` — the shared Pinia store (`useStore`, id `pagerite`) for cross-bundle UI state. - `pagerite.js` — public page entry. - `editorLang.js` + `LangSelect.vue` — the editor shell's shared language selection and its selector component (page + structure tabs; drives the page preview while the panel is open, via `swapdoc.setLangOverride`). diff --git a/docs/messages.md b/docs/messages.md index a029c15..2986397 100644 --- a/docs/messages.md +++ b/docs/messages.md @@ -2,24 +2,24 @@ Visitor→admin messaging built into the site: a `{contact: Label}` directive in article Markdown renders a button that opens a chat-style panel; messages -are stored in the kanta database and answered from the admin inbox on `/_a`. +are stored in the kanta database and answered from the admin inbox on `/_m`. Visitors are anonymous — a localStorage session token identifies their thread — and everything realtime rides the two existing WebSockets; there is no REST polling anywhere. - `pagerite/messages.py` — the feature's core: the strict message renderer, - thread store ops, wire payloads, both socket protocols and the - `/_contact/files` upload route. + thread store ops, wire payloads, both socket protocols, the `/_m` inbox + page route and the `/_contact/files` upload route. - `pagerite/data.py` — `Data.contact` (`ContactThread`/`ContactMessage`). - `pagerite/tracking.py` — the sockets the protocol rides (see below). -- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`) and the - on-demand bundle's asset config. +- `pagerite/views.py` — the `{contact:}` directive (`_contact_tag`), the + `/_m` page render and the on-demand bundles' asset config. - `frontend/src/contact-main.js` + `ContactPanel.vue` — the visitor panel, imported on demand by `pagerite.js` on trigger clicks. - `frontend/src/Composer.vue` + `richtext.js` — the shared composer and its DOM→Markdown serializer. -- `frontend/src/MessagesView.vue` — the admin inbox (the messages tab of - `AnalyticsView.vue` on `/_a`). +- `frontend/src/messages-main.js` + `MessagesView.vue` — the admin inbox + page at `/_m` (own pens-row icon, own socket lifecycle). ## Trigger @@ -78,16 +78,18 @@ solves and retries once. Challenges are a per-connection local in the ## Admin side -Admins watch the inbox as the **messages tab on `/_a`**, served by the -existing `/_api/ws/analytics` socket (admin-gated like all of `/_api`). -Analytics payloads keep their type-less snapshot shape; messaging frames -always carry a `type` field, and that is how the client tells them apart. -On connect the server sends `{type: "threads", threads: [...]}` (100 most +Admins watch the inbox on its own page, **`/_m`** (✉️ icon in the pens +row), served by the existing `/_api/ws/analytics` socket (admin-gated like +all of `/_api`). Analytics payloads keep their type-less snapshot shape; +messaging frames always carry a `type` field, and that is how each client +picks out its own frames (the analytics viewer ignores them). On connect +the server sends `{type: "threads", threads: [...]}` (100 most recently active); new messages and replies push single-thread updates. Admin→server frames: `{type: "reply", id, text}` and `{type: "read", id}`. -Replies are authored with the SSO `remote-user` name and mark the thread -read; `unread` on a thread means its last message is the visitor's and -newer than the admin's last read. +Replies store the SSO `remote-user` name for audit but are labeled with +the currently configured **site name** in the wire payload (substituted at +read time) and mark the thread read; `unread` on a thread means its last +message is the visitor's and newer than the admin's last read. ## Message format and the XSS model @@ -151,7 +153,8 @@ message, fixed afterwards) and `messages: dict[str, ContactMessage]` keyed by a sortable timestamp-random id (dict-keyed, so a save's database diff touches only the appended message). Each message stores `t`, the Markdown source `md`, its strict -rendering `html`, `admin` ("" = visitor, else the SSO name) and `page`. +rendering `html`, `admin` ("" = visitor, else the SSO name — the wire +payload shows the current site name instead) and `page`. Appends are single transactions (`contact` / `contact:reply` / `contact:read`). diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue index fe2fa2f..820b1e9 100644 --- a/frontend/src/AnalyticsView.vue +++ b/frontend/src/AnalyticsView.vue @@ -2,9 +2,10 @@ // 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. 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). +// visit/crawler tables. The same socket also carries the contact-messaging +// protocol (typed frames) for the admin inbox — that lives on its own /_m +// page now (MessagesView.vue, docs/messages.md), so typed frames are ignored +// here. // See docs/analytics.md for the data format. import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { fetchJson } from 'paskia' @@ -33,7 +34,6 @@ 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. @@ -73,28 +73,6 @@ 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' @@ -110,20 +88,9 @@ function connect() { ws.onmessage = (event) => { try { 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 - } + // Contact-messaging frames (docs/messages.md) carry a type field and + // are for the /_m inbox; the type-less snapshot is analytics data. + if (msg?.type) return data.value = msg if (!rangePinned) { rangePinned = true @@ -340,15 +307,7 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c

Analytics

- -
- -
+

⚠️ {{ error }}

loading…