Files
Waters ChurchandClaude Fable 5.1 1274360ee6 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>
2026-10-05 13:55:55 -04:00

858 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Slack — client part.
One channel transcript per configured channel (moduleApi.variant / the
tile's `view` setting pick which). Sending is the Send Message module's:
a click anywhere in a transcript makes its channel the one that module's
tiles post to (the tile's bottom edge lights up while it is), and the
reaction menu a right-click opens rides on that module's unlock — see the
page bus below and "Messaging on the page" in docs/MODULE-GUIDE.md. This
module has no PIN, no send tile and no quick replies of its own since
1.3.0. Everything lives inside the tile's root; all traffic goes through
the module's server routes (the Slack token never reaches a browser).
Nothing here uses innerHTML with data — every string from Slack lands in
a text node. */
/* ── shared helpers ─────────────────────────────────────────────────── */
function el(tag, cls, text) {
const node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
function fmtTime(ms) {
const d = new Date(ms);
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
}
/* Inline marks the server left in the text: ```code``` `code` *bold*
_italic_ ~strike~. Openers must not sit inside a word (snake_case_names
and 5 * 3 * 2 stay as they are). Built with DOM nodes, so any character
in a message is safe. */
const MARK_RE = /(```[\s\S]*?```|`[^`\n]+`|(?<![\w*])\*(?!\s)[^*\n]*?(?<!\s)\*(?![\w*])|(?<![\w_])_(?!\s)[^_\n]*?(?<!\s)_(?![\w_])|(?<![\w~])~(?!\s)[^~\n]*?(?<!\s)~(?![\w~]))/g;
/* Standard :codes: arrive as their characters (the server swaps them); a
custom emoji's :code: is still text here and becomes its image once the
workspace's set is known. */
const CUSTOM_CODE_RE = /:([a-z0-9_+-]{1,100}):/gi;
function appendText(container, str) {
if (!str) return;
if (!emojiStore.ready() || !str.includes(':')) return void container.appendChild(document.createTextNode(str));
let last = 0;
for (const m of str.matchAll(CUSTOM_CODE_RE)) {
if (!emojiStore.isCustom(m[1])) continue;
if (m.index > last) container.appendChild(document.createTextNode(str.slice(last, m.index)));
container.appendChild(emojiStore.node(m[1]));
last = m.index + m[0].length;
}
if (last < str.length) container.appendChild(document.createTextNode(str.slice(last)));
}
function renderRich(container, text, depth = 0) {
container.textContent = '';
const s = String(text || '');
let last = 0;
if (depth < 3) {
for (const m of s.matchAll(MARK_RE)) {
if (m.index > last) appendText(container, s.slice(last, m.index));
const tok = m[0];
let node;
if (tok.startsWith('```')) {
node = el('span', 'sl-code sl-block', tok.slice(3, -3).replace(/^\n+|\n+$/g, ''));
} else if (tok.startsWith('`')) {
node = el('code', 'sl-code', tok.slice(1, -1));
} else {
node = el(tok.startsWith('*') ? 'b' : tok.startsWith('_') ? 'i' : 's');
renderRich(node, tok.slice(1, -1), depth + 1);
}
container.appendChild(node);
last = m.index + tok.length;
}
}
if (last < s.length) appendText(container, s.slice(last));
}
function textSizePx(moduleApi, fallback = 14) {
return Math.min(48, Math.max(10, Number(moduleApi.instanceSettings.textSize) || fallback));
}
async function readJson(res) {
try {
return await res.json();
} catch {
return {};
}
}
const NO_CHANNEL = 'No channel configured — pick channels in Admin → Slack → Channels';
const JUMP_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11"/><path d="m7 10 5 5 5-5"/><path d="M5 20h14"/></svg>';
/* ── the page bus (shared with the Send Message module) ───────────── */
/* Deliberately page-level. The guide forbids module-level *per-instance*
state — two tiles' closures must never leak into each other — and every
piece of per-tile state in this file still lives inside its create().
This is a different kind of thing: facts about the whole page that the
Send Message module's tiles and every transcript on the dashboard have to
agree on — where the next message goes, whether the page is unlocked —
and since those tiles belong to another module, the facts live on
`window` (modules don't import each other), with CustomEvents to say
when they change. The contract is in docs/MODULE-GUIDE.md under
"Messaging on the page"; whichever tile mounts first creates the object. */
const PROVIDER_ID = 'slack';
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(),
senders: new Set(),
};
}
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 : '';
}
/* ── this module's API path, for <img src> (moduleApi.fetch covers the rest) ── */
const API_BASE = '/api/modules/slack';
const apiUrl = (path) => `${API_BASE}${path}`;
/* ── emoji: the shipped table plus the workspace's custom set, once per page ── */
const emojiStore = (() => {
const bus = new EventTarget();
let data = null; // { standard: { code: char }, custom: { name: { url } | { char } }, version }
let version = -1;
let loading = null;
let reverse = null; // char (variation selector stripped) → code
const strip = (str) => String(str || '').replace(/️/g, '');
const split = (code) => {
const m = /^(.+?)(?:::(skin-tone-[2-6]))?$/.exec(String(code || '').replace(/^:|:$/g, ''));
return m ? { base: m[1], tone: m[2] || '' } : { base: '', tone: '' };
};
function load(moduleApi) {
if (!loading) {
loading = moduleApi.fetch('/emoji')
.then(async (res) => {
if (!res.ok) throw new Error(String(res.status));
const body = await res.json();
data = { standard: body.standard || {}, custom: body.custom || {}, version: Number(body.version) || 0 };
version = data.version;
reverse = null;
bus.dispatchEvent(new Event('change'));
})
.catch(() => { /* the next state frame asks again */ })
.finally(() => { loading = null; });
}
return loading;
}
return {
ready: () => Boolean(data),
on(fn) {
bus.addEventListener('change', fn);
return () => bus.removeEventListener('change', fn);
},
/** With every state frame: fetch the table the first time, again when the custom set changed. */
sync(moduleApi, v) {
if (!data || (v !== undefined && Number(v) !== version)) load(moduleApi);
},
isCustom: (code) => Boolean(data?.custom?.[split(code).base]),
/** The character for a code — '' for a custom image or an unknown code. */
charOf(code) {
if (!data) return '';
const { base, tone } = split(code);
const ch = data.standard[base] || data.custom[base]?.char || '';
if (!ch) return '';
const t = tone ? data.standard[tone] || '' : '';
return t ? strip(ch) + t : ch;
},
imageOf(code) {
return data?.custom?.[split(code).base]?.url || '';
},
/** The code Slack knows a character by ('' if none) — a reaction needs the code. */
nameOf(char) {
if (!data) return '';
if (!reverse) {
reverse = new Map();
for (const [code, ch] of Object.entries(data.standard)) {
if (code.startsWith('skin-tone-')) continue;
const key = strip(ch);
if (!reverse.has(key)) reverse.set(key, code);
}
}
const str = strip(char);
const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(str);
if (tone) {
const base = reverse.get(str.slice(0, -tone[0].length));
const toneCode = Object.keys(data.standard).find((c) => c.startsWith('skin-tone-') && data.standard[c] === tone[0]);
return base && toneCode ? `${base}::${toneCode}` : '';
}
return reverse.get(str) || '';
},
/** A node drawing :code: — the character, or the custom image. */
node(code) {
const img = this.imageOf(code);
if (img) {
const i = el('img', 'sl-emoji');
i.src = img;
i.alt = `:${code}:`;
i.title = `:${code}:`;
i.loading = 'lazy';
i.draggable = false;
return i;
}
const span = el('span', 'sl-emoji-char', this.charOf(code) || `:${code}:`);
span.title = `:${code}:`;
return span;
},
/** Codes matching a search: custom ones first, names that start with it before the rest. */
search(q, limit = 40) {
const str = String(q || '').trim().toLowerCase().replace(/^:|:$/g, '').replace(/\s+/g, '_');
if (!data || !str) return [];
const out = [];
const seen = new Set();
const add = (code) => {
if (!seen.has(code) && out.length < limit) {
seen.add(code);
out.push(code);
}
};
const customs = Object.keys(data.custom);
const standard = Object.keys(data.standard).filter((c) => !c.startsWith('skin-tone-'));
for (const c of customs) if (c.startsWith(str)) add(c);
for (const c of standard) if (c.startsWith(str)) add(c);
for (const c of customs) if (c.includes(str)) add(c);
for (const c of standard) if (c.includes(str)) add(c);
return out;
},
};
})();
/* ── the reaction menu: right-click a message, pick an emoji ── */
/* One for the page, on document.body so a tile's overflow can't clip it;
`data-module="slack"` keeps the module's scoped styles applying to it. */
const emojiMenu = (() => {
let menu = null;
let ctx = null;
let cleanup = null;
function close() {
if (!menu) return;
cleanup?.();
cleanup = null;
menu.remove();
menu = null;
ctx = null;
}
function place(x, y) {
const r = menu.getBoundingClientRect();
const left = Math.max(8, Math.min(x, window.innerWidth - r.width - 8));
const top = Math.max(8, Math.min(y, window.innerHeight - r.height - 8));
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
}
function anchor() {
return { x: parseFloat(menu.style.left) || 8, y: parseFloat(menu.style.top) || 8 };
}
/** Add (or remove) :code: on the message; resolves to '' or an error sentence. */
async function perform(code, remove) {
const { moduleApi, channel, ts } = ctx;
const bus = messagingBus();
const token = pageToken(bus);
if (!token) return 'locked';
try {
const res = await moduleApi.fetch('/react', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ channel, ts, name: code, remove: Boolean(remove) }),
});
const body = await readJson(res);
if (res.status === 401) {
// The Send Message module says this unlock is over: every messaging tile locks.
if (bus.unlock.token === token) {
bus.unlock = { token: '', expiresAt: 0, reason: body.error || 'Locked — enter the PIN' };
emit('unlock', bus.unlock);
}
return 'locked';
}
if (!res.ok) return body.error || `Could not react (HTTP ${res.status})`;
if (bus.unlock.token === token && Number(body.expiresAt) > 0) bus.unlock.expiresAt = Number(body.expiresAt);
return '';
} catch {
return 'ProdDash unreachable — try again';
}
}
async function pick(code, remove) {
if (!menu || !ctx) return;
menu.classList.add('is-busy');
const err = await perform(code, remove);
if (!menu) return;
menu.classList.remove('is-busy');
if (!err) return void close();
if (err === 'locked') return void render();
render(err);
}
function button(code) {
const b = el('button', 'sl-emoji-btn');
b.type = 'button';
b.title = `:${code}:`;
b.appendChild(emojiStore.node(code));
b.addEventListener('click', () => pick(code, false));
return b;
}
function render(error = '') {
if (!menu || !ctx) return;
const { x, y } = anchor();
const bus = messagingBus();
menu.textContent = '';
menu.hidden = false;
if (!pageToken(bus)) {
// Locked: the PIN lives in the Send Message module's tiles, not here.
// A pending toggle (a click on a reaction pill) is carried out the
// moment the page unlocks, if this menu is still open.
const head = el('div', 'sl-emoji-head', ctx.sendingAs ? `React as ${ctx.sendingAs}` : 'React');
const note = el('div', 'sl-emoji-locked');
note.appendChild(el('b', '', bus.senders.size ? 'Locked' : 'No Send message tile'));
note.appendChild(el('span', '', bus.senders.size
? ' — enter the PIN in the Send message tile to react.'
: ' — add Send message from + to react as the location.'));
if (bus.unlock.reason) note.appendChild(el('span', 'sl-emoji-reason', bus.unlock.reason));
menu.append(head, note);
menu.focus({ preventScroll: true });
place(x, y);
return;
}
if (ctx.pending) {
// Unlocked and asked for one toggle: do it without showing a picker.
const { code, remove } = ctx.pending;
ctx.pending = null;
menu.hidden = true;
pick(code, remove);
return;
}
const head = el('div', 'sl-emoji-head', ctx.sendingAs ? `React as ${ctx.sendingAs}` : 'React');
// The Send Message module's quick emojis head the menu — a reaction
// needs the code Slack knows the character by, so ones it has no name
// for are left out (a :custom_code: is already a name).
const quick = el('div', 'sl-emoji-quickrow');
const seen = new Set();
for (const q of Array.isArray(bus.quickEmojis) ? bus.quickEmojis : []) {
const raw = String(q || '').trim();
const code = /^:[^:\s]+(?:::skin-tone-[2-6])?:$/i.test(raw) ? raw.replace(/^:|:$/g, '').toLowerCase() : emojiStore.nameOf(raw);
if (code && !seen.has(code)) {
seen.add(code);
quick.appendChild(button(code));
}
}
const search = el('input', 'sl-emoji-search');
search.type = 'search';
search.placeholder = 'Search all emoji…';
search.autocomplete = 'off';
search.spellcheck = false;
const results = el('div', 'sl-emoji-results');
const paint = () => {
results.textContent = '';
const q = search.value.trim();
const list = q ? emojiStore.search(q, 40) : [];
if (!list.length) {
results.appendChild(el('div', 'sl-emoji-none', q ? 'Nothing by that name' : 'Type to search — names as in Slack'));
return;
}
for (const code of list) results.appendChild(button(code));
};
search.addEventListener('input', paint);
search.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
results.querySelector('.sl-emoji-btn')?.click();
}
});
const msg = el('div', 'sl-emoji-msg' + (error ? ' is-error' : ''), error);
menu.append(head, quick, search, results, msg);
paint();
place(x, y);
search.focus({ preventScroll: true });
}
return {
/**
* Open at (x, y) for message `ts` in `channel`. `pending` { code, remove }
* performs that one toggle (a click on a reaction pill) instead of
* showing the picker.
*/
open({ moduleApi, x, y, channel, ts, sendingAs, pending = null }) {
close();
ctx = { moduleApi, channel, ts, sendingAs, pending };
menu = el('div', 'sl-emoji-menu');
menu.dataset.module = 'slack';
menu.tabIndex = -1;
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', 'React with an emoji');
menu.style.left = `${Math.round(x)}px`;
menu.style.top = `${Math.round(y)}px`;
document.body.appendChild(menu);
render();
const onDown = (e) => { if (menu && !menu.contains(e.target)) close(); };
const onKey = (e) => {
if (e.key === 'Escape' && menu) {
e.stopPropagation();
close();
}
};
const offUnlock = on('unlock', () => { if (menu) render(); });
document.addEventListener('pointerdown', onDown, true);
document.addEventListener('keydown', onKey, true);
window.addEventListener('resize', close);
cleanup = () => {
offUnlock();
document.removeEventListener('pointerdown', onDown, true);
document.removeEventListener('keydown', onKey, true);
window.removeEventListener('resize', close);
};
},
close,
};
})();
/* ── the factory ────────────────────────────────────────────────────── */
/* the text-size buttons: a drawn − and + at the icon size, like every other header icon */
const MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
export default function create({ root, moduleApi }) {
const variant = String(moduleApi.variant || '');
// The entry's preset `view` is authoritative; a tile added under 1.0.0 has
// the plain variant "transcript", a new one "transcript:<channel id>".
const view = String(moduleApi.instanceSettings.view || (variant.startsWith('transcript') ? 'transcript' : variant) || 'transcript');
// A Send message / Quick replies tile from before 1.3.0: those live in the
// Send Message module now. Its entry is no longer offered, so the shell
// normally never mounts it; if it does, draw nothing (per the guide).
if (view === 'send' || view === 'quick') {
return {
start() { moduleApi.setStatus('error', 'Sending moved to the Send Message module — remove this tile and add Send message from +'); },
stop() { root.innerHTML = ''; },
};
}
return createTranscriptTile({ root, moduleApi });
}
/* ── transcript ─────────────────────────────────────────────────────── */
function createTranscriptTile({ root, moduleApi }) {
const entries = new Map(); // ts -> { el, sig, ... }
// The channel this tile shows: preset by the picker entry; a tile from
// 1.0.0 has none and follows the first configured channel.
const pinnedChannel = String(moduleApi.instanceSettings.channel || '');
let channelId = pinnedChannel;
let lastState = null;
let pinnedToLatest = true;
let painted = false;
let stream = null;
let stopped = false;
let targetTimer = null;
const bus = messagingBus();
root.innerHTML = `
<div class="sl-wrap sl-transcript">
<div class="sl-banner" hidden></div>
<div class="sl-stream">
<div class="sl-empty">Connecting to Slack…</div>
<div class="sl-entries"></div>
</div>
<button class="sl-jump" hidden>↓ New messages</button>
</div>`;
const wrap = root.querySelector('.sl-wrap');
const banner = root.querySelector('.sl-banner');
const streamEl = root.querySelector('.sl-stream');
const entriesEl = root.querySelector('.sl-entries');
const emptyEl = root.querySelector('.sl-empty');
const jumpBtn = root.querySelector('.sl-jump');
/* title-bar controls */
function bumpTextSize(delta) {
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, textSizePx(moduleApi) + delta)) });
applyInstanceSettings();
}
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(1) });
moduleApi.header.addButton({
icon: JUMP_SVG,
title: 'Latest',
onClick() {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
},
});
function applyInstanceSettings() {
const s = moduleApi.instanceSettings;
wrap.style.setProperty('--sl-size', textSizePx(moduleApi) + 'px');
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
wrap.classList.toggle('show-times', s.showTimes !== false);
wrap.classList.toggle('show-names', s.showNames !== false);
wrap.classList.toggle('show-system', s.showSystem !== false);
if (pinnedToLatest) scrollToLatest();
}
applyInstanceSettings();
/* scrolling */
streamEl.addEventListener('scroll', () => {
const near = streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
pinnedToLatest = near;
if (near) jumpBtn.hidden = true;
});
jumpBtn.addEventListener('click', () => {
pinnedToLatest = true;
jumpBtn.hidden = true;
scrollToLatest();
});
function scrollToLatest() {
streamEl.scrollTop = streamEl.scrollHeight;
}
/* the send target: a click anywhere in the body points the Send Message tiles here */
wrap.addEventListener('click', () => {
if (!channelId || !lastState?.channels?.[channelId]) return; // not configured — can't be a target
emit('select', { provider: PROVIDER_ID, destination: channelId });
});
const offTarget = on('target', () => {
// Coalesce: a remount detaches and re-attaches within a few ms.
clearTimeout(targetTimer);
targetTimer = setTimeout(paintTarget, 50);
});
// Custom emoji images and reaction glyphs need the table: redraw once it is here (or changes).
const offEmoji = emojiStore.on(() => {
if (stopped) return;
for (const e of entries.values()) e.sig = '';
if (lastState) applyState(lastState);
});
function isTarget() {
return Boolean(channelId) && bus.target.provider === PROVIDER_ID && bus.target.destination === channelId;
}
/** On the page and configured: that is what lets the Send Message tiles default to this channel. */
function attach(onPage) {
const have = bus.transcripts.get(moduleApi.instanceId);
if (onPage) {
if (have && have.destination === channelId) return;
bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: channelId });
} else if (!bus.transcripts.delete(moduleApi.instanceId)) return;
emit('transcripts', {});
}
function paintTarget() {
if (stopped) return;
const on = isTarget();
if (on === wrap.classList.contains('is-target')) return;
wrap.classList.toggle('is-target', on);
if (lastState) reportStatus(lastState);
}
/* entries — laid out like the ProdCom transcript: a square picture on the
left, then the name line with its time, the text, the reactions */
const messagesByTs = new Map(); // ts → message (replies too), for the reaction menu
function hueOf(name) {
let h = 0;
for (const c of String(name || '')) h = (h * 31 + c.codePointAt(0)) % 360;
return h;
}
function initialsOf(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '?';
const first = [...parts[0]][0] || '';
const last = parts.length > 1 ? [...parts[parts.length - 1]][0] || '' : '';
return (first + last).toUpperCase();
}
function buildAvatar(m) {
const av = el('span', 'sl-avatar');
av.style.setProperty('--sl-hue', String(hueOf(m.name)));
if (m.avatarEmoji) {
av.appendChild(el('span', 'sl-avatar-emoji', m.avatarEmoji));
return av;
}
const initials = () => {
av.textContent = '';
av.appendChild(el('span', 'sl-avatar-initials', initialsOf(m.name)));
};
if (m.avatar) {
const img = el('img', 'sl-avatar-img');
img.alt = '';
img.loading = 'lazy';
img.draggable = false;
img.addEventListener('error', initials, { once: true });
img.src = apiUrl(`/avatar/${m.avatar}`);
av.appendChild(img);
} else initials();
return av;
}
function buildMeta(m) {
const meta = el('div', 'sl-meta');
meta.append(el('span', 'sl-name', m.name || ''), el('span', 'sl-time', fmtTime(m.time)));
return meta;
}
function buildReactions(m) {
const row = el('div', 'sl-reactions');
for (const r of m.reactions) {
const pill = el('button', 'sl-reaction' + (r.me ? ' is-me' : ''));
pill.type = 'button';
pill.title = `:${r.name}: — ${r.me ? 'you reacted; click to take it back' : 'click to react too'}`;
pill.append(emojiStore.node(r.name), el('span', 'sl-reaction-count', String(r.count)));
pill.addEventListener('click', (e) => {
e.stopPropagation();
const rect = pill.getBoundingClientRect();
openReactionMenu(m, rect.left, rect.bottom + 4, { code: r.name, remove: Boolean(r.me) });
});
row.appendChild(pill);
}
return row;
}
/** A message or a thread reply as one row; returns the row and its body (replies nest in the body). */
function buildLine(m, reply = false) {
const line = el('div', reply ? 'sl-reply' : 'sl-row');
line.dataset.ts = m.ts;
line.classList.toggle('sl-me', Boolean(m.me));
line.classList.toggle('sl-bot', Boolean(m.bot));
const body = el('div', 'sl-body');
body.appendChild(buildMeta(m));
const text = el('div', 'sl-text');
renderRich(text, m.text);
if (m.edited) text.appendChild(el('span', 'sl-edited', ' (edited)'));
body.appendChild(text);
if (Array.isArray(m.reactions) && m.reactions.length) body.appendChild(buildReactions(m));
line.append(buildAvatar(m), body);
return { line, body };
}
function buildEntry(m) {
const node = el('div', 'sl-entry');
node.dataset.ts = m.ts;
return { el: node, sig: '' };
}
function fillEntry(entry, m) {
const sig = JSON.stringify(m);
if (sig === entry.sig) return;
entry.sig = sig;
const node = entry.el;
node.textContent = '';
node.classList.toggle('sl-system', Boolean(m.system));
node.classList.toggle('sl-me', Boolean(m.me));
if (m.system) {
const line = el('div', 'sl-sysline');
line.append(el('span', 'sl-time', fmtTime(m.time)), el('span', 'sl-systext', m.text || ''));
node.appendChild(line);
return;
}
const { line, body } = buildLine(m);
node.appendChild(line);
if (Array.isArray(m.replies)) {
if (m.replies.length) {
const list = el('div', 'sl-replies');
for (const r of m.replies) list.appendChild(buildLine(r, true).line);
body.appendChild(list);
}
} else if (m.replyCount > 0) {
body.appendChild(el('div', 'sl-replycount', `${m.replyCount} ${m.replyCount === 1 ? 'reply' : 'replies'}`));
}
}
/* reactions: a right-click on a row, or a click on a pill, opens the menu */
function openReactionMenu(m, x, y, pending = null) {
if (!channelId || !lastState?.channels?.[channelId]) return;
emojiMenu.open({
moduleApi,
x,
y,
channel: channelId,
ts: m.ts,
sendingAs: lastState.sendingAs || '',
pending,
});
}
entriesEl.addEventListener('contextmenu', (e) => {
const line = e.target.closest('.sl-row, .sl-reply');
if (!line || !entriesEl.contains(line)) return;
const m = messagesByTs.get(line.dataset.ts);
if (!m) return;
e.preventDefault();
openReactionMenu(m, e.clientX, e.clientY);
});
function renderMessages(list) {
const seen = new Set();
let prev = null;
let added = 0;
messagesByTs.clear();
for (const m of list) {
if (!m || !m.ts) continue;
seen.add(m.ts);
messagesByTs.set(m.ts, m);
if (Array.isArray(m.replies)) for (const r of m.replies) if (r?.ts) messagesByTs.set(r.ts, r);
let entry = entries.get(m.ts);
if (!entry) {
entry = buildEntry(m);
entries.set(m.ts, entry);
if (painted) entry.el.classList.add('sl-new');
added += 1;
}
fillEntry(entry, m);
const node = entry.el;
// Keep DOM order equal to list order without re-inserting settled nodes
// (a move would replay the fade-in).
if (prev ? node.previousElementSibling !== prev : node !== entriesEl.firstElementChild) {
if (prev) prev.after(node);
else entriesEl.prepend(node);
}
prev = node;
}
for (const [ts, entry] of entries) {
if (seen.has(ts)) continue;
entry.el.remove();
entries.delete(ts);
}
if (pinnedToLatest) scrollToLatest();
else if (added) jumpBtn.hidden = false;
}
/** The status dot, banner and empty-state text for the current state. */
function reportStatus(s) {
const ch = channelId && s.channels && typeof s.channels === 'object' ? s.channels[channelId] : null;
const hasEntries = entries.size > 0;
const who = s.identity?.name ? ` as ${s.identity.name}` : '';
let problem = '';
if (!channelId) problem = NO_CHANNEL;
else if (!ch) problem = `#${channelId} is no longer one of this module's channels — pick it again in Admin → Slack → Channels, or remove this tile`;
if (problem) {
moduleApi.setStatus('error', problem);
banner.hidden = true;
emptyEl.hidden = false;
emptyEl.textContent = problem;
emptyEl.classList.add('is-error');
return;
}
const chan = `#${ch.name || channelId}`;
const st = ch.status || {};
if (st.state === 'ok') {
moduleApi.setStatus('ok', `Live — ${chan}${who}${isTarget() ? ' · sends go here' : ''}`);
banner.hidden = true;
} else {
moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to Slack…');
// With messages on screen the problem rides in a banner; with none,
// the empty state says it, once.
banner.textContent = st.message || 'Connecting to Slack…';
banner.classList.toggle('is-error', st.state === 'error');
banner.hidden = !hasEntries;
}
emptyEl.hidden = hasEntries;
if (!hasEntries) {
emptyEl.textContent = st.state !== 'ok' ? (st.message || 'Connecting to Slack…') : `No messages yet in ${chan}`;
emptyEl.classList.toggle('is-error', st.state === 'error');
}
}
function applyState(s) {
if (stopped || !s || typeof s !== 'object') return;
lastState = s;
emojiStore.sync(moduleApi, s.emojiVersion);
const order = Array.isArray(s.channelOrder) ? s.channelOrder.map(String) : [];
channelId = pinnedChannel || order[0] || '';
const ch = channelId && s.channels && typeof s.channels === 'object' ? s.channels[channelId] : null;
attach(Boolean(ch));
renderMessages(ch && Array.isArray(ch.messages) ? ch.messages : []);
wrap.classList.toggle('is-target', isTarget());
reportStatus(s);
painted = true;
}
async function loadState() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) throw new Error(String(res.status));
applyState(await res.json());
} catch {
/* the stream's next frame paints; the error handler reports the outage */
}
}
function connect() {
stream = moduleApi.sse('/stream', {
open() {
// Tiles share one stream; a late joiner missed its first frame, and a
// reconnect may have missed changes — a snapshot covers both.
loadState();
},
error() {
moduleApi.setStatus('error', 'ProdDash Slack module unreachable — reconnecting…');
banner.textContent = 'ProdDash Slack module unreachable — reconnecting…';
banner.classList.add('is-error');
banner.hidden = entries.size === 0;
if (!entries.size) {
emptyEl.textContent = 'ProdDash Slack module unreachable — reconnecting…';
emptyEl.classList.add('is-error');
}
},
events: {
state(e) {
try {
applyState(JSON.parse(e.data));
} catch { /* a bad frame is skipped */ }
},
},
});
}
return {
start() {
moduleApi.setStatus('connecting', 'Connecting to Slack…');
connect();
},
stop() {
stopped = true;
clearTimeout(targetTimer);
offTarget();
offEmoji();
emojiMenu.close();
attach(false);
stream?.close();
root.innerHTML = ''; // header controls are removed by the shell
},
onResize() {
if (pinnedToLatest) scrollToLatest();
},
};
}