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
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|