Files

1190 lines
45 KiB
JavaScript
Raw Permalink Normal View History

/* Send Message — client part.
Two tiles (moduleApi.variant / the tile's `view` setting pick): a
PIN-gated free-text sender and a PIN-gated grid of preset replies. Both
send through this module's server, which hands the message to whichever
messaging module — Slack, ProdCom, … — the operator pointed them at by
clicking its transcript. A platform whose destinations are identities
(ProdCom: typed text goes out *as* a channel) also gets the Send as menu
in the title bar, where that identity is picked; a platform whose
destinations are recipients (Slack: a message goes *to* a channel) is
targeted by its transcripts alone and stays out of the menu, which is
hidden altogether when no installed platform offers identities. The
choice is saved with the tile (so a named layout remembers it) and shared
with every other messaging tile on the page through the bus below, so
Send message, Quick replies and the transcripts always agree on where the
next message goes.
Everything lives inside the tile's root; the PIN never leaves the server
except as the token it hands back. */
/* ── 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;
}
async function readJson(res) {
try {
return await res.json();
} catch {
return {};
}
}
const NO_PROVIDERS = 'No messaging module yet — install Slack or ProdCom from Admin → Available modules';
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 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>';
/* feedback after a send: it landed / it did not */
const CHECK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m7.5 12.5 3 3 6-6.5"/></svg>';
const BLOCKED_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M5.8 5.8l12.4 12.4"/></svg>';
/* where sends go: a crosshair */
const TARGET_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="6.5"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><path d="M12 2.5v3.5M12 18v3.5M2.5 12H6M18 12h3.5"/></svg>';
/* ── the page bus ───────────────────────────────────────────────────── */
/* One fact set about the whole page, not one tile: where the next message
goes, whether the page is unlocked, which transcripts are on it. Shared
with the Slack and ProdCom modules (modules don't import each other) as
a plain object on `window` plus CustomEvents on it — the contract is in
docs/MODULE-GUIDE.md under "Messaging on the page". Whoever mounts first
creates it. */
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(), // instanceId → { provider, destination }
senders: new Set(), // instanceIds of Send message / Quick replies tiles on the page
};
}
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 page's unlock token, '' once it has expired. */
function pageToken(bus) {
return bus.unlock.token && bus.unlock.expiresAt > Date.now() ? bus.unlock.token : '';
}
/* ── the PIN pad ────────────────────────────────────────────────────── */
/**
* 1–9, 0 and ⌫ under a row of dots, one per digit of the PIN. The dots sit
* on a real (invisible) numeric input, so a tap on them raises a phone's
* keyboard and a desktop keyboard types straight in; the keys are for a
* touch screen without one. It submits by itself once as many digits as the
* PIN has are in (the server says how many, never which), or at eight. On
* success the page-wide unlock is set and `onUnlocked` runs.
*/
function createPinPad({ moduleApi, bus, heading = 'Enter PIN', subtitle, pinLength, onUnlocked }) {
let busy = false;
let error = '';
let note = '';
const pad = el('div', 'sm-pad');
const head = el('div', 'sm-pad-head');
const sub = el('span', 'sm-pad-sub');
head.append(el('b', '', heading), sub);
const field = el('div', 'sm-field');
const dots = el('div', 'sm-dots');
for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sm-dot'));
const input = el('input', 'sm-pin-input');
input.type = 'text';
input.inputMode = 'numeric';
input.autocomplete = 'one-time-code';
input.setAttribute('pattern', '[0-9]*');
input.setAttribute('aria-label', 'PIN');
input.maxLength = 8;
input.spellcheck = false;
input.autocapitalize = 'off';
field.append(dots, input);
field.title = 'Tap to type the PIN';
const keys = el('div', 'sm-keys');
const key = (label, cls, onClick, html) => {
const b = el('button', 'sm-key' + (cls ? ' ' + cls : ''));
b.type = 'button';
b.tabIndex = -1; // the input keeps the focus; keys are taps
if (html) b.innerHTML = html; // our own SVG constants only
else b.textContent = label;
b.addEventListener('pointerdown', (e) => e.preventDefault()); // don't steal the input's focus
b.addEventListener('click', (e) => {
e.preventDefault();
onClick();
});
return b;
};
for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => press(d)));
keys.appendChild(el('span', 'sm-key sm-key-blank'));
keys.appendChild(key('0', '', () => press('0')));
const back = key('⌫', 'sm-key-back', backspace, BACK_SVG);
back.title = 'Backspace';
keys.appendChild(back);
const msg = el('div', 'sm-pad-msg');
// Shown instead of the keys when the tile is too short for them (style.css).
const hint = el('div', 'sm-pad-hint', 'Tap the dots and type the PIN, or make the tile taller for a keypad.');
pad.append(head, field, keys, hint, msg);
const wanted = () => Math.min(8, Math.max(0, Math.trunc(Number(pinLength?.()) || 0)));
const value = () => input.value;
function refresh() {
const text = subtitle ? String(subtitle() || '') : '';
sub.textContent = text ? ` · ${text}` : '';
const n = Math.min(8, value().length);
// One dot per digit of the PIN; while its length is unknown, 4 and one spare.
const slots = wanted() || Math.max(4, n + (n < 8 ? 1 : 0));
[...dots.children].forEach((d, i) => {
d.classList.toggle('on', i < n);
d.hidden = i >= slots;
});
pad.classList.toggle('is-busy', busy);
input.disabled = busy;
msg.textContent = error || note;
msg.classList.toggle('is-error', Boolean(error));
}
function setValue(v) {
input.value = String(v || '').replace(/\D/g, '').slice(0, 8);
refresh();
}
function maybeSubmit() {
const len = wanted();
if ((len && value().length >= len) || value().length === 8) submit();
}
function press(d) {
if (busy || value().length >= 8) return;
error = '';
setValue(value() + d);
maybeSubmit();
}
function backspace() {
if (busy) return;
error = '';
setValue(value().slice(0, -1));
}
input.addEventListener('input', () => {
if (busy) return;
error = '';
setValue(input.value);
maybeSubmit();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
e.preventDefault();
error = '';
setValue('');
}
});
field.addEventListener('click', () => input.focus());
function shake(message) {
error = message;
note = '';
setValue('');
pad.classList.remove('sm-shake');
void pad.offsetWidth; // restart the animation
pad.classList.add('sm-shake');
}
async function submit() {
if (busy || value().length < 4) return;
const pin = value();
busy = true;
refresh();
try {
const res = await moduleApi.fetch('/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin }),
});
const body = await readJson(res);
if (!res.ok || !body.token) {
shake(body.error || `Could not unlock (HTTP ${res.status})`);
return;
}
error = '';
note = '';
setValue('');
bus.unlock = { token: String(body.token), expiresAt: Number(body.expiresAt) || Date.now() + 10 * 60000, reason: '' };
emit('unlock', bus.unlock);
onUnlocked?.(body);
} catch {
shake('ProdDash unreachable — try again');
} finally {
busy = false;
refresh();
}
}
/** Keys that reach the tile rather than the input: digits, Backspace, Enter, Escape. */
function onKey(e) {
if (e.target === input) return false;
if (/^[0-9]$/.test(e.key)) press(e.key);
else if (e.key === 'Backspace') backspace();
else if (e.key === 'Enter') submit();
else if (e.key === 'Escape') {
error = '';
setValue('');
} else return false;
e.preventDefault();
return true;
}
refresh();
return {
el: pad,
refresh,
onKey,
focus() {
input.focus({ preventScroll: true });
},
setNote(text) {
note = String(text || '');
error = '';
refresh();
},
};
}
/* ── the factory ────────────────────────────────────────────────────── */
export default function create({ root, moduleApi }) {
const variant = String(moduleApi.variant || '');
const view = String(moduleApi.instanceSettings.view || variant || 'send');
return createSendTile({ root, moduleApi, mode: view === 'quick' ? 'quick' : 'send' });
}
/* ── send + quick replies ───────────────────────────────────────────── */
function createSendTile({ root, moduleApi, mode }) {
const bus = messagingBus();
let token = pageToken(bus); // the page's unlock — memory only, gone on reload
let state = null; // latest snapshot: providers, pin flags, quick emojis, status
let stream = null;
let ticker = null;
let stopped = false;
let busy = false;
let autoUnlocking = false;
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;
let targetTimer = null;
// The choice, saved with the tile: which platform, and per platform which
// destination — the Slack channel a message goes to, the ProdCom channel it
// goes out as.
let activeProvider = String(moduleApi.instanceSettings.provider || '');
let chosen = { ...(moduleApi.instanceSettings.chosen && typeof moduleApi.instanceSettings.chosen === 'object' ? moduleApi.instanceSettings.chosen : {}) };
root.innerHTML = `
<div class="sm-wrap sm-send" tabindex="0">
<div class="sm-gate"></div>
<div class="sm-compose" hidden></div>
</div>`;
const wrap = root.querySelector('.sm-wrap');
const gate = root.querySelector('.sm-gate');
const compose = root.querySelector('.sm-compose');
const lockBtn = moduleApi.header.addButton({ icon: LOCK_SVG, title: 'Lock', onClick: () => lock('Locked') });
lockBtn.style.display = 'none';
/* ── where a send goes ── */
function providers() {
return Array.isArray(state?.providers) ? state.providers : [];
}
function providerById(id) {
return id ? providers().find((p) => p.id === id) || null : null;
}
/** Destinations of `providerId` with a transcript on this page, in page order. */
function shownDestinations(providerId) {
const out = [];
for (const t of bus.transcripts.values()) if (t.provider === providerId && t.destination) out.push(t.destination);
return out;
}
/**
* The platform the operator chose (a click on its transcript, or the Send
* as menu) while it is still offered, else one with a transcript on this
* page, else the first; its destination the one chosen for it, else one
* with a transcript here, else its first.
*/
function resolveTarget() {
const list = providers();
if (!list.length) return { provider: null, destination: null };
let p = providerById(activeProvider);
if (!p) {
for (const t of bus.transcripts.values()) {
p = providerById(t.provider);
if (p) break;
}
if (!p) p = list[0];
}
const dests = Array.isArray(p.destinations) ? p.destinations : [];
let d = dests.find((x) => x.id === chosen[p.id]) || null;
if (!d) {
const shown = shownDestinations(p.id);
d = dests.find((x) => shown.includes(x.id)) || dests[0] || null;
}
return { provider: p, destination: d };
}
/** Tell the page (transcripts light up, the other send tile follows). */
function publishTarget() {
if (!state) return;
const { provider, destination } = resolveTarget();
const next = {
provider: provider?.id || '',
destination: destination?.id || '',
providerLabel: provider?.label || '',
destinationLabel: destination?.label || '',
identity: provider?.identity || '',
};
const cur = bus.target;
if (next.provider === cur.provider && next.destination === cur.destination && next.destinationLabel === cur.destinationLabel && next.identity === cur.identity) return;
bus.target = next;
emit('target', next);
}
function persistChoice() {
moduleApi.saveInstanceSettings({ provider: activeProvider, chosen });
}
/** A transcript was clicked, or a destination picked from a Send as menu (ours or the other tile's). */
function choose(provider, destination) {
if (!provider) return;
let changed = false;
if (activeProvider !== provider) {
activeProvider = provider;
changed = true;
}
if (destination && chosen[provider] !== destination) {
chosen = { ...chosen, [provider]: destination };
changed = true;
}
if (changed) persistChoice();
repaintTarget();
}
const isIdentity = (p) => p?.destinationRole === 'identity';
/** "Slack · as Apollo Beach → #ops" / "ProdCom · as FOH" — or why there is nowhere to send. */
function targetLine() {
if (state && !providers().length) return NO_PROVIDERS;
const { provider, destination } = resolveTarget();
if (!provider) return 'Connecting…';
if (!destination) {
if (provider.emptyMessage) return provider.emptyMessage;
return isIdentity(provider) ? `${provider.label}: pick who to send as in Send as` : `${provider.label}: no destination yet`;
}
if (isIdentity(provider)) return `${provider.label} · as ${destination.label}`;
const who = provider.identity ? ` · as ${provider.identity}` : '';
return `${provider.label}${who} → ${destination.label}`;
}
/** "Send message to ProdCom as FOH…" / "Send message to #ops…" */
function placeholder() {
const { provider, destination } = resolveTarget();
if (!destination) return 'Send message…';
return isIdentity(provider)
? `Send message to ${provider.label} as ${destination.label}…`
: `Send message to ${destination.label}…`;
}
/** Platforms whose destinations are identities — the only ones the Send as menu is for. */
function identityProviders() {
return providers().filter(isIdentity);
}
/** The selection changed somewhere on the page: every line that names the target follows. */
function repaintTarget() {
if (stopped || !state) return;
publishTarget();
if (token) {
if (composeEls) {
if (composeEls.line) composeEls.line.textContent = targetLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
}
} else renderGate();
reportStatus();
}
const offSelect = on('select', ({ provider, destination }) => {
if (stopped) return;
choose(String(provider || ''), String(destination || ''));
});
const offTranscripts = on('transcripts', () => {
// Coalesce: a remount detaches and re-attaches within a few ms.
clearTimeout(targetTimer);
targetTimer = setTimeout(repaintTarget, 50);
});
// Another messaging tile (or Slack's reaction menu) unlocked or locked the page.
const offUnlock = on('unlock', () => {
if (stopped) return;
const t = pageToken(bus);
if (t && t !== token) {
token = t;
gateNote = '';
gateError = '';
showCompose();
} else if (!t && token) lock(bus.unlock.reason || 'Locked', true);
});
/* ── the Send as menu: who a message goes out as, per platform that has identities ──
Slack is not here: a Slack channel is where a message goes, and that is
chosen by clicking its transcript. The button itself only exists while
some installed platform offers identities (see applyState). */
const sendAsMenu = moduleApi.header.addMenu({
icon: TARGET_SVG,
title: 'Send as',
build(menu) {
menu.classList.add('sm-menu');
const list = identityProviders();
if (!list.length) {
menu.appendChild(el('div', 'sm-menu-note', state ? 'No platform here sends as a chosen identity' : 'Connecting…'));
return;
}
const { provider: cur, destination: curD } = resolveTarget();
for (const p of list) {
const head = el('div', 'sm-menu-head');
head.appendChild(el('b', '', p.label));
if (p.status?.state !== 'ok') {
head.classList.add(p.status?.state === 'error' ? 'is-error' : 'is-muted');
head.title = p.status?.message || '';
}
menu.appendChild(head);
const dests = Array.isArray(p.destinations) ? p.destinations : [];
if (!dests.length) {
menu.appendChild(el('div', 'sm-menu-note', p.status?.state === 'error' ? p.status.message : p.emptyMessage || 'Nothing to send as yet'));
continue;
}
for (const d of dests) {
const isOn = cur?.id === p.id && curD?.id === d.id;
const remembered = !isOn && chosen[p.id] === d.id;
const item = el('button', 'sm-menu-item' + (isOn ? ' on' : remembered ? ' remembered' : ''));
item.type = 'button';
item.setAttribute('role', 'menuitemradio');
item.setAttribute('aria-checked', String(isOn));
const check = el('span', 'check', '✓');
const name = el('span', 'name', d.label);
if (d.color) name.style.color = d.color;
item.append(check, name);
item.addEventListener('click', () => {
sendAsMenu.close();
emit('select', { provider: p.id, destination: d.id });
});
menu.appendChild(item);
}
}
},
});
const sendAsWrap = sendAsMenu.button.parentElement; // the shell's .tile-dd
sendAsWrap.hidden = true;
/** The Send as button is for platforms with identities; with none installed it isn't there at all. */
function placeSendAsMenu() {
const show = identityProviders().length > 0;
if (sendAsWrap.hidden !== !show) {
sendAsWrap.hidden = !show;
if (!show) sendAsMenu.close();
}
}
function authHeaders(extra = {}) {
return { Authorization: `Bearer ${token}`, ...extra };
}
/* ── the gate: a PIN pad, or the reason there isn't one ── */
let pinPad = null;
function renderGate() {
compose.hidden = true;
gate.hidden = false;
lockBtn.style.display = 'none';
let kind;
if (!state) kind = 'connecting';
else if (!providers().length) kind = 'noproviders';
else if (!state.pin?.enabled) kind = 'auto';
else if (!state.pin?.set) kind = 'nopin';
else kind = 'pad';
if (kind !== gateKind) {
gateKind = kind;
gate.textContent = '';
pinPad = null;
if (kind === 'pad') {
pinPad = createPinPad({
moduleApi,
bus,
subtitle: targetLine,
pinLength: () => Number(state?.pin?.length) || 0,
onUnlocked() {
token = pageToken(bus);
gateNote = '';
gateError = '';
showCompose();
},
});
gate.appendChild(pinPad.el);
pinPad.focus();
} else {
const note = el('div', 'sm-note');
note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…'
: kind === 'noproviders' ? NO_PROVIDERS
: kind === 'nopin' ? 'Set a PIN in Admin → Send Message, or switch the PIN off'
: 'Unlocking…';
gate.appendChild(note);
}
}
if (kind === 'pad' && pinPad) {
pinPad.setNote(gateError || gateNote);
pinPad.refresh();
} else if (kind === 'auto') {
const note = gate.querySelector('.sm-note');
if (note && gateNote) note.textContent = gateNote;
autoUnlock();
}
}
/** The PIN is switched off: take a token without asking (one per page is enough). */
async function autoUnlock() {
if (autoUnlocking || token || stopped) return;
const t = pageToken(bus);
if (t) {
token = t;
return void showCompose();
}
autoUnlocking = true;
try {
const res = await moduleApi.fetch('/unlock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
const body = await readJson(res);
if (stopped) return;
if (!res.ok || !body.token) {
gateNote = body.error || `Could not unlock (HTTP ${res.status})`;
return void renderGate();
}
if (pageToken(bus)) {
token = pageToken(bus); // the other tile got there first
} else {
bus.unlock = { token: String(body.token), expiresAt: Number(body.expiresAt) || Date.now() + 3600000, reason: '' };
emit('unlock', bus.unlock);
token = bus.unlock.token;
}
gateNote = '';
showCompose();
} catch {
if (!stopped) {
gateNote = 'ProdDash unreachable — retrying';
renderGate();
}
} finally {
autoUnlocking = false;
}
}
wrap.addEventListener('keydown', (e) => {
if (!gate.hidden && gateKind === 'pad' && pinPad) pinPad.onKey(e);
});
/* ── unlocked: compose or the preset grid ── */
let composeEls = null;
let rowWatch = null; // watches the quick-emoji row's width for the edge fades
function buildCompose() {
compose.textContent = '';
rowWatch?.disconnect();
rowWatch = null;
// Send message names the destination in the box's own placeholder and
// locks from the title bar, so a line above the field would only say
// both again. Quick replies has no placeholder — there it is the only
// place the target is named, so it stays.
let line = null;
if (mode !== 'send') {
const head = el('div', 'sm-compose-head');
line = el('span', 'sm-sending', targetLine());
head.appendChild(line);
if (state?.pin?.enabled) {
const lockLink = el('button', 'sm-lock-link', 'Lock');
lockLink.type = 'button';
lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked'));
head.appendChild(lockLink);
}
compose.appendChild(head);
}
const foot = el('div', 'sm-compose-foot');
const feedbackEl = el('div', 'sm-feedback');
const countdown = el('span', 'sm-countdown');
countdown.hidden = true;
let input = null;
let sendBtn = null;
let grid = null;
let quick = null;
let grip = null;
if (mode === 'send') {
input = el('textarea', 'sm-input');
input.rows = 1;
input.placeholder = placeholder();
input.spellcheck = true;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendText();
}
});
// The box and, straddling its bottom edge, the grip: drag it up to give
// the quick emojis more rows (the box gives up the height), down to
// take them back. It snaps a row at a time; the choice is saved with
// the tile. The grip takes no room of its own.
const box = el('div', 'sm-box');
box.appendChild(input);
grip = el('div', 'sm-grip');
grip.title = 'Drag for more quick emoji rows';
grip.setAttribute('role', 'separator');
grip.setAttribute('aria-orientation', 'horizontal');
grip.setAttribute('aria-label', 'Quick emoji rows');
grip.tabIndex = 0;
box.appendChild(grip);
compose.appendChild(box);
sendBtn = el('button', 'sm-sendbtn', 'Send');
sendBtn.type = 'button';
sendBtn.addEventListener('click', sendText);
// The admin's quick emojis: one press sends one, no confirmation. As a
// single row it scrolls sideways when the tile is narrow (style.css);
// a plain mouse wheel only moves vertically, so that motion is turned
// into the row's scroll. With more rows allotted it wraps instead.
quick = el('div', 'sm-emoji-sendrow');
quick.addEventListener('wheel', (e) => {
if (quick.classList.contains('is-multi')) return;
if (!e.deltaY || e.deltaX || quick.scrollWidth <= quick.clientWidth) return;
e.preventDefault();
quick.scrollLeft += e.deltaY;
}, { passive: false });
// The scrollbar is hidden; the edge with more keys behind it fades instead.
const paintEdges = () => {
const single = !quick.classList.contains('is-multi');
quick.classList.toggle('has-more', single && quick.scrollWidth - quick.clientWidth - quick.scrollLeft > 1);
quick.classList.toggle('has-less', single && quick.scrollLeft > 1);
};
quick.addEventListener('scroll', paintEdges, { passive: true });
rowWatch?.disconnect();
rowWatch = new ResizeObserver(() => {
applyQuickRows(); // the tile may have shrunk under the rows it had
paintEdges();
});
rowWatch.observe(quick);
rowWatch.observe(compose);
if (moduleApi.instanceSettings.showEmojis !== false) {
for (const q of Array.isArray(state?.quickEmojis) ? state.quickEmojis : []) {
// A single emoji fills a square key; a longer token (a Slack
// :code:, a word) is drawn as small text in a wider pill.
const isGlyph = [...String(q)].length <= 2 || /^\p{Extended_Pictographic}/u.test(q) && [...String(q)].length <= 4;
const b = el('button', 'sm-emoji-send' + (isGlyph ? '' : ' is-text'));
b.type = 'button';
b.title = `Send ${q}`;
b.setAttribute('aria-label', `Send ${q}`);
b.appendChild(el('span', 'sm-emoji-char', q));
b.addEventListener('click', () => sendEmoji(q, b));
quick.appendChild(b);
}
}
// emoji keys on the left, taking what room there is; the outcome icon
// sits beside Send, in a slot that is always there so Send never moves
foot.append(quick, countdown, feedbackEl, sendBtn);
} else {
grid = el('div', 'sm-grid');
compose.appendChild(grid);
foot.classList.add('is-quick');
foot.append(countdown, feedbackEl);
}
compose.appendChild(foot);
composeEls = { line, feedback: feedbackEl, countdown, input, sendBtn, grid, quick, grip };
if (grip) {
applyQuickRows();
setupGrip(grip);
}
}
/* ── quick emoji rows: how many, and the grip that sets it ── */
const QUICK_ROWS_MAX = 4;
const KEY_GAP_PX = 4;
/** Height of one key row plus its gap, from a real key when there is one. */
function keyPitch() {
const key = composeEls?.quick?.firstElementChild;
const h = key ? key.getBoundingClientRect().height : 0;
return (h || 32) + KEY_GAP_PX;
}
/** How many rows the keys need at the row's current width — more would be empty. */
function rowsNeeded() {
const q = composeEls?.quick;
if (!q || !q.childElementCount) return 1;
const width = q.clientWidth || q.getBoundingClientRect().width;
if (!width) return 1;
let rows = 1;
let used = 0;
for (const key of q.children) {
const w = key.getBoundingClientRect().width || 32;
if (used && used + KEY_GAP_PX + w > width + 0.5) {
rows += 1;
used = w;
} else used += (used ? KEY_GAP_PX : 0) + w;
}
return rows;
}
/**
* The most rows this tile can hold right now: whatever is left of the
* compose area once the box keeps one line and the foot its first row
* (the one the Send button shares) — and no more rows than the keys
* would fill. The grip rides on the box's edge and costs nothing.
*/
function maxQuickRows() {
const c = composeEls;
if (!c?.quick || !c.input || !c.grip) return 1;
const cs = getComputedStyle(compose);
const inner = compose.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
const gaps = parseFloat(cs.rowGap || cs.gap) || 0; // one gap: box → foot
// the box's one-line minimum, as an outer height whatever its box-sizing
const ics = getComputedStyle(c.input);
let inputMin = parseFloat(ics.minHeight) || 34;
if (ics.boxSizing !== 'border-box') {
inputMin += parseFloat(ics.paddingTop) + parseFloat(ics.paddingBottom) + parseFloat(ics.borderTopWidth) + parseFloat(ics.borderBottomWidth);
}
const pitch = keyPitch();
const spare = inner - gaps - inputMin - (pitch - KEY_GAP_PX);
return Math.max(1, Math.min(QUICK_ROWS_MAX, rowsNeeded(), 1 + Math.floor(spare / pitch)));
}
/** Lay the keys out in `wanted` rows (default: the tile's saved choice), capped by what fits. Returns the rows in use. */
function applyQuickRows(wanted = null) {
const c = composeEls;
if (!c?.quick) return 1;
const saved = Math.trunc(Number(moduleApi.instanceSettings.quickRows)) || 1;
const max = maxQuickRows();
const rows = Math.max(1, Math.min(max, wanted ?? saved));
c.quick.style.setProperty('--sm-quick-rows', String(rows));
c.quick.classList.toggle('is-multi', rows > 1);
if (c.grip) {
c.grip.setAttribute('aria-valuenow', String(rows));
// nothing to trade when every key already fits on the one row
c.grip.hidden = !c.quick.childElementCount || (max <= 1 && rows <= 1 && rowsNeeded() <= 1);
}
return rows;
}
function currentQuickRows() {
return Math.max(1, Math.trunc(Number(composeEls?.quick?.style.getPropertyValue('--sm-quick-rows'))) || 1);
}
/** Drag (or arrow keys) on the grip: a row per key pitch, saved on release. */
function setupGrip(grip) {
grip.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
grip.setPointerCapture(e.pointerId);
grip.classList.add('is-dragging');
const startY = e.clientY;
const startRows = currentQuickRows();
const pitch = keyPitch();
let rows = startRows;
const move = (ev) => {
// up means more rows for the keys, down gives the box its height back
rows = applyQuickRows(startRows + Math.round((startY - ev.clientY) / pitch));
};
const done = () => {
grip.removeEventListener('pointermove', move);
grip.classList.remove('is-dragging');
if (rows !== (Math.trunc(Number(moduleApi.instanceSettings.quickRows)) || 1)) moduleApi.saveInstanceSettings({ quickRows: rows });
};
grip.addEventListener('pointermove', move);
grip.addEventListener('pointerup', done, { once: true });
grip.addEventListener('pointercancel', done, { once: true });
});
grip.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
const rows = applyQuickRows(currentQuickRows() + (e.key === 'ArrowUp' ? 1 : -1));
moduleApi.saveInstanceSettings({ quickRows: rows });
});
}
async function showCompose() {
if (stopped) return;
buildCompose();
gate.hidden = true;
compose.hidden = false;
applyQuickRows(); // measurable only now that the compose area is shown
lockBtn.style.display = state?.pin?.enabled ? '' : 'none';
reportStatus();
if (mode === 'quick') await loadPresets();
else composeEls.input?.focus({ preventScroll: true });
tickLock();
}
/**
* The outcome of a send, as an icon beside the Send button: a check when
* it landed, a blocked sign when it did not, either gone after five
* seconds (or at the next attempt). The sentence behind it rides along as
* the tooltip — the foot is too small for words, the hover is not.
*/
const FEEDBACK_MS = 5000;
function feedback(kind, text = '') {
const f = composeEls?.feedback;
if (!f) return;
clearTimeout(feedback.timer);
f.textContent = '';
f.className = 'sm-feedback';
f.removeAttribute('title');
f.removeAttribute('aria-label');
f.removeAttribute('role');
if (!kind) return;
f.innerHTML = kind === 'ok' ? CHECK_SVG : BLOCKED_SVG; // our own SVG constants only
f.classList.add(kind === 'ok' ? 'is-ok' : 'is-error');
f.setAttribute('role', 'img');
if (text) {
f.title = text;
f.setAttribute('aria-label', text);
}
feedback.timer = setTimeout(() => {
if (composeEls?.feedback === f) feedback('');
}, FEEDBACK_MS);
}
const flash = (text) => feedback('ok', text);
const showError = (text) => feedback('error', text);
function touch(expiresAt) {
if (token && Number(expiresAt) > 0 && bus.unlock.token === token) bus.unlock.expiresAt = Number(expiresAt);
}
/** POST one message to the selected destination; true when it landed. */
async function postText(text, what) {
const { provider, destination } = resolveTarget();
if (!provider || !destination) {
showError(targetLine());
return false;
}
try {
const res = await moduleApi.fetch('/send', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ text, provider: provider.id, destination: destination.id }),
});
const body = await readJson(res);
if (res.status === 401) {
lock(body.error || 'Locked — enter the PIN', true);
return false;
}
if (!res.ok) {
showError(body.error || `Send failed (HTTP ${res.status})`);
return false;
}
touch(body.expiresAt);
const where = (body.role || provider.destinationRole) === 'identity'
? `to ${body.providerLabel || provider.label} as ${body.label || destination.label}`
: `to ${body.label || destination.label}`;
flash(`Sent ${what || ''}${where}`);
return true;
} catch {
showError('ProdDash unreachable — not sent');
return false;
}
}
async function sendText() {
if (busy || !composeEls?.input) return;
const text = composeEls.input.value.trim();
if (!text) return;
busy = true;
composeEls.sendBtn.disabled = true;
try {
if (await postText(text)) composeEls.input.value = '';
} finally {
busy = false;
if (composeEls?.sendBtn) {
composeEls.sendBtn.disabled = false;
composeEls.input?.focus({ preventScroll: true });
}
}
}
/** A quick emoji, as a message of its own. */
async function sendEmoji(q, btn) {
if (busy) return;
busy = true;
btn.disabled = true;
btn.classList.add('is-busy');
try {
if (await postText(q, `${q} `)) {
btn.classList.add('is-sent');
setTimeout(() => btn.classList.remove('is-sent'), 900);
}
} finally {
busy = false;
btn.disabled = false;
btn.classList.remove('is-busy');
composeEls?.input?.focus({ preventScroll: true });
}
}
async function loadPresets() {
if (!composeEls?.grid) return;
composeEls.grid.textContent = '';
composeEls.grid.appendChild(el('div', 'sm-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 : [];
touch(body.expiresAt);
renderPresets();
} catch (err) {
if (!composeEls?.grid) return;
composeEls.grid.textContent = '';
composeEls.grid.appendChild(el('div', 'sm-note is-error', `Could not load quick replies — ${err.message}`));
}
}
function renderPresets() {
if (!composeEls?.grid) return;
const grid = composeEls.grid;
grid.textContent = '';
if (!presets.length) {
grid.appendChild(el('div', 'sm-note', 'No quick replies yet — add some in Admin → Send Message → Presets'));
return;
}
for (const p of presets) {
const b = el('button', 'sm-quick');
b.type = 'button';
b.appendChild(el('span', 'sm-quick-label', p.label));
// A preset bound to its own destination says so — it ignores the selection.
if (p.target) b.appendChild(el('span', 'sm-quick-chan', p.target));
const check = el('span', 'sm-quick-check');
check.innerHTML = CHECK_SVG; // our own SVG constant
b.appendChild(check);
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 { provider, destination } = resolveTarget();
const res = await moduleApi.fetch('/quick', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ index, provider: provider?.id || '', destination: destination?.id || '' }),
});
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})`);
touch(body.expiresAt);
btn.classList.remove('is-busy');
btn.classList.add('is-sent');
flash(body.role === 'identity'
? `Sent to ${body.providerLabel || provider?.label || ''} as ${body.label || destination?.label || ''}`
: `Sent to ${body.label || destination?.label || ''}`);
setTimeout(() => {
if (btn.isConnected) 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 = '';
presets = null;
composeEls = null;
rowWatch?.disconnect();
rowWatch = null;
compose.textContent = '';
if (had && !fromServer) {
moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {});
}
if (had && bus.unlock.token === had) {
// every messaging tile on the page locks with this one
bus.unlock = { token: '', expiresAt: 0, reason: note || 'Locked' };
emit('unlock', bus.unlock);
}
gateNote = note || '';
gateError = '';
gateKind = ''; // rebuild the pad fresh
pinPad = null;
renderGate();
reportStatus();
}
function tickLock() {
if (!token) return;
const left = bus.unlock.expiresAt - Date.now();
if (left <= 0) return void lock('Locked after inactivity');
if (composeEls) {
const cd = composeEls.countdown;
if (left < 60000 && state?.pin?.enabled) {
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 { provider } = resolveTarget();
const own = state?.status?.state;
if (own === 'error' && !providers().length) return 'error';
const st = provider?.status?.state;
if (st === 'ok' || st === 'connecting' || st === 'error') return st;
return own === 'ok' || own === 'connecting' || own === 'error' ? own : 'connecting';
}
function statusMessage() {
const { provider } = resolveTarget();
if (provider && provider.status?.state !== 'ok') return provider.status?.message || `${provider.label}: connecting…`;
if (state?.status?.state === 'error' && !providers().length) return state.status.message;
return targetLine();
}
function reportStatus() {
if (stopped) return;
const lockWord = !state?.pin?.enabled ? 'PIN off' : token ? 'unlocked' : 'locked';
moduleApi.setStatus(statusState(), `${statusMessage()} · ${lockWord}`);
}
function applyState(s) {
if (stopped || !s || typeof s !== 'object') return;
state = s;
bus.quickEmojis = Array.isArray(s.quickEmojis) ? s.quickEmojis.slice() : [];
placeSendAsMenu();
publishTarget();
reportStatus();
if (token) {
// The PIN switched on without one set while unlocked: the server will
// refuse the next send; say so now instead of on failure.
if (s.pin?.enabled && !s.pin?.set) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls.line) composeEls.line.textContent = targetLine();
if (composeEls.input) composeEls.input.placeholder = placeholder();
lockBtn.style.display = s.pin?.enabled ? '' : 'none';
} 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 */ }
}
/**
* The stream (re)opened: the module may have restarted (an admin Apply,
* a ProdDash restart), which forgets every unlock. Ask rather than find
* out on the next send — and with the PIN off, take a fresh token at once.
*/
async function checkToken() {
if (!token || stopped) return;
try {
const res = await moduleApi.fetch('/verify', { headers: authHeaders() });
if (stopped || res.ok) return;
if (res.status === 401) lock('Locked — ProdDash restarted', true);
} catch { /* unreachable — the error handler says so */ }
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
loadState();
checkToken();
},
error() {
moduleApi.setStatus('error', 'ProdDash Send Message module unreachable — reconnecting…');
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* skip a bad frame */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting…');
token = pageToken(bus);
bus.senders.add(moduleApi.instanceId);
emit('senders', {});
// The other send tile is already up: its choice is the page's.
if (bus.target.provider) {
activeProvider = bus.target.provider;
if (bus.target.destination) chosen = { ...chosen, [bus.target.provider]: bus.target.destination };
persistChoice();
}
renderGate();
connect();
ticker = setInterval(tickLock, 1000);
},
stop() {
stopped = true;
clearInterval(ticker);
clearTimeout(feedback.timer);
clearTimeout(targetTimer);
offSelect();
offTranscripts();
offUnlock();
rowWatch?.disconnect();
stream?.close();
bus.senders.delete(moduleApi.instanceId);
emit('senders', {});
if (!bus.senders.size && bus.target.provider) {
// No send tile left: nothing is a target any more.
bus.target = { provider: '', destination: '', providerLabel: '', destinationLabel: '', identity: '' };
emit('target', bus.target);
}
root.innerHTML = ''; // the page's unlock outlives a remount; a reload asks again
},
};
}