Files
ProdDash/modules/slack/client.js
T
Waters ChurchandClaude Fable 5.1 babe8263eb Slack module: channel transcript + PIN-gated sending as the location
Each campus signs its booth machine into Slack as the account named for
the location, so a message from ProdDash reads as "Apollo Beach" rather
than as whoever is at the desk. modules/slack (1.0.0) is three tiles from
one module: the live transcript of one channel, a free-text Send tile and
a Quick-replies grid — the last two behind a PIN pad, auto-locking after
a configurable idle time.

Server: one user (xoxp-) or bot (xoxb-) token, identified with auth.test;
conversations.history polled every N seconds (thread replies via
conversations.replies, capped per poll), diffed and fanned out as one
`state` SSE snapshot; mrkdwn rendered to safe text on the server; sends
through chat.postMessage (username/icon for a bot with "Send as"); PIN
compared in constant time with a 5-wrong → 30 s lockout, 32-byte unlock
tokens with a sliding expiry, 1 message/s, every send logged; readable
messages for invalid_auth / token_revoked / missing_scope /
not_in_channel / ratelimited; health reflects token → channel → poll →
sending. SLACK_API_BASE points it at tools/slack-mock.js (new).

Shell: the admin config form gains a multi-line field type `text` (a
textarea; one item per line by convention) — used for the quick replies.
Documented in the module guide; the mock is listed in CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 11:49:40 -04:00

