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>
858 lines
33 KiB
JavaScript
858 lines
33 KiB
JavaScript
/* 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();
|
||
},
|
||
};
|
||
}
|