contact messages: visitor panel and composer

The composer is a constrained contenteditable (plain-text paste,
toolbar formatting, upload chips for images/attachments) serialized to
a Markdown subset by richtext.js — the wire format is text and the
server re-renders it, so no user markup ever reaches a page. Uploads go
to /_contact/files with the session token (admins share the component
via /_api/files). ContactPanel shows the thread pushed live over the
activity socket: pagerite.js lazy-loads the bundle on trigger clicks,
routes socket pushes to it, re-registers the session token on every
reconnect (the resync), and dots trigger buttons on unread replies.
This commit is contained in:
LeoVasanko committed 2026-09-26 01:11:14 +00:00
1 parent 776f408743
commit eea89032a2
6 files changed
+669

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
// Serialize the constrained contenteditable composer DOM to the contact
// message Markdown subset (emphasis, links, lists, code, /_f/ images and
// file chips). The wire format is text: the server re-renders it with a
// strict, html-less MarkdownIt (pagerite/messages.py), so no user markup
// of any kind ever reaches a page. This serializer is for fidelity, not
// safety — anything it does not know is simply unwrapped.
const ESCAPE_RE = /([\\`*_[\]])/g
export function escapeMd(text) {
return text.replace(ESCAPE_RE, '\\$1')
}
// One message's Markdown from the composer root's child nodes: each
// top-level node becomes a block, joined by blank lines.
export function serializeRich(root) {
const blocks = []
for (const node of root.childNodes) block(node, blocks)
return blocks
.map((b) => b.trim())
.filter((b) => b)
.join('\n\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function block(node, out) {
if (node.nodeType === Node.TEXT_NODE) {
out.push(escapeMd(node.textContent))
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
switch (node.tagName) {
case 'UL':
out.push([...node.children].map((li) => `- ${inline(li).trim()}`).join('\n'))
break
case 'OL':
out.push([...node.children].map((li, i) => `${i + 1}. ${inline(li).trim()}`).join('\n'))
break
case 'BLOCKQUOTE':
out.push(inline(node).split('\n').map((l) => `> ${l}`).join('\n'))
break
case 'PRE':
out.push('```\n' + node.textContent + '\n```')
break
case 'IMG':
// Uploaded image (data-path set by the composer on insert).
if (node.dataset.path) out.push(`![${escapeMd(node.alt || 'image')}](${node.dataset.path})`)
break
case 'BR':
break
default:
// DIV, P and anything else: a paragraph of inline content.
out.push(inline(node))
}
}
function inline(node) {
let out = ''
for (const child of node.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
out += escapeMd(child.textContent)
continue
}
if (child.nodeType !== Node.ELEMENT_NODE) continue
switch (child.tagName) {
case 'B':
case 'STRONG':
out += `**${inline(child)}**`
break
case 'I':
case 'EM':
out += `*${inline(child)}*`
break
case 'CODE':
out += '`' + child.textContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`') + '`'
break
case 'BR':
out += '\n'
break
case 'A': {
const text = inline(child)
const href = child.getAttribute('href') || ''
out += /^(https?:\/\/|mailto:|\/)/.test(href) ? `[${text}](${href})` : text
break
}
case 'IMG':
if (child.dataset.path) out += `![${escapeMd(child.alt || 'image')}](${child.dataset.path})`
break
case 'SPAN':
// Attachment chip (data-path + data-name); other spans unwrap.
out += child.dataset.path && child.dataset.name
? `[📎 ${escapeMd(child.dataset.name)}](${child.dataset.path})`
: inline(child)
break
default:
out += inline(child)
}
}
return out
}