766 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Slack — client part.
Three tiles from one module (moduleApi.variant picks): the channel
transcript, a PIN-gated free-text sender and a PIN-gated grid of preset
replies. Everything lives inside the tile's root; all traffic goes through
the module's server routes (the token and the PIN never reach a browser).
Nothing here uses innerHTML with data — every string from Slack lands in
a text node. */
/* ── shared helpers ─────────────────────────────────────────────────── */
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
function fmtTime(ms) {
const d = new Date(ms);
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
}
/* Inline marks the server left in the text: ```code``` `code` *bold*
_italic_ ~strike~. Openers must not sit inside a word (snake_case_names
and 5 * 3 * 2 stay as they are). Built with DOM nodes, so any character
in a message is safe. */
const MARK_RE = /(```[\s\S]*?```|`[^`\n]+`|(?<![\w*])\*(?!\s)[^*\n]*?(?<!\s)\*(?![\w*])|(?<![\w_])_(?!\s)[^_\n]*?(?<!\s)_(?![\w_])|(?<![\w~])~(?!\s)[^~\n]*?(?<!\s)~(?![\w~]))/g;
function renderRich(container, text, depth = 0) {
container.textContent = '';
const s = String(text || '');
let last = 0;
if (depth < 3) {
for (const m of s.matchAll(MARK_RE)) {
if (m.index > last) container.appendChild(document.createTextNode(s.slice(last, m.index)));
const tok = m[0];
let node;
if (tok.startsWith('```')) {
node = el('span', 'sl-code sl-block', tok.slice(3, -3).replace(/^\n+|\n+$/g, ''));
} else if (tok.startsWith('`')) {
node = el('code', 'sl-code', tok.slice(1, -1));
} else {
node = el(tok.startsWith('*') ? 'b' : tok.startsWith('_') ? 'i' : 's');
renderRich(node, tok.slice(1, -1), depth + 1);
}
container.appendChild(node);
last = m.index + tok.length;
}
}
if (last < s.length) container.appendChild(document.createTextNode(s.slice(last)));
}
function textSizePx(moduleApi, fallback = 14) {
return Math.min(48, Math.max(10, Number(moduleApi.instanceSettings.textSize) || fallback));
}
async function readJson(res) {
try {
return await res.json();
} catch {
return {};
}
}
const LOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
const BACK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 6H8l-5 6 5 6h13a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1z"/><path d="m18 9-6 6M12 9l6 6"/></svg>';
const CHECK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7"/></svg>';
/* ── the factory ────────────────────────────────────────────────────── */
export default function create({ root, moduleApi }) {
const view = String(moduleApi.variant || moduleApi.instanceSettings.view || 'transcript');
if (view === 'send' || view === 'quick') return createSendTile({ root, moduleApi, mode: view });
return createTranscriptTile({ root, moduleApi });
}
/* ── transcript ─────────────────────────────────────────────────────── */
function createTranscriptTile({ root, moduleApi }) {
const entries = new Map(); // ts -> { el, sig, ... }
let pinnedToLatest = true;
let painted = false;
let stream = null;
let stopped = false;
root.innerHTML = `
<div class="sl-wrap sl-transcript">
<div class="sl-banner" hidden></div>
<div class="sl-stream">
<div class="sl-empty">Connecting to Slack…</div>
<div class="sl-entries"></div>
</div>
<button class="sl-jump" hidden>↓ New messages</button>
</div>`;
const wrap = root.querySelector('.sl-wrap');
const banner = root.querySelector('.sl-banner');
const streamEl = root.querySelector('.sl-stream');
const entriesEl = root.querySelector('.sl-entries');
const emptyEl = root.querySelector('.sl-empty');
const jumpBtn = root.querySelector('.sl-jump');
/* title-bar controls */
function bumpTextSize(delta) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, textSizePx(moduleApi) + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ label: 'A−', title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ label: 'A+', title: 'Larger text', onClick: () => bumpTextSize(1) });
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Jump to latest',
onClick() {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
},
});
function applyInstanceSettings() {
const s = moduleApi.instanceSettings;
wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px');
wrap.classList.toggle('show-times', s.showTimes !== false);
wrap.classList.toggle('show-names', s.showNames !== false);
wrap.classList.toggle('show-system', s.showSystem !== false);
if (pinnedToLatest) scrollToLatest();
}
applyInstanceSettings();
/* scrolling */
streamEl.addEventListener('scroll', () => {
const near = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
});
jumpBtn.addEventListener('click', () => {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
});
function scrollToLatest() {
streamEl.scrollTop = streamEl.scrollHeight;
}
/* entries */
function buildMeta(m) {
const meta = el('div', 'sl-meta');
meta.append(el('span', 'sl-name', m.name || ''), el('span', 'sl-time', fmtTime(m.time)));
return meta;
}
function buildLine(m) {
// A message or a thread reply: name + time, then the text.
const line = el('div', m.parent ? 'sl-reply' : 'sl-msg');
line.classList.toggle('sl-me', Boolean(m.me));
line.classList.toggle('sl-bot', Boolean(m.bot));
line.appendChild(buildMeta(m));
const text = el('div', 'sl-text');
renderRich(text, m.text);
if (m.edited) text.appendChild(el('span', 'sl-edited', ' (edited)'));
line.appendChild(text);
return line;
}
function buildEntry(m) {
const node = el('div', 'sl-entry');
node.dataset.ts = m.ts;
return { el: node, sig: '' };
}
function fillEntry(entry, m) {
const sig = JSON.stringify(m);
if (sig === entry.sig) return;
entry.sig = sig;
const node = entry.el;
node.textContent = '';
node.classList.toggle('sl-system', Boolean(m.system));
node.classList.toggle('sl-me', Boolean(m.me));
if (m.system) {
const line = el('div', 'sl-sysline');
line.append(el('span', 'sl-time', fmtTime(m.time)), el('span', 'sl-systext', m.text || ''));
node.appendChild(line);
return;
}
node.appendChild(buildLine(m));
if (Array.isArray(m.replies)) {
if (m.replies.length) {
const list = el('div', 'sl-replies');
for (const r of m.replies) list.appendChild(buildLine(r));
node.appendChild(list);
}
} else if (m.replyCount > 0) {
node.appendChild(el('div', 'sl-replycount', `${m.replyCount} ${m.replyCount === 1 ? 'reply' : 'replies'}`));
}
}
function renderMessages(list) {
const seen = new Set();
let prev = null;
let added = 0;
for (const m of list) {
if (!m || !m.ts) continue;
seen.add(m.ts);
let entry = entries.get(m.ts);
if (!entry) {
entry = buildEntry(m);
entries.set(m.ts, entry);
if (painted) entry.el.classList.add('sl-new');
added += 1;
}
fillEntry(entry, m);
const node = entry.el;
// Keep DOM order equal to list order without re-inserting settled nodes
// (a move would replay the fade-in).
if (prev ? node.previousElementSibling !== prev : node !== entriesEl.firstElementChild) {
if (prev) prev.after(node);
else entriesEl.prepend(node);
}
prev = node;
}
for (const [ts, entry] of entries) {
if (seen.has(ts)) continue;
entry.el.remove();
entries.delete(ts);
}
if (pinnedToLatest) scrollToLatest();
else if (added) jumpBtn.hidden = false;
}
function applyState(s) {
if (stopped || !s || typeof s !== 'object') return;
const st = s.status || {};
const chan = s.channel?.name ? '#' + s.channel.name : '';
const who = s.identity?.name ? ` as ${s.identity.name}` : '';
renderMessages(Array.isArray(s.messages) ? s.messages : []);
const hasEntries = entries.size > 0;
if (st.state === 'ok') {
moduleApi.setStatus('ok', `Live — ${chan}${who}`);
banner.hidden = true;
} else {
moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to Slack…');
// With messages on screen the problem rides in a banner; with none,
// the empty state says it, once.
banner.textContent = st.message || 'Connecting to Slack…';
banner.classList.toggle('is-error', st.state === 'error');
banner.hidden = !hasEntries;
}
emptyEl.hidden = hasEntries;
if (!hasEntries) {
emptyEl.textContent = st.state !== 'ok'
? (st.message || 'Connecting to Slack…')
: chan ? `No messages yet in ${chan}` : 'No channel chosen — pick one in Admin → Slack';
emptyEl.classList.toggle('is-error', st.state === 'error');
}
painted = true;
}
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applyState(await res.json());
} catch {
/* the stream's next frame paints; the error handler reports the outage */
}
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
// Tiles share one stream; a late joiner missed its first frame, and a
// reconnect may have missed changes — a snapshot covers both.
loadState();
},
error() {
moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…');
banner.textContent = 'ProdDash Slack module unreachable — reconnecting…';
banner.classList.add('is-error');
banner.hidden = entries.size === 0;
if (!entries.size) {
emptyEl.textContent = 'ProdDash Slack module unreachable — reconnecting…';
emptyEl.classList.add('is-error');
}
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* a bad frame is skipped */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting to Slack…');
connect();
},
stop() {
stopped = true;
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
onResize() {
if (pinnedToLatest) scrollToLatest();
},
};
}
/* ── send + quick replies ───────────────────────────────────────────── */
function createSendTile({ root, moduleApi, mode }) {
let token = ''; // the unlock token — memory only; a remount shows the pad again
let expiresAt = 0;
let state = null; // latest snapshot: sending flags, identity, channel, status
let stream = null;
let ticker = null;
let stopped = false;
let busy = false;
let pinBuf = '';
let gateNote = ''; // one line under the pad ("Locked after inactivity")
let gateError = ''; // the server's refusal ("Wrong PIN", "locked for 30 s")
let gateKind = ''; // what the gate currently shows, to avoid needless rebuilds
let presets = null;
root.innerHTML = `
<div class="sl-wrap sl-send" tabindex="0">
<div class="sl-gate"></div>
<div class="sl-compose" hidden></div>
</div>`;
const wrap = root.querySelector('.sl-wrap');
const gate = root.querySelector('.sl-gate');
const compose = root.querySelector('.sl-compose');
const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock now', onClick: () => lock('Locked') });
lockBtn.style.display = 'none';
function applyInstanceSettings() {
wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px');
}
applyInstanceSettings();
function sendingLine() {
const who = state?.sendingAs || '…';
const chan = state?.channel?.name ? '#' + state.channel.name : 'no channel';
return `Sending as ${who} → ${chan}`;
}
function authHeaders(extra = {}) {
return { Authorization: `Bearer ${token}`, ...extra };
}
/* ── the gate: a PIN pad, or the reason there isn't one ── */
function renderGate() {
compose.hidden = true;
gate.hidden = false;
lockBtn.style.display = 'none';
let kind;
if (!state) kind = 'connecting';
else if (!state.sending?.enabled) kind = 'off';
else if (!state.sending?.pinSet) kind = 'nopin';
else kind = 'pad';
if (kind !== gateKind) {
gateKind = kind;
gate.textContent = '';
if (kind === 'pad') gate.appendChild(buildPad());
else {
const note = el('div', 'sl-note');
note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…'
: kind === 'off' ? 'Sending is off — turn it on in Admin → Slack → Sending'
: 'Set a PIN in Admin → Slack → Sending';
gate.appendChild(note);
}
}
if (kind === 'pad') refreshPad();
}
let padEls = null;
function buildPad() {
const pad = el('div', 'sl-pad');
const head = el('div', 'sl-pad-head');
head.append(el('b', '', 'Enter PIN'), el('span', 'sl-pad-sub'));
const dots = el('div', 'sl-dots');
for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sl-dot'));
const keys = el('div', 'sl-keys');
const key = (label, cls, onClick, html) => {
const b = el('button', 'sl-key' + (cls ? ' ' + cls : ''));
b.type = 'button';
if (html) b.innerHTML = html; // our own SVG constants only
else b.textContent = label;
b.addEventListener('click', (e) => {
e.preventDefault();
onClick();
wrap.focus({ preventScroll: true });
});
return b;
};
for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => pressDigit(d)));
const back = key('⌫', 'sl-key-back', () => { pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); }, BACK_SVG);
back.title = 'Backspace';
keys.appendChild(back);
keys.appendChild(key('0', '', () => pressDigit('0')));
const ok = key('Unlock', 'sl-key-ok', submitPin, CHECK_SVG);
ok.title = 'Unlock';
keys.appendChild(ok);
const msg = el('div', 'sl-pad-msg');
pad.append(head, dots, keys, msg);
padEls = { pad, sub: head.lastChild, dots, msg, ok };
return pad;
}
function refreshPad() {
if (!padEls) return;
padEls.sub.textContent = ` · ${sendingLine().replace(/^Sending/, 'sending')}`;
const n = Math.min(8, pinBuf.length);
[...padEls.dots.children].forEach((d, i) => {
d.classList.toggle('on', i < n);
// Show as many slots as could still be typed: 4 minimum, up to 8.
d.hidden = i >= Math.max(4, n + (n < 8 ? 1 : 0));
});
padEls.ok.disabled = pinBuf.length < 4 || busy;
padEls.msg.textContent = gateError || gateNote;
padEls.msg.classList.toggle('is-error', Boolean(gateError));
}
function pressDigit(d) {
if (busy || pinBuf.length >= 8) return;
pinBuf += d;
gateError = '';
refreshPad();
if (pinBuf.length === 8) submitPin();
}
function shake(message) {
gateError = message;
gateNote = '';
pinBuf = '';
refreshPad();
if (!padEls) return;
padEls.pad.classList.remove('sl-shake');
// restart the animation
void padEls.pad.offsetWidth;
padEls.pad.classList.add('sl-shake');
}
async function submitPin() {
if (busy || pinBuf.length < 4) return;
busy = true;
refreshPad();
try {
const res = await moduleApi.fetch('/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: pinBuf }),
});
const body = await readJson(res);
if (!res.ok || !body.token) {
shake(body.error || `Could not unlock (HTTP ${res.status})`);
return;
}
token = String(body.token);
expiresAt = Number(body.expiresAt) || Date.now() + 10 * 60000;
pinBuf = '';
gateError = '';
gateNote = '';
await showCompose();
} catch {
shake('ProdDash unreachable — try again');
} finally {
busy = false;
refreshPad();
}
}
wrap.addEventListener('keydown', (e) => {
if (!gate.hidden && gateKind === 'pad') {
if (/^[0-9]$/.test(e.key)) { e.preventDefault(); pressDigit(e.key); }
else if (e.key === 'Backspace') { e.preventDefault(); pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); }
else if (e.key === 'Enter') { e.preventDefault(); submitPin(); }
else if (e.key === 'Escape') { pinBuf = ''; gateError = ''; refreshPad(); }
}
});
/* ── unlocked: compose or the preset grid ── */
let composeEls = null;
function buildCompose() {
compose.textContent = '';
const head = el('div', 'sl-compose-head');
const line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink);
compose.appendChild(head);
const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg');
const countdown = el('span', 'sl-countdown');
countdown.hidden = true;
let input = null;
let sendBtn = null;
let grid = null;
if (mode === 'send') {
input = el('textarea', 'sl-input');
input.rows = 3;
input.placeholder = state?.channel?.name ? `Message #${state.channel.name}…` : 'Message…';
input.spellcheck = true;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendText();
}
});
compose.appendChild(input);
sendBtn = el('button', 'sl-sendbtn', 'Send');
sendBtn.type = 'button';
sendBtn.addEventListener('click', sendText);
foot.append(msg, countdown, sendBtn);
} else {
grid = el('div', 'sl-grid');
compose.appendChild(grid);
foot.append(msg, countdown);
}
compose.appendChild(foot);
composeEls = { line, msg, countdown, input, sendBtn, grid };
}
async function showCompose() {
buildCompose();
gate.hidden = true;
compose.hidden = false;
lockBtn.style.display = '';
moduleApi.setStatus(statusState(), `${statusMessage()} · unlocked`);
if (mode === 'quick') await loadPresets();
else composeEls.input?.focus();
tickLock();
}
function flash(text) {
if (!composeEls) return;
composeEls.msg.textContent = text;
composeEls.msg.className = 'sl-msg is-flash';
clearTimeout(flash.timer);
flash.timer = setTimeout(() => {
if (composeEls && composeEls.msg.textContent === text) {
composeEls.msg.textContent = '';
composeEls.msg.className = 'sl-msg';
}
}, 1800);
}
function showError(text) {
if (!composeEls) return;
clearTimeout(flash.timer);
composeEls.msg.textContent = text;
composeEls.msg.className = 'sl-msg is-error';
}
async function sendText() {
if (busy || !composeEls?.input) return;
const text = composeEls.input.value.trim();
if (!text) return;
busy = true;
composeEls.sendBtn.disabled = true;
try {
const res = await moduleApi.fetch('/send', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ text }),
});
const body = await readJson(res);
if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true);
if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`);
composeEls.input.value = '';
if (body.expiresAt) expiresAt = Number(body.expiresAt);
flash('Sent ✓');
} catch {
showError('ProdDash unreachable — not sent');
} finally {
busy = false;
if (composeEls?.sendBtn) {
composeEls.sendBtn.disabled = false;
composeEls.input?.focus();
}
}
}
async function loadPresets() {
if (!composeEls?.grid) return;
composeEls.grid.textContent = '';
composeEls.grid.appendChild(el('div', 'sl-note', 'Loading…'));
try {
const res = await moduleApi.fetch('/quick', { headers: authHeaders() });
const body = await readJson(res);
if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true);
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
presets = Array.isArray(body.presets) ? body.presets : [];
if (body.expiresAt) expiresAt = Number(body.expiresAt);
renderPresets();
} catch (err) {
composeEls.grid.textContent = '';
const note = el('div', 'sl-note is-error', `Could not load quick replies — ${err.message}`);
composeEls.grid.appendChild(note);
}
}
function renderPresets() {
if (!composeEls?.grid) return;
const grid = composeEls.grid;
grid.textContent = '';
if (!presets.length) {
grid.appendChild(el('div', 'sl-note', 'No quick replies yet — add some in Admin → Slack → Sending'));
return;
}
for (const p of presets) {
const b = el('button', 'sl-quick');
b.type = 'button';
b.appendChild(el('span', 'sl-quick-label', p.label));
if (p.channel) b.appendChild(el('span', 'sl-quick-chan', `→ ${p.channel}`));
b.addEventListener('click', () => sendQuick(p.index, b));
grid.appendChild(b);
}
}
async function sendQuick(index, btn) {
if (busy) return;
busy = true;
btn.disabled = true;
btn.classList.add('is-busy');
try {
const res = await moduleApi.fetch('/quick', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ index }),
});
const body = await readJson(res);
if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true);
if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`);
if (body.expiresAt) expiresAt = Number(body.expiresAt);
if (composeEls) {
composeEls.msg.textContent = '';
composeEls.msg.className = 'sl-msg';
}
btn.classList.remove('is-busy');
btn.classList.add('is-sent');
const label = btn.querySelector('.sl-quick-label');
const original = label.textContent;
label.textContent = 'Sent ✓';
setTimeout(() => {
if (!btn.isConnected) return;
label.textContent = original;
btn.classList.remove('is-sent');
}, 1000);
} catch {
showError('ProdDash unreachable — not sent');
} finally {
busy = false;
btn.disabled = false;
btn.classList.remove('is-busy');
}
}
/* ── locking ── */
function lock(note, fromServer = false) {
const had = token;
token = '';
expiresAt = 0;
pinBuf = '';
presets = null;
composeEls = null;
compose.textContent = '';
if (had && !fromServer) {
moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {});
}
gateNote = note || '';
gateError = '';
gateKind = ''; // rebuild the pad fresh
padEls = null;
renderGate();
moduleApi.setStatus(statusState(), `${statusMessage()} · locked`);
}
function tickLock() {
if (!token) return;
const left = expiresAt - Date.now();
if (left <= 0) return void lock('Locked after inactivity');
if (composeEls) {
const cd = composeEls.countdown;
if (left < 60000) {
const s = Math.ceil(left / 1000);
cd.textContent = `Locks in 0:${String(s).padStart(2, '0')}`;
cd.hidden = false;
} else cd.hidden = true;
}
}
/* ── server state ── */
function statusState() {
const st = state?.status?.state;
return st === 'ok' || st === 'connecting' || st === 'error' ? st : 'connecting';
}
function statusMessage() {
return state?.status?.message || 'Connecting to Slack…';
}
function applyState(s) {
if (stopped || !s || typeof s !== 'object') return;
state = s;
moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`);
if (token) {
// Sending switched off or the PIN removed while unlocked: the server
// will refuse the next send; say so now instead of on failure.
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (composeEls) {
composeEls.line.textContent = sendingLine();
if (composeEls.input && s.channel?.name) composeEls.input.placeholder = `Message #${s.channel.name}…`;
}
} else renderGate();
}
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applyState(await res.json());
} catch { /* the stream paints */ }
}
function connect() {
stream = moduleApi.sse('/stream', {
open() { loadState(); },
error() {
moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…');
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* skip a bad frame */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting to Slack…');
renderGate();
connect();
ticker = setInterval(tickLock, 1000);
},
stop() {
stopped = true;
clearInterval(ticker);
clearTimeout(flash.timer);
stream?.close();
token = ''; // the unlock dies with the tile
root.innerHTML = '';
},
};
}