/* 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]+`|(? 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 = ''; /* ── 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 (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 = ''; const PLUS_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:". 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 = `
Connecting to Slack…
`; 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(); }, }; }