Send Message 1.0.0: one PIN, one place to send from; Slack 1.3.0 and ProdCom 1.4.0 become messaging providers
A new send-message module owns everything a dashboard sends: the PIN
(switchable off in admin), the unlock tokens, the Send message and Quick
replies tiles, the quick replies and quick emojis. It opens no upstream
connection of its own — it finds every module declaring
provides: ["messaging"] through the shell's module list and hands each
message to that module's POST /messaging/send over loopback, so Slack keeps
its one Slack connection and ProdCom its one ProdCom connection. Providers
check the browser's unlock token with GET /verify over loopback before
touching their upstream, so no browser on the network can post as the
location without having entered the PIN here.
Slack 1.3.0 is view-only plus reactions: the Sending group, PIN, send tiles
and quick replies are gone; it serves the messaging descriptor (channels as
recipients) and its reactions ride on the Send Message unlock. A layout's
old Send message / Quick replies tiles draw nothing until removed.
ProdCom 1.4.0 serves the descriptor as identities — only channels with
typingEnabled, since typed text goes out *as* a channel — and posts with
POST /api/v1/channels/{id}/send. Its tile is a click-to-target like Slack's
(accent stripe along the bottom), and typed entries carry a keyboard mark.
The tiles: click any Slack or ProdCom transcript to point sends there; a
Send as menu (only for platforms with identities, hidden otherwise) picks
the ProdCom channel to speak as; the choice is saved with the tile so named
layouts remember it. Wording follows the role — "Send message to #ops…" /
"Send message to ProdCom as FOH…". The PIN pad's dots ride on a real
numeric input so a phone's keyboard appears. Outcome is an icon beside
Send (check / blocked, five seconds, sentence as tooltip). A pill on the
box's bottom edge trades box height for rows of quick-emoji keys, a row
at a time; the single row scrolls sideways on a narrow tile with Send
pinned in place.
The module guide documents the provider contract and the page bus
(window.proddashMessaging + proddash:messaging:* events) the tiles share;
the ProdCom mock gained the send route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
3b81efcb51
commit
1274360ee6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "send-message",
|
||||
"name": "Send Message",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "PIN-protected sending to Slack, ProdCom and any other messaging module, from one tile",
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 2
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 3,
|
||||
"h": 3
|
||||
},
|
||||
"configGroups": {
|
||||
"PIN": {
|
||||
"toggle": "pinEnabled",
|
||||
"help": "Nothing leaves this machine until the PIN is entered on a Send message or Quick replies tile. One PIN unlocks every messaging tile on that dashboard page — Send message, Quick replies and Slack's reaction menu — until Auto-lock minutes pass without a send; a reload asks again. With the PIN switched off, the tiles send without asking. Sending stays refused while the PIN is on but not yet set."
|
||||
},
|
||||
"Presets": {
|
||||
"help": "Quick replies are one per line, Label | Message. Add | platform:destination to bind one — slack:#production or prodcom:FOH — and that reply always goes there, whichever transcript is selected. Quick emojis are sent as a message of their own with one press from the Send message tile."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"pinEnabled": {
|
||||
"type": "switch",
|
||||
"label": "PIN",
|
||||
"default": true,
|
||||
"group": "PIN"
|
||||
},
|
||||
"pin": {
|
||||
"type": "password",
|
||||
"label": "PIN",
|
||||
"default": "",
|
||||
"group": "PIN",
|
||||
"help": "4–8 digits; the pad unlocks by itself once they are all in."
|
||||
},
|
||||
"pinIdleMinutes": {
|
||||
"type": "number",
|
||||
"label": "Auto-lock (min)",
|
||||
"default": 10,
|
||||
"group": "PIN"
|
||||
},
|
||||
"quickReplies": {
|
||||
"type": "text",
|
||||
"label": "Quick replies",
|
||||
"default": "",
|
||||
"group": "Presets",
|
||||
"help": "One per line: Label | Message. Add | slack:#channel or | prodcom:Channel to bind one to a destination."
|
||||
},
|
||||
"quickEmojis": {
|
||||
"type": "string",
|
||||
"label": "Quick emojis",
|
||||
"default": "👍 🙏 ✅ 👀 🎉",
|
||||
"group": "Presets",
|
||||
"help": "Emoji separated by spaces, eight at most. Sent as plain text to whatever is selected."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Send Message — server part.
|
||||
*
|
||||
* One place a dashboard sends from, whatever the platform. The module owns
|
||||
* nothing upstream: it finds every module whose manifest says
|
||||
* "provides": ["messaging"] (Slack, ProdCom, whatever comes next) through
|
||||
* the shell's /api/modules over loopback, reads each one's destinations
|
||||
* from its GET /messaging, and hands a message to the provider's
|
||||
* POST /messaging/send — so Slack keeps its one Slack connection, ProdCom
|
||||
* its one ProdCom connection, and this module never opens a second one.
|
||||
*
|
||||
* It also owns the PIN. A browser unlocks here and gets a token; every
|
||||
* gated action anywhere — a send through this module, a reaction in a
|
||||
* Slack transcript — carries that token, and the provider checks it with
|
||||
* GET /verify over loopback before touching its upstream. No provider has
|
||||
* a PIN of its own, and no browser on the network can reach a provider's
|
||||
* send route without having entered the PIN here.
|
||||
*
|
||||
* GET /state providers (destinations, identity, status), pin flags, quick emojis
|
||||
* GET /stream SSE `state` events (change-detected, heartbeated)
|
||||
* POST /unlock { pin } → { token, expiresAt } (constant-time, lockout)
|
||||
* with the PIN switched off: {} → a token, no questions
|
||||
* POST /lock Bearer → forget that token
|
||||
* GET /verify Bearer → { ok, expiresAt }, 401 otherwise — for providers
|
||||
* GET /quick Bearer → preset labels, indexes and bindings (never the text)
|
||||
* POST /send Bearer, { provider, destination, text }
|
||||
* POST /quick Bearer, { index, provider, destination }
|
||||
*
|
||||
* The provider contract is in docs/MODULE-GUIDE.md ("Offering messaging").
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const SELF_ID = 'send-message';
|
||||
const CAPABILITY = 'messaging';
|
||||
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** Re-read /api/modules: a newly installed provider shows up without a restart. */
|
||||
const DISCOVER_MS = 30000;
|
||||
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
|
||||
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
|
||||
/** Each provider's /messaging, re-read this often while a tile is watching… */
|
||||
const DESCRIBE_MS = 4000;
|
||||
/** …and this slowly with nobody looking. */
|
||||
const DESCRIBE_IDLE_MS = 30000;
|
||||
/** A tile counts as watching for this long after its last /state or /stream. */
|
||||
const IDLE_AFTER_MS = 30000;
|
||||
const LOOPBACK_TIMEOUT_MS = 5000;
|
||||
/** A provider's send may itself wait on its upstream. */
|
||||
const SEND_TIMEOUT_MS = 15000;
|
||||
const PIN_RE = /^\d{4,8}$/;
|
||||
const PIN_MAX_WRONG = 5;
|
||||
const PIN_LOCKOUT_MS = 30000;
|
||||
/** With the PIN off, a token is minted on request and simply lasts. */
|
||||
const NO_PIN_TOKEN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const SEND_MIN_INTERVAL_MS = 1000;
|
||||
const DEFAULT_MAX_CHARS = 4000;
|
||||
const BODY_LIMIT_BYTES = 64 * 1024;
|
||||
const UNLOCK_SWEEP_MS = 30000;
|
||||
|
||||
/** Set by init(), read by routes() — rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
/* ── small helpers ──────────────────────────────────────────────────── */
|
||||
|
||||
function sendJson(res, code, body) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function readJson(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = '';
|
||||
let size = 0;
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length;
|
||||
if (size > BODY_LIMIT_BYTES) {
|
||||
reject(new Error('Request body too large.'));
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!data.trim()) return resolve({});
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
resolve(body && typeof body === 'object' ? body : {});
|
||||
} catch {
|
||||
reject(new Error('Malformed JSON.'));
|
||||
}
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Route handlers are async — the shell only catches synchronous throws. */
|
||||
function guard(fn) {
|
||||
return (req, res) => {
|
||||
Promise.resolve()
|
||||
.then(() => fn(req, res))
|
||||
.catch((err) => {
|
||||
if (!res.headersSent) sendJson(res, 500, { error: err?.message || String(err) });
|
||||
else try { res.end(); } catch { /* gone */ }
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+([A-Za-z0-9]+)\s*$/.exec(String(req.headers.authorization || ''));
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function clientIp(req) {
|
||||
return String(req.socket?.remoteAddress || '');
|
||||
}
|
||||
|
||||
/** Constant-time PIN compare (lengths are folded in after the timing-safe step). */
|
||||
function pinMatches(given, expected) {
|
||||
const a = Buffer.alloc(16);
|
||||
const b = Buffer.alloc(16);
|
||||
a.write(String(given).slice(0, 16));
|
||||
b.write(String(expected).slice(0, 16));
|
||||
const same = crypto.timingSafeEqual(a, b);
|
||||
return same && given.length === expected.length;
|
||||
}
|
||||
|
||||
function errorText(err) {
|
||||
return String(err?.cause?.code || err?.code || err?.message || err || 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* "Label | Message | platform:destination" per line. A line with no "|" is
|
||||
* both its label and its message. The binding (optional) names a provider
|
||||
* — its module id, a prefix of it ("prodcom"), or its label ("Slack") — and
|
||||
* one of its destinations by id or label ("#production", "FOH"); a reply
|
||||
* that carries one always goes there, whatever is selected.
|
||||
*/
|
||||
function parseQuickReplies(text) {
|
||||
const out = [];
|
||||
for (const rawLine of String(text || '').split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const parts = line.split('|').map((p) => p.trim());
|
||||
const label = parts[0];
|
||||
const message = parts.length > 1 && parts[1] ? parts[1] : label;
|
||||
const binding = parts.length > 2 ? parts[2] : '';
|
||||
if (!label || !message) continue;
|
||||
out.push({ label, message, binding });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "👍 🙏 ✅" → ['👍', '🙏', '✅'] — plain text tokens, eight at most. */
|
||||
function parseQuickEmojis(text) {
|
||||
return String(text || '').split(/\s+/).filter(Boolean).slice(0, 8);
|
||||
}
|
||||
|
||||
/** A provider's GET /messaging answer, as the tiles and the send route use it. */
|
||||
function normalizeDescriptor(id, fallbackLabel, body) {
|
||||
const b = body && typeof body === 'object' ? body : {};
|
||||
const st = b.status && typeof b.status === 'object' ? b.status : {};
|
||||
const state = st.state === 'ok' || st.state === 'error' ? st.state : 'connecting';
|
||||
const destinations = [];
|
||||
const seen = new Set();
|
||||
for (const d of Array.isArray(b.destinations) ? b.destinations : []) {
|
||||
if (!d || typeof d !== 'object') continue;
|
||||
const did = String(d.id ?? '').trim();
|
||||
if (!did || seen.has(did)) continue;
|
||||
seen.add(did);
|
||||
destinations.push({ id: did, label: String(d.label || did), color: typeof d.color === 'string' ? d.color : '' });
|
||||
}
|
||||
const maxChars = Math.trunc(Number(b.maxChars));
|
||||
return {
|
||||
id,
|
||||
label: String(b.label || fallbackLabel || id),
|
||||
identity: String(b.identity || ''),
|
||||
// recipient: a message goes *to* a destination (a Slack channel);
|
||||
// identity: it goes out *as* one (a ProdCom channel) — the Send as menu
|
||||
// lists only the latter, and the wording follows
|
||||
destinationRole: b.destinationRole === 'identity' ? 'identity' : 'recipient',
|
||||
status: { state, message: String(st.message || '') },
|
||||
destinations,
|
||||
// what to say when the list is empty, if the provider knows why
|
||||
emptyMessage: String(b.emptyMessage || ''),
|
||||
maxChars: maxChars > 0 ? Math.min(DEFAULT_MAX_CHARS * 10, maxChars) : DEFAULT_MAX_CHARS,
|
||||
};
|
||||
}
|
||||
|
||||
/** "Slack → #ops" / "ProdCom as FOH" — how a provider + destination pair reads. */
|
||||
function pairLabel(descriptor, destination) {
|
||||
return descriptor.destinationRole === 'identity'
|
||||
? `${descriptor.label} as ${destination.label}`
|
||||
: `${descriptor.label} → ${destination.label}`;
|
||||
}
|
||||
|
||||
/* ── the module ─────────────────────────────────────────────────────── */
|
||||
|
||||
module.exports = {
|
||||
init({ config, log, shell }) {
|
||||
/* ── config ── */
|
||||
const shellPort = Number(shell && shell.port) || 0;
|
||||
const pinEnabled = config.pinEnabled !== false;
|
||||
const pin = String(config.pin || '').trim();
|
||||
const pinOk = PIN_RE.test(pin);
|
||||
const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000;
|
||||
const quickReplies = parseQuickReplies(config.quickReplies);
|
||||
const quickEmojis = parseQuickEmojis(config.quickEmojis);
|
||||
|
||||
/* ── state (all inside this closure so a re-init starts clean) ── */
|
||||
let stopped = false;
|
||||
const providers = new Map(); // module id → { id, label, descriptor | null, error, at }
|
||||
let listError = ''; // the last /api/modules failure ('' while fine)
|
||||
let listedAt = 0;
|
||||
let discoverTimer = null;
|
||||
let describeTimer = null;
|
||||
let describing = null; // one descriptor sweep in flight
|
||||
const streams = new Set();
|
||||
const unlocks = new Map(); // token → { expiresAt, lastSendAt, ip }
|
||||
const pinAttempts = new Map(); // ip → { count, lockedUntil }
|
||||
let lastInterest = Date.now();
|
||||
let lastSig = '';
|
||||
|
||||
const loopback = (path) => `http://127.0.0.1:${shellPort}${path}`;
|
||||
|
||||
function watched() {
|
||||
return streams.size > 0 || Date.now() - lastInterest < IDLE_AFTER_MS;
|
||||
}
|
||||
|
||||
/* ── discovery: who offers messaging ── */
|
||||
async function discover() {
|
||||
discoverTimer = null;
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
|
||||
const res = await fetch(loopback('/api/modules'), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
if (stopped) return;
|
||||
const mods = (Array.isArray(body?.modules) ? body.modules : [])
|
||||
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes(CAPABILITY));
|
||||
const keep = new Set();
|
||||
for (const m of mods) {
|
||||
keep.add(m.id);
|
||||
const have = providers.get(m.id);
|
||||
if (have) have.label = String(m.name || m.id);
|
||||
else {
|
||||
providers.set(m.id, { id: m.id, label: String(m.name || m.id), descriptor: null, error: '', at: 0 });
|
||||
log(`module "${m.id}" offers messaging`);
|
||||
}
|
||||
}
|
||||
for (const id of [...providers.keys()]) {
|
||||
if (keep.has(id)) continue;
|
||||
providers.delete(id);
|
||||
log(`module "${id}" no longer offers messaging`);
|
||||
}
|
||||
listError = '';
|
||||
listedAt = Date.now();
|
||||
await describeAll();
|
||||
} catch (err) {
|
||||
listError = errorText(err);
|
||||
broadcastIfChanged();
|
||||
}
|
||||
if (!stopped) {
|
||||
discoverTimer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
|
||||
discoverTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Read one provider's /messaging; a failure is kept on the entry, not thrown. */
|
||||
async function describe(p) {
|
||||
try {
|
||||
const res = await fetch(loopback(`/api/modules/${encodeURIComponent(p.id)}/messaging`), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(LOOPBACK_TIMEOUT_MS),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
// Disabled or uninstalled, most likely: let the module list decide
|
||||
// now rather than at the next 30 s pass, so the tiles drop it at once.
|
||||
if (!discoverTimer && !stopped) {
|
||||
discoverTimer = setTimeout(discover, 250);
|
||||
discoverTimer.unref?.();
|
||||
}
|
||||
throw new Error('not answering (disabled or restarting)');
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
if (stopped) return;
|
||||
p.descriptor = normalizeDescriptor(p.id, p.label, body);
|
||||
if (p.error) log(`${p.label}: describing itself again`);
|
||||
p.error = '';
|
||||
p.at = Date.now();
|
||||
} catch (err) {
|
||||
const text = errorText(err);
|
||||
if (p.error !== text) log(`${p.label}: cannot read its destinations — ${text}`);
|
||||
p.error = text;
|
||||
}
|
||||
}
|
||||
|
||||
function describeAll() {
|
||||
if (!describing) {
|
||||
describing = Promise.all([...providers.values()].map(describe))
|
||||
.then(() => broadcastIfChanged())
|
||||
.finally(() => { describing = null; });
|
||||
}
|
||||
return describing;
|
||||
}
|
||||
|
||||
function scheduleDescribe(delay = null) {
|
||||
if (stopped) return;
|
||||
clearTimeout(describeTimer);
|
||||
describeTimer = setTimeout(async () => {
|
||||
describeTimer = null;
|
||||
await describeAll();
|
||||
scheduleDescribe();
|
||||
}, delay ?? (watched() ? DESCRIBE_MS : DESCRIBE_IDLE_MS));
|
||||
describeTimer.unref?.();
|
||||
}
|
||||
|
||||
/** Somebody is looking: catch up at once if the list is stale or empty. */
|
||||
function wake() {
|
||||
lastInterest = Date.now();
|
||||
if (stopped) return;
|
||||
if ((!providers.size || listError) && Date.now() - listedAt > DISCOVER_RETRY_MS && !discoverTimer) {
|
||||
discoverTimer = setTimeout(discover, 50);
|
||||
discoverTimer.unref?.();
|
||||
} else if (describeTimer) {
|
||||
// The idle cadence was in force; move to the watched one now.
|
||||
scheduleDescribe(DESCRIBE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── what the tiles see ── */
|
||||
function providerView(p) {
|
||||
const d = p.descriptor;
|
||||
if (!d) {
|
||||
return {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
identity: '',
|
||||
destinationRole: 'recipient',
|
||||
status: { state: p.error ? 'error' : 'connecting', message: p.error ? `${p.label} module ${p.error}` : 'Reading destinations…' },
|
||||
destinations: [],
|
||||
emptyMessage: '',
|
||||
maxChars: DEFAULT_MAX_CHARS,
|
||||
error: p.error,
|
||||
};
|
||||
}
|
||||
return { ...d, error: p.error, status: p.error ? { state: 'error', message: `${p.label} module ${p.error}` } : d.status };
|
||||
}
|
||||
|
||||
function health() {
|
||||
if (!shellPort) return { status: 'error', message: 'This module needs ProdDash 1.4.0 or newer' };
|
||||
const pinLine = !pinEnabled ? 'PIN off' : pinOk ? 'PIN set' : 'PIN on but not set — sending refused';
|
||||
if (listError) return { status: listedAt ? 'connecting' : 'error', message: `Cannot read the module list (${listError}) · ${pinLine}` };
|
||||
if (!listedAt) return { status: 'connecting', message: 'Looking for messaging modules…' };
|
||||
const list = [...providers.values()];
|
||||
if (!list.length) return { status: 'error', message: `No module offers messaging — install Slack or ProdCom · ${pinLine}` };
|
||||
const parts = list.map((p) => {
|
||||
const v = providerView(p);
|
||||
if (v.error) return `${v.label}: ${v.error}`;
|
||||
const n = v.destinations.length;
|
||||
const who = v.identity ? ` as ${v.identity}` : '';
|
||||
// a provider that is up but unhappy says why, so the admin line does too
|
||||
const why = v.status.state !== 'ok' && v.status.message ? ` — ${v.status.message}`
|
||||
: !n && v.emptyMessage ? ` — ${v.emptyMessage}` : '';
|
||||
const noun = v.destinationRole === 'identity' ? 'identit' + (n === 1 ? 'y' : 'ies') : 'destination' + (n === 1 ? '' : 's');
|
||||
return `${v.label}${who} (${n} ${noun})${why}`;
|
||||
});
|
||||
const bad = list.some((p) => p.error || p.descriptor?.status.state === 'error');
|
||||
const pending = list.some((p) => !p.descriptor);
|
||||
return {
|
||||
status: pinEnabled && !pinOk ? 'error' : bad ? 'error' : pending ? 'connecting' : 'ok',
|
||||
message: `${parts.join(' · ')} · ${pinLine}`,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const h = health();
|
||||
return {
|
||||
providers: [...providers.values()].map(providerView),
|
||||
pin: {
|
||||
enabled: pinEnabled,
|
||||
set: pinOk,
|
||||
// the pad submits by itself once this many digits are in (never the PIN)
|
||||
length: pinEnabled && pinOk ? pin.length : 0,
|
||||
idleMinutes: idleMs / 60000,
|
||||
},
|
||||
quickEmojis,
|
||||
quickCount: quickReplies.length,
|
||||
status: { state: h.status, message: h.message },
|
||||
};
|
||||
}
|
||||
|
||||
function broadcastIfChanged() {
|
||||
const snap = snapshot();
|
||||
const sig = JSON.stringify(snap);
|
||||
if (sig === lastSig) return;
|
||||
lastSig = sig;
|
||||
if (!streams.size) return;
|
||||
const payload = `event: state\ndata: ${JSON.stringify(snap)}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the PIN and its unlock tokens ── */
|
||||
function sweepUnlocks() {
|
||||
const now = Date.now();
|
||||
for (const [t, u] of unlocks) if (u.expiresAt <= now) unlocks.delete(t);
|
||||
for (const [ip, a] of pinAttempts) if (a.lockedUntil && a.lockedUntil <= now && !a.count) pinAttempts.delete(ip);
|
||||
}
|
||||
|
||||
/** The unlock behind a token, or null; a hit slides its idle expiry along. */
|
||||
function verify(token) {
|
||||
const u = token ? unlocks.get(token) : null;
|
||||
if (!u) return null;
|
||||
const now = Date.now();
|
||||
if (u.expiresAt <= now) {
|
||||
unlocks.delete(token);
|
||||
return null;
|
||||
}
|
||||
if (pinEnabled) u.expiresAt = now + idleMs;
|
||||
return { token, ...u };
|
||||
}
|
||||
|
||||
/** Everything a send needs before anyone is asked; answers the refusal itself. */
|
||||
function sendGate(req, res) {
|
||||
if (pinEnabled && !pinOk) {
|
||||
sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
|
||||
return null;
|
||||
}
|
||||
const u = verify(bearer(req));
|
||||
if (!u) {
|
||||
sendJson(res, 401, { error: 'Locked — enter the PIN', code: 'locked' });
|
||||
return null;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
|
||||
function unlock(req, res, given) {
|
||||
const ip = clientIp(req);
|
||||
const now = Date.now();
|
||||
if (!pinEnabled) {
|
||||
const t = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = now + NO_PIN_TOKEN_MS;
|
||||
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
|
||||
return sendJson(res, 200, { token: t, expiresAt, idleMs: NO_PIN_TOKEN_MS, pinEnabled: false });
|
||||
}
|
||||
if (!pinOk) return sendJson(res, 403, { error: 'Set a PIN in Admin → Send Message, or switch the PIN off', code: 'no_pin' });
|
||||
let a = pinAttempts.get(ip);
|
||||
if (a && a.lockedUntil > now) {
|
||||
const secs = Math.ceil((a.lockedUntil - now) / 1000);
|
||||
return sendJson(res, 429, { error: `Too many wrong PINs — locked for ${secs} s`, code: 'lockout', retryAfter: secs });
|
||||
}
|
||||
if (a && a.lockedUntil && a.lockedUntil <= now) a = null; // lockout served — clean slate
|
||||
const str = String(given ?? '');
|
||||
if (!PIN_RE.test(str) || !pinMatches(str, pin)) {
|
||||
const count = (a ? a.count : 0) + 1;
|
||||
if (count >= PIN_MAX_WRONG) {
|
||||
pinAttempts.set(ip, { count: 0, lockedUntil: now + PIN_LOCKOUT_MS });
|
||||
log(`wrong PIN ×${PIN_MAX_WRONG} from ${ip} — locked for ${PIN_LOCKOUT_MS / 1000} s`);
|
||||
return sendJson(res, 429, { error: `Wrong PIN — locked for ${PIN_LOCKOUT_MS / 1000} s`, code: 'lockout', retryAfter: PIN_LOCKOUT_MS / 1000 });
|
||||
}
|
||||
pinAttempts.set(ip, { count, lockedUntil: 0 });
|
||||
return sendJson(res, 401, { error: 'Wrong PIN', code: 'wrong_pin', triesLeft: PIN_MAX_WRONG - count });
|
||||
}
|
||||
pinAttempts.delete(ip);
|
||||
const t = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = now + idleMs;
|
||||
unlocks.set(t, { expiresAt, lastSendAt: 0, ip });
|
||||
log(`unlocked from ${ip} (auto-lock after ${idleMs / 60000} min idle)`);
|
||||
return sendJson(res, 200, { token: t, expiresAt, idleMs, pinEnabled: true });
|
||||
}
|
||||
|
||||
/* ── destinations and bindings ── */
|
||||
function findProvider(ref) {
|
||||
const s = String(ref ?? '').trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
for (const p of providers.values()) if (p.id.toLowerCase() === s) return p;
|
||||
for (const p of providers.values()) {
|
||||
const label = (p.descriptor?.label || p.label).toLowerCase();
|
||||
if (label === s || p.id.toLowerCase().startsWith(s) || label.replace(/\s+/g, '') === s.replace(/\s+/g, '')) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDestination(p, ref) {
|
||||
const s = String(ref ?? '').trim();
|
||||
if (!p?.descriptor || !s) return null;
|
||||
const list = p.descriptor.destinations;
|
||||
const exact = list.find((d) => d.id === s);
|
||||
if (exact) return exact;
|
||||
const name = s.replace(/^#/, '').toLowerCase();
|
||||
return list.find((d) => d.label.replace(/^#/, '').toLowerCase() === name) || null;
|
||||
}
|
||||
|
||||
/** A quick reply's "platform:destination" → { provider, destination } or { error }. */
|
||||
function resolveBinding(binding) {
|
||||
const s = String(binding || '').trim();
|
||||
if (!s) return null;
|
||||
const i = s.indexOf(':');
|
||||
if (i > 0) {
|
||||
const p = findProvider(s.slice(0, i));
|
||||
if (!p) return { error: `No messaging module called "${s.slice(0, i).trim()}" for this quick reply` };
|
||||
if (!p.descriptor) return { error: `${p.label} hasn't listed its destinations yet — try again in a moment` };
|
||||
const d = findDestination(p, s.slice(i + 1));
|
||||
if (!d) return { error: `${p.descriptor.label} has no destination "${s.slice(i + 1).trim()}" — check the quick reply in Admin → Send Message` };
|
||||
return { provider: p, destination: d };
|
||||
}
|
||||
for (const p of providers.values()) {
|
||||
const d = findDestination(p, s);
|
||||
if (d) return { provider: p, destination: d };
|
||||
}
|
||||
return { error: `No destination "${s}" on any messaging module — check the quick reply in Admin → Send Message` };
|
||||
}
|
||||
|
||||
/** The label a tile shows for a binding — resolved when it can be, the raw text otherwise. */
|
||||
function bindingLabel(binding) {
|
||||
const r = resolveBinding(binding);
|
||||
if (!r) return '';
|
||||
if (r.error) return String(binding).trim();
|
||||
return pairLabel(r.provider.descriptor, r.destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider and destination a browser asked for. Both must be ones
|
||||
* the module has listed — a PIN-unlocked browser chooses among what the
|
||||
* providers offer, never an arbitrary id.
|
||||
*/
|
||||
async function chooseTarget(providerRef, destinationRef) {
|
||||
const p = findProvider(providerRef);
|
||||
if (!p) {
|
||||
const s = String(providerRef ?? '').trim();
|
||||
return { error: s ? `${s} isn't offering messaging right now — is the module enabled?` : 'Pick where to send first' };
|
||||
}
|
||||
if (!p.descriptor) await describe(p);
|
||||
if (!p.descriptor) return { error: `${p.label} module ${p.error || 'is not answering'}` };
|
||||
let d = findDestination(p, destinationRef);
|
||||
if (!d && String(destinationRef ?? '').trim()) {
|
||||
await describe(p); // a channel added a moment ago
|
||||
d = findDestination(p, destinationRef);
|
||||
}
|
||||
if (!d) {
|
||||
const s = String(destinationRef ?? '').trim();
|
||||
return { error: s ? `${p.descriptor.label} has no destination "${s}" — pick another in Send as` : `Pick a ${p.descriptor.label} destination in Send as` };
|
||||
}
|
||||
return { provider: p, destination: d };
|
||||
}
|
||||
|
||||
/* ── delivery: hand the message to the provider over loopback ── */
|
||||
async function deliver(req, res, unlock, { provider, destination, text, what }) {
|
||||
const now = Date.now();
|
||||
if (now - unlock.lastSendAt < SEND_MIN_INTERVAL_MS) {
|
||||
return sendJson(res, 429, { error: 'One message per second — try again', code: 'too_fast' });
|
||||
}
|
||||
const u = unlocks.get(unlock.token);
|
||||
if (u) u.lastSendAt = now;
|
||||
const max = provider.descriptor?.maxChars || DEFAULT_MAX_CHARS;
|
||||
if (text.length > max) return sendJson(res, 400, { error: `Too long for ${provider.descriptor.label} — ${max} characters at most`, code: 'too_long' });
|
||||
const label = provider.descriptor.label;
|
||||
let answer;
|
||||
try {
|
||||
const r = await fetch(loopback(`/api/modules/${encodeURIComponent(provider.id)}/messaging/send`), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${unlock.token}`,
|
||||
},
|
||||
body: JSON.stringify({ destination: destination.id, text }),
|
||||
signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
|
||||
});
|
||||
let body = {};
|
||||
try { body = await r.json(); } catch { /* a non-JSON refusal */ }
|
||||
answer = { status: r.status, body: body && typeof body === 'object' ? body : {} };
|
||||
} catch (err) {
|
||||
const message = `${label} module unreachable (${errorText(err)}) — not sent`;
|
||||
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
|
||||
return sendJson(res, 502, { error: message, code: 'provider_unreachable' });
|
||||
}
|
||||
if (answer.status === 404) {
|
||||
return sendJson(res, 502, { error: `${label} module isn't answering (disabled or restarting) — not sent`, code: 'provider_gone' });
|
||||
}
|
||||
if (answer.status < 200 || answer.status >= 300 || answer.body.ok === false) {
|
||||
const message = String(answer.body.error || `${label} answered HTTP ${answer.status}`);
|
||||
log(`${what} → ${pairLabel(provider.descriptor, destination)} failed: ${message}`);
|
||||
const status = answer.status === 401 ? 401 : [400, 404, 429].includes(answer.status) ? answer.status : 502;
|
||||
return sendJson(res, status, { error: message, code: answer.body.code || `http_${answer.status}` });
|
||||
}
|
||||
const preview = text.replace(/\s+/g, ' ').slice(0, 60);
|
||||
log(`${what} → ${pairLabel(provider.descriptor, destination)}: "${preview}${text.length > 60 ? '…' : ''}"`);
|
||||
scheduleDescribe(500); // the provider may have something new to say about itself
|
||||
return sendJson(res, 200, {
|
||||
ok: true,
|
||||
provider: provider.id,
|
||||
providerLabel: label,
|
||||
destination: destination.id,
|
||||
label: String(answer.body.label || destination.label),
|
||||
role: provider.descriptor.destinationRole,
|
||||
expiresAt: u ? u.expiresAt : unlock.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── go ── */
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
const sweeper = setInterval(sweepUnlocks, UNLOCK_SWEEP_MS);
|
||||
sweeper.unref?.();
|
||||
|
||||
if (!shellPort) log('the shell did not say which port it serves on — this module needs ProdDash 1.4.0 or newer');
|
||||
else {
|
||||
if (pinEnabled && !pinOk) log('the PIN is on but not set (4–8 digits) — send tiles stay locked until it is, or until the PIN is switched off');
|
||||
if (!pinEnabled) log('PIN off — send tiles send without asking');
|
||||
discoverTimer = setTimeout(discover, DISCOVER_FIRST_MS);
|
||||
discoverTimer.unref?.();
|
||||
scheduleDescribe();
|
||||
}
|
||||
|
||||
current = {
|
||||
streams,
|
||||
snapshot,
|
||||
health,
|
||||
wake,
|
||||
verify,
|
||||
sendGate,
|
||||
unlock,
|
||||
chooseTarget,
|
||||
deliver,
|
||||
resolveBinding,
|
||||
bindingLabel,
|
||||
quickReplies,
|
||||
lock(req, res) {
|
||||
const t = bearer(req);
|
||||
if (t) unlocks.delete(t);
|
||||
return sendJson(res, 200, { ok: true });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(discoverTimer);
|
||||
clearTimeout(describeTimer);
|
||||
clearInterval(heartbeat);
|
||||
clearInterval(sweeper);
|
||||
unlocks.clear(); // an admin Apply locks every send tile
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health,
|
||||
};
|
||||
},
|
||||
|
||||
/** The picker: the two send surfaces, one of each per dashboard. */
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'send',
|
||||
name: 'Send message',
|
||||
description: 'A message to the transcript clicked last, on any platform',
|
||||
single: true,
|
||||
defaultSize: { w: 3, h: 3 },
|
||||
minSize: { w: 2, h: 2 },
|
||||
settings: { view: 'send' },
|
||||
instanceSchema: {
|
||||
showEmojis: { type: 'switch', label: 'Quick emojis', default: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'quick',
|
||||
name: 'Quick replies',
|
||||
description: 'Preset buttons, one press each',
|
||||
single: true,
|
||||
defaultSize: { w: 3, h: 2 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
settings: { view: 'quick' },
|
||||
instanceSchema: {},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes() {
|
||||
const unmounted = (res) => sendJson(res, 503, { error: 'Send Message module is restarting — try again' });
|
||||
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
current.wake();
|
||||
sendJson(res, 200, current.snapshot());
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
if (!current) {
|
||||
res.write(`event: state\ndata: ${JSON.stringify({ providers: [], pin: { enabled: true, set: false, length: 0 }, quickEmojis: [], status: { state: 'connecting', message: 'Send Message module is restarting…' } })}\n\n`);
|
||||
return void res.end();
|
||||
}
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current.snapshot())}\n\n`);
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
current.streams.add(res);
|
||||
current.wake();
|
||||
req.on('close', () => current && current.streams.delete(res));
|
||||
},
|
||||
|
||||
'POST /unlock': guard(async (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
let body;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
current.unlock(req, res, body.pin);
|
||||
}),
|
||||
|
||||
'POST /lock': (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
current.lock(req, res);
|
||||
},
|
||||
|
||||
// Providers ask this over loopback before an upstream action: is this unlock good?
|
||||
'GET /verify': (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const u = current.verify(bearer(req));
|
||||
if (!u) return sendJson(res, 401, { ok: false, error: 'Locked — enter the PIN', code: 'locked' });
|
||||
sendJson(res, 200, { ok: true, expiresAt: u.expiresAt });
|
||||
},
|
||||
|
||||
// Labels, indexes and bindings only — the message text never leaves the server.
|
||||
'GET /quick': (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const unlock = current.sendGate(req, res);
|
||||
if (!unlock) return;
|
||||
sendJson(res, 200, {
|
||||
presets: current.quickReplies.map((q, index) => ({ index, label: q.label, target: current.bindingLabel(q.binding) })),
|
||||
expiresAt: unlock.expiresAt,
|
||||
});
|
||||
},
|
||||
|
||||
'POST /send': guard(async (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const unlock = current.sendGate(req, res);
|
||||
if (!unlock) return;
|
||||
let body;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
const text = String(body.text ?? '').replace(/\r\n/g, '\n').trim();
|
||||
if (!text) return sendJson(res, 400, { error: 'Nothing to send' });
|
||||
const target = await current.chooseTarget(body.provider, body.destination);
|
||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
|
||||
await current.deliver(req, res, unlock, { ...target, text, what: 'sent' });
|
||||
}),
|
||||
|
||||
'POST /quick': guard(async (req, res) => {
|
||||
if (!current) return unmounted(res);
|
||||
const unlock = current.sendGate(req, res);
|
||||
if (!unlock) return;
|
||||
let body;
|
||||
try {
|
||||
body = await readJson(req);
|
||||
} catch (err) {
|
||||
return sendJson(res, 400, { error: err.message });
|
||||
}
|
||||
const index = Math.trunc(Number(body.index));
|
||||
const preset = Number.isInteger(index) ? current.quickReplies[index] : null;
|
||||
if (!preset) return sendJson(res, 404, { error: 'No such quick reply — the list may have changed in Admin' });
|
||||
// A bound reply ignores the selection; an unbound one needs it.
|
||||
let target = current.resolveBinding(preset.binding);
|
||||
if (!target) target = await current.chooseTarget(body.provider, body.destination);
|
||||
if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_target' });
|
||||
await current.deliver(req, res, unlock, { ...target, text: preset.message, what: `quick reply "${preset.label}"` });
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
/* Send Message module — scoped to its tiles, shell variables only.
|
||||
The gate (PIN pad), the compose box and the preset grid were the Slack
|
||||
module's send surfaces until 1.3.0; they live here now, for every platform. */
|
||||
|
||||
[data-module="send-message"] .sm-wrap {
|
||||
--sm-size: 14px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: size;
|
||||
font-size: var(--sm-size);
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="send-message"] .sm-wrap:focus { outline: none; }
|
||||
[data-module="send-message"] [hidden] { display: none !important; }
|
||||
|
||||
/* ── the gate ── */
|
||||
|
||||
[data-module="send-message"] .sm-gate,
|
||||
[data-module="send-message"] .sm-compose {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 6px 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[data-module="send-message"] .sm-note {
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
[data-module="send-message"] .sm-note.is-error { color: var(--danger); }
|
||||
|
||||
/* The pad never shrinks below its content (min-height stays `auto` on it
|
||||
and on the key grid) — a shrunken grid would spill its rows under the
|
||||
message line, which then swallows the taps. A tile too short for it
|
||||
scrolls instead (.sm-gate has overflow: auto). */
|
||||
[data-module="send-message"] .sm-pad {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 340px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
[data-module="send-message"] .sm-pad.sm-shake { animation: sm-shake 0.4s ease; }
|
||||
@keyframes sm-shake {
|
||||
10%, 90% { transform: translateX(-2px); }
|
||||
20%, 80% { transform: translateX(3px); }
|
||||
30%, 50%, 70% { transform: translateX(-5px); }
|
||||
40%, 60% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
/* one line: "Enter PIN · Slack · as Apollo Beach → #production" */
|
||||
[data-module="send-message"] .sm-pad-head {
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="send-message"] .sm-pad-head b { font-weight: 700; color: var(--text); }
|
||||
|
||||
/* The dots ride on a real numeric input: tapping them focuses it, which is
|
||||
what raises a phone's keyboard; on a desktop the keyboard types into it
|
||||
directly. The input itself is invisible — the dots are its display. */
|
||||
[data-module="send-message"] .sm-field {
|
||||
flex: none;
|
||||
position: relative;
|
||||
height: 24px;
|
||||
cursor: text;
|
||||
}
|
||||
[data-module="send-message"] .sm-dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-module="send-message"] .sm-dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--muted);
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
[data-module="send-message"] .sm-dot.on { background: var(--accent); border-color: var(--accent); }
|
||||
[data-module="send-message"] .sm-pin-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
[data-module="send-message"] .sm-pin-input::selection { background: transparent; }
|
||||
[data-module="send-message"] .sm-pin-input:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); }
|
||||
|
||||
[data-module="send-message"] .sm-keys {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(4, minmax(24px, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
[data-module="send-message"] .sm-key {
|
||||
min-width: 0;
|
||||
min-height: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: clamp(13px, 9cqh, 26px);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
[data-module="send-message"] .sm-key:hover { background: var(--border); }
|
||||
[data-module="send-message"] .sm-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||
[data-module="send-message"] .sm-key svg { width: 1.1em; height: 1.1em; }
|
||||
[data-module="send-message"] .sm-key-back { color: var(--muted); }
|
||||
[data-module="send-message"] .sm-key-blank { visibility: hidden; }
|
||||
[data-module="send-message"] .sm-pad.is-busy .sm-keys { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
[data-module="send-message"] .sm-pad-msg {
|
||||
flex: none;
|
||||
min-height: 1.1em;
|
||||
text-align: center;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.1;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="send-message"] .sm-pad-msg.is-error { color: var(--danger); font-weight: 600; }
|
||||
|
||||
/* A tile too short for the keypad (the smallest Send message / Quick replies
|
||||
tiles) shows the dots and a hint instead — the dots still take a tap and
|
||||
the keyboard, and a taller tile brings the keys back. .sm-wrap is the size
|
||||
container the height is measured against. */
|
||||
[data-module="send-message"] .sm-pad-hint {
|
||||
display: none;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.3;
|
||||
color: var(--muted);
|
||||
}
|
||||
@container (max-height: 176px) {
|
||||
[data-module="send-message"] .sm-pad .sm-keys { display: none; }
|
||||
[data-module="send-message"] .sm-pad .sm-pad-hint { display: block; }
|
||||
[data-module="send-message"] .sm-pad { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ── unlocked ── */
|
||||
|
||||
[data-module="send-message"] .sm-compose { gap: 6px; }
|
||||
|
||||
[data-module="send-message"] .sm-compose-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="send-message"] .sm-sending {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="send-message"] .sm-lock-link {
|
||||
flex: none;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="send-message"] .sm-lock-link:hover { color: var(--text); background: var(--border); }
|
||||
|
||||
/* The box takes whatever the quick emoji rows leave it, down to one line. */
|
||||
[data-module="send-message"] .sm-box {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-module="send-message"] .sm-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(1.4em + 16px);
|
||||
width: 100%;
|
||||
resize: none;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
[data-module="send-message"] .sm-input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* The grip: a small pill centred on the box's bottom edge, straddling the
|
||||
border. Drag it up for more emoji rows. It is positioned over the box, so
|
||||
it adds no height between the box and the keys. */
|
||||
[data-module="send-message"] .sm-grip {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -6px;
|
||||
transform: translateX(-50%);
|
||||
width: 44px;
|
||||
height: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ns-resize;
|
||||
touch-action: none;
|
||||
z-index: 1;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
[data-module="send-message"] .sm-grip::before {
|
||||
content: "";
|
||||
width: 26px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 1.5px var(--panel);
|
||||
transition: background 0.12s, width 0.12s;
|
||||
}
|
||||
[data-module="send-message"] .sm-grip:hover::before,
|
||||
[data-module="send-message"] .sm-grip:focus-visible::before,
|
||||
[data-module="send-message"] .sm-grip.is-dragging::before { background: var(--accent); width: 34px; }
|
||||
[data-module="send-message"] .sm-grip:focus { outline: none; }
|
||||
|
||||
/* The smallest Send message tile (two grid rows, a 110px body): the compose
|
||||
area tightens its padding, gaps and the box's one-line minimum so that two
|
||||
rows of quick emoji keys still fit under the box. .sm-wrap is the size
|
||||
container the height is measured against. */
|
||||
@container (max-height: 150px) {
|
||||
[data-module="send-message"] .sm-compose { padding: 3px 10px; gap: 2px; }
|
||||
[data-module="send-message"] .sm-input { padding: 4px 9px; line-height: 1.25; min-height: calc(1.25em + 10px); }
|
||||
}
|
||||
|
||||
/* The Send button and the outcome icon sit right under the box, level with
|
||||
the first row of keys; further rows of keys run on below them. */
|
||||
[data-module="send-message"] .sm-compose-foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-height: 1.6em;
|
||||
min-width: 0;
|
||||
}
|
||||
/* the outcome of a send, as an icon beside the Send button: a check (accent
|
||||
— green on the default theme) or a blocked sign (danger), fading out as
|
||||
its five seconds run down; the sentence is the tooltip. The slot keeps its
|
||||
width while empty so the Send button never shifts. */
|
||||
[data-module="send-message"] .sm-feedback {
|
||||
flex: none;
|
||||
width: 1.45em;
|
||||
min-height: 2.1em; /* the Send button's height, so the icon centres on it */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
[data-module="send-message"] .sm-countdown { align-self: center; }
|
||||
[data-module="send-message"] .sm-feedback svg {
|
||||
width: 1.45em;
|
||||
height: 1.45em;
|
||||
flex: none;
|
||||
animation: sm-pop 0.18s ease-out, sm-fade 0.6s ease-in 4.4s forwards;
|
||||
}
|
||||
[data-module="send-message"] .sm-feedback.is-ok { color: var(--accent); }
|
||||
[data-module="send-message"] .sm-feedback.is-error { color: var(--danger); cursor: help; }
|
||||
/* the Quick replies foot has no Send button: the icon keeps to the right edge */
|
||||
[data-module="send-message"] .sm-compose-foot.is-quick { justify-content: flex-end; }
|
||||
@keyframes sm-pop {
|
||||
from { transform: scale(0.6); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
@keyframes sm-fade {
|
||||
to { opacity: 0; }
|
||||
}
|
||||
[data-module="send-message"] .sm-countdown {
|
||||
flex: none;
|
||||
font-size: 0.75em;
|
||||
color: var(--warn);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="send-message"] .sm-sendbtn {
|
||||
flex: none;
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
[data-module="send-message"] .sm-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); }
|
||||
[data-module="send-message"] .sm-sendbtn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* quick emojis: one press each sends it as a message of its own. The keys
|
||||
take whatever room the foot has to the left of the outcome icon and the
|
||||
Send button. As one row (the default) they scroll sideways when the tile
|
||||
is narrow (trackpad, touch, or the wheel — client.js turns a vertical
|
||||
wheel into a horizontal scroll) while Send keeps its place; with more
|
||||
rows allotted from the grip they wrap, --sm-quick-rows rows tall. One key
|
||||
is --sm-key square, so a row's pitch is --sm-key plus the 4px gap. */
|
||||
[data-module="send-message"] .sm-emoji-sendrow {
|
||||
--sm-quick-rows: 1;
|
||||
--sm-key: calc(2.31 * var(--sm-size));
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
min-width: var(--sm-key);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
[data-module="send-message"] .sm-emoji-sendrow.is-multi {
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
height: calc(var(--sm-quick-rows) * var(--sm-key) + (var(--sm-quick-rows) - 1) * 4px);
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
[data-module="send-message"] .sm-emoji-sendrow::-webkit-scrollbar { display: none; }
|
||||
/* more keys off one edge or the other: that edge fades out (client.js sets the classes) */
|
||||
[data-module="send-message"] .sm-emoji-sendrow.has-more { mask-image: linear-gradient(to right, #000 calc(100% - 16px), transparent); }
|
||||
[data-module="send-message"] .sm-emoji-sendrow.has-less { mask-image: linear-gradient(to right, transparent, #000 16px); }
|
||||
[data-module="send-message"] .sm-emoji-sendrow.has-less.has-more { mask-image: linear-gradient(to right, transparent, #000 16px, #000 calc(100% - 16px), transparent); }
|
||||
[data-module="send-message"] .sm-emoji-sendrow:empty { display: none; }
|
||||
[data-module="send-message"] .sm-emoji-send {
|
||||
flex: none;
|
||||
width: var(--sm-key);
|
||||
height: var(--sm-key);
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--text) 5%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 1.1em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
[data-module="send-message"] .sm-emoji-send:hover { border-color: var(--accent); background: var(--border); }
|
||||
[data-module="send-message"] .sm-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); }
|
||||
[data-module="send-message"] .sm-emoji-send.is-busy { opacity: 0.6; }
|
||||
[data-module="send-message"] .sm-emoji-send.is-sent { background: var(--accent); border-color: transparent; }
|
||||
[data-module="send-message"] .sm-emoji-char { font-size: 1em; line-height: 1; }
|
||||
/* a :code: or a word rather than one emoji: a wider pill of small text */
|
||||
[data-module="send-message"] .sm-emoji-send.is-text {
|
||||
width: auto;
|
||||
max-width: 7em;
|
||||
padding: 0 7px;
|
||||
}
|
||||
[data-module="send-message"] .sm-emoji-send.is-text .sm-emoji-char {
|
||||
font-size: 0.72em;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* the preset grid: big targets, labels wrap */
|
||||
[data-module="send-message"] .sm-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(150px, 100%), 1fr));
|
||||
grid-auto-rows: minmax(44px, auto);
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-module="send-message"] .sm-grid .sm-note { grid-column: 1 / -1; }
|
||||
[data-module="send-message"] .sm-quick {
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--panel));
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
[data-module="send-message"] .sm-quick:hover { background: color-mix(in srgb, var(--accent) 22%, var(--panel)); }
|
||||
[data-module="send-message"] .sm-quick:active { background: color-mix(in srgb, var(--accent) 40%, var(--panel)); }
|
||||
[data-module="send-message"] .sm-quick.is-busy { opacity: 0.7; }
|
||||
[data-module="send-message"] .sm-quick.is-sent {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
[data-module="send-message"] .sm-quick-label { overflow-wrap: anywhere; }
|
||||
[data-module="send-message"] .sm-quick-chan {
|
||||
font-size: 0.72em;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
/* sent: the words step aside for a check, in the button itself */
|
||||
[data-module="send-message"] .sm-quick { position: relative; }
|
||||
[data-module="send-message"] .sm-quick-check {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
[data-module="send-message"] .sm-quick-check svg { width: 1.6em; height: 1.6em; animation: sm-pop 0.18s ease-out; }
|
||||
[data-module="send-message"] .sm-quick.is-sent .sm-quick-label,
|
||||
[data-module="send-message"] .sm-quick.is-sent .sm-quick-chan { visibility: hidden; }
|
||||
[data-module="send-message"] .sm-quick.is-sent .sm-quick-check { display: flex; }
|
||||
|
||||
/* ── the Send as menu ──
|
||||
The menu container is created and positioned by the shell (.tile-menu);
|
||||
it hangs off the body under a [data-module] host, so scoping still applies. */
|
||||
|
||||
[data-module="send-message"] .sm-menu { min-width: 180px; max-height: min(60vh, 420px); overflow-y: auto; }
|
||||
[data-module="send-message"] .sm-menu-head {
|
||||
padding: 6px 9px 2px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-module="send-message"] .sm-menu-head b { color: var(--text); font-weight: 700; }
|
||||
[data-module="send-message"] .sm-menu-head.is-error b { color: var(--danger); }
|
||||
[data-module="send-message"] .sm-menu-head.is-muted b { color: var(--muted); }
|
||||
[data-module="send-message"] .sm-menu-head + .sm-menu-head,
|
||||
[data-module="send-message"] .sm-menu-item + .sm-menu-head,
|
||||
[data-module="send-message"] .sm-menu-note + .sm-menu-head {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
}
|
||||
[data-module="send-message"] .sm-menu-note {
|
||||
padding: 4px 9px 6px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
max-width: 260px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
[data-module="send-message"] .sm-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 9px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
[data-module="send-message"] .sm-menu-item:hover { background: var(--border); }
|
||||
[data-module="send-message"] .sm-menu-item .check {
|
||||
width: 14px;
|
||||
flex: none;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
visibility: hidden;
|
||||
}
|
||||
[data-module="send-message"] .sm-menu-item.on .check { visibility: visible; }
|
||||
[data-module="send-message"] .sm-menu-item.remembered .check { visibility: visible; color: var(--muted); opacity: 0.6; }
|
||||
[data-module="send-message"] .sm-menu-item .name { font-weight: 600; }
|
||||
[data-module="send-message"] .sm-menu-item.on .name { color: var(--accent); }
|
||||
Reference in New Issue
Block a user