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:
co-authored by
Claude Fable 5.1
parent
3b81efcb51
commit
1274360ee6
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user