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>
1190 lines
45 KiB
JavaScript
1190 lines
45 KiB
JavaScript
/* 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
|
||
},
|
||
};
|
||
}
|