Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers

A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.

Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.

ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.

The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.

The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-10-05 13:55:55 -04:00
co-authored by Claude Fable 5.1
parent 3b81efcb51
commit 1274360ee6
15 changed files with 3409 additions and 1528 deletions
+90 -7
View File
@@ -5,7 +5,45 @@
(hidden channels, channel icons, timestamps, text size, flow direction)
persists in instance settings, and the data comes from the module's own
server, which keeps one connection to ProdCom for every dashboard: a
snapshot on connect (`state`), then each changed entry (`entry`). */
snapshot on connect (`state`), then each changed entry (`entry`).
Messaging: a click anywhere in the tile makes ProdCom the platform the
Send Message module's tiles send through (the channel the text goes out
*as* is picked in their Send as menu, among the channels ProdCom lets text
be typed onto); while it is, the tile's bottom edge carries the accent
stripe.
That is shared through the page bus below — see "Messaging on the page"
in docs/MODULE-GUIDE.md. Text typed from a dashboard comes back on the
stream as an entry with source "typed" and is drawn with a keyboard mark. */
/* ── the page bus (shared with the Send Message module) ───────────── */
const PROVIDER_ID = 'prodcom-transcript';
const BUS_EVENT = 'proddash:messaging:';
function messagingBus() {
if (!window.proddashMessaging) {
window.proddashMessaging = {
version: 1,
target: { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' },
unlock: { token: '', expiresAt: 0, reason: '' },
quickEmojis: [],
transcripts: new Map(),
senders: new Set(),
};
}
return window.proddashMessaging;
}
function emit(name, detail = {}) {
window.dispatchEvent(new CustomEvent(BUS_EVENT + name, { detail }));
}
function on(name, fn) {
const handler = (e) => fn(e.detail || {});
window.addEventListener(BUS_EVENT + name, handler);
return () => window.removeEventListener(BUS_EVENT + name, handler);
}
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
@@ -21,6 +59,8 @@ export default function create({ root, moduleApi }) {
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
let stream = null; // moduleApi.sse handle
let stopped = false;
let lastStatus = null; // the link's last status, re-reported when the target changes
const bus = messagingBus();
/* ── DOM skeleton ───────────────────────────────────────────────── */
@@ -39,6 +79,26 @@ export default function create({ root, moduleApi }) {
const emptyEl = root.querySelector('.pt-empty');
const jumpBtn = root.querySelector('.pt-jump');
/* ── the send target: a click anywhere in the body points the senders here ── */
wrap.addEventListener('click', () => {
if (!channels.size) return; // nothing to send to yet
emit('select', { provider: PROVIDER_ID, destination: '' });
});
function isTarget() {
return bus.target.provider === PROVIDER_ID;
}
function paintTarget() {
if (stopped) return;
const on = isTarget();
if (on === wrap.classList.contains('is-target')) return;
wrap.classList.toggle('is-target', on);
if (lastStatus) applyStatus(lastStatus);
}
const offTarget = on('target', paintTarget);
/* ── title-bar controls ─────────────────────────────────────────── */
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
@@ -386,10 +446,18 @@ export default function create({ root, moduleApi }) {
const avatar = document.createElement('span');
avatar.className = 'avatar';
avatar.style.color = info.color;
avatar.innerHTML =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
// speech is a person; typed text (from a dashboard, or ProdCom's own
// box) a keyboard; an automation a bolt
avatar.innerHTML = entry.source === 'typed'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<rect x="3" y="7" width="18" height="11" rx="2"/><path d="M7 11h1M11 11h1M15 11h1M8 14.5h8"/></svg>'
: entry.source === 'automation'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
'<circle cx="12" cy="8.5" r="3.2"/>' +
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
if (entry.source === 'typed' || entry.source === 'automation') el.classList.add(entry.source);
const ch = document.createElement('span');
ch.className = 'channel';
@@ -434,14 +502,27 @@ export default function create({ root, moduleApi }) {
function applyStatus(st) {
if (!st || typeof st !== 'object') return;
if (st.state === 'ok') moduleApi.setStatus('ok', st.message || 'Live');
else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
lastStatus = st;
if (st.state === 'ok') {
// the Send Message tiles point here: typed text goes out as the chosen channel
const as = !isTarget() ? ''
: bus.target.destinationLabel ? ` · sends go out as ${bus.target.destinationLabel}`
: ' · selected — no channel accepts typed text';
moduleApi.setStatus('ok', (st.message || 'Live') + as);
} else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
}
function applySnapshot(snap) {
if (!snap || typeof snap !== 'object') return;
channels.clear();
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
// On the page and able to send: that is what lets the Send Message tiles
// pick ProdCom by default when it is the only transcript showing.
if (channels.size && !bus.transcripts.has(moduleApi.instanceId)) {
bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: '' });
emit('transcripts', {});
}
wrap.classList.toggle('is-target', isTarget());
groups.clear();
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
rebuildChannelMenu();
@@ -493,6 +574,8 @@ export default function create({ root, moduleApi }) {
stop() {
stopped = true;
sizeWatch.disconnect();
offTarget();
if (bus.transcripts.delete(moduleApi.instanceId)) emit('transcripts', {});
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},