2026-08-30 15:03:12 +00:00
|
|
|
|
/* ProdCom Transcript — client part.
|
|
|
|
|
|
|
|
|
|
|
|
Ported from prodcom-listener/public/app.js into the ProdDash module
|
|
|
|
|
|
contract: everything lives inside the tile's root, per-tile state
|
2026-09-10 16:40:46 -04:00
|
|
|
|
(hidden channels, channel icons, timestamps, text size, flow direction)
|
2026-09-15 12:43:08 -04:00
|
|
|
|
persists in instance settings, and the data comes from the module's own
|
|
|
|
|
|
server, which keeps one connection to ProdCom for every dashboard: a
|
2026-10-05 13:55:55 -04:00
|
|
|
|
snapshot on connect (`state`), then each changed entry (`entry`).
|
|
|
|
|
|
|
|
|
|
|
|
Messaging: a click anywhere in the tile makes ProdCom the platform the
|
|
|
|
|
|
Send Message module's tiles send through (the channel the text goes out
|
|
|
|
|
|
*as* is picked in their Send as menu, among the channels ProdCom lets text
|
|
|
|
|
|
be typed onto); while it is, the tile's bottom edge carries the accent
|
|
|
|
|
|
stripe.
|
|
|
|
|
|
That is shared through the page bus below — see "Messaging on the page"
|
|
|
|
|
|
in docs/MODULE-GUIDE.md. Text typed from a dashboard comes back on the
|
|
|
|
|
|
stream as an entry with source "typed" and is drawn with a keyboard mark. */
|
|
|
|
|
|
|
|
|
|
|
|
/* ── the page bus (shared with the Send Message module) ───────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const PROVIDER_ID = 'prodcom-transcript';
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
2026-09-15 10:49:58 -04:00
|
|
|
|
/* 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>';
|
|
|
|
|
|
|
2026-08-30 15:03:12 +00:00
|
|
|
|
export default function create({ root, moduleApi }) {
|
|
|
|
|
|
/* ── per-instance state ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const channels = new Map(); // channelId -> {name, color}
|
|
|
|
|
|
const groups = new Map(); // groupId -> {name, channelIds}
|
|
|
|
|
|
const entryEls = new Map(); // entryId -> element
|
2026-09-10 16:40:46 -04:00
|
|
|
|
let pinnedToLatest = true;
|
2026-08-30 15:03:12 +00:00
|
|
|
|
let hiddenChannels = new Set(moduleApi.instanceSettings.hiddenChannels || []);
|
|
|
|
|
|
let stream = null; // moduleApi.sse handle
|
|
|
|
|
|
let stopped = false;
|
2026-10-05 13:55:55 -04:00
|
|
|
|
let lastStatus = null; // the link's last status, re-reported when the target changes
|
|
|
|
|
|
const bus = messagingBus();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
|
|
|
|
|
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
root.innerHTML = `
|
|
|
|
|
|
<div class="pt-wrap">
|
|
|
|
|
|
<div class="pt-stream">
|
|
|
|
|
|
<div class="pt-empty">Waiting for transcript…</div>
|
|
|
|
|
|
<div class="pt-entries"></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="pt-jump" hidden>↓ New messages</button>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
|
|
const wrap = root.querySelector('.pt-wrap');
|
|
|
|
|
|
const streamEl = root.querySelector('.pt-stream');
|
|
|
|
|
|
const entriesEl = root.querySelector('.pt-entries');
|
|
|
|
|
|
const emptyEl = root.querySelector('.pt-empty');
|
|
|
|
|
|
const jumpBtn = root.querySelector('.pt-jump');
|
2026-08-30 16:39:22 +00:00
|
|
|
|
|
2026-10-05 13:55:55 -04:00
|
|
|
|
/* ── the send target: a click anywhere in the body points the senders here ── */
|
|
|
|
|
|
|
|
|
|
|
|
wrap.addEventListener('click', () => {
|
|
|
|
|
|
if (!channels.size) return; // nothing to send to yet
|
|
|
|
|
|
emit('select', { provider: PROVIDER_ID, destination: '' });
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function isTarget() {
|
|
|
|
|
|
return bus.target.provider === PROVIDER_ID;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function paintTarget() {
|
|
|
|
|
|
if (stopped) return;
|
|
|
|
|
|
const on = isTarget();
|
|
|
|
|
|
if (on === wrap.classList.contains('is-target')) return;
|
|
|
|
|
|
wrap.classList.toggle('is-target', on);
|
|
|
|
|
|
if (lastStatus) applyStatus(lastStatus);
|
|
|
|
|
|
}
|
|
|
|
|
|
const offTarget = on('target', paintTarget);
|
|
|
|
|
|
|
2026-08-30 16:39:22 +00:00
|
|
|
|
/* ── title-bar controls ─────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
|
|
|
|
|
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>';
|
|
|
|
|
|
const CLEAR_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/></svg>';
|
2026-09-10 16:40:46 -04:00
|
|
|
|
const CHANNELS_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="M4 5h16l-6 7.4V19l-4 2v-8.6z"/></svg>';
|
|
|
|
|
|
// arrow shows where new messages land: down = oldest first, up = newest first
|
|
|
|
|
|
const FLOW_DOWN_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="M6 4v14"/><path d="m3 15 3 3 3-3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
|
|
|
|
|
const FLOW_UP_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="M6 20V6"/><path d="m3 9 3-3 3 3"/><path d="M12 5h8"/><path d="M12 12h8"/><path d="M12 19h6"/></svg>';
|
2026-08-30 16:39:22 +00:00
|
|
|
|
|
|
|
|
|
|
let channelMenuEl = null; // the open menu's element (filled on each open)
|
|
|
|
|
|
const channelsMenu = moduleApi.header.addMenu({
|
2026-09-10 16:40:46 -04:00
|
|
|
|
icon: CHANNELS_SVG,
|
2026-09-15 10:49:58 -04:00
|
|
|
|
title: 'Channels',
|
2026-08-30 16:39:22 +00:00
|
|
|
|
build(menu) {
|
|
|
|
|
|
menu.classList.add('pt-menu');
|
|
|
|
|
|
channelMenuEl = menu;
|
|
|
|
|
|
rebuildChannelMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
channelsMenu.button.classList.add('pt-channels-btn');
|
2026-09-10 16:40:46 -04:00
|
|
|
|
// the icon carries the label; a count rides alongside it while filtering
|
|
|
|
|
|
const channelCountEl = document.createElement('span');
|
|
|
|
|
|
channelCountEl.className = 'pt-count';
|
|
|
|
|
|
channelCountEl.hidden = true;
|
|
|
|
|
|
channelsMenu.button.appendChild(channelCountEl);
|
2026-08-30 16:39:22 +00:00
|
|
|
|
|
|
|
|
|
|
let timeBtn = null;
|
|
|
|
|
|
timeBtn = moduleApi.header.addButton({
|
|
|
|
|
|
icon: CLOCK_SVG,
|
2026-09-15 10:49:58 -04:00
|
|
|
|
title: 'Timestamps',
|
2026-08-30 16:39:22 +00:00
|
|
|
|
onClick() {
|
|
|
|
|
|
moduleApi.saveInstanceSettings({ showTimes: !moduleApi.instanceSettings.showTimes });
|
|
|
|
|
|
applyInstanceSettings();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
function bumpTextSize(delta) {
|
|
|
|
|
|
const current = Number(moduleApi.instanceSettings.textSize) || 15;
|
|
|
|
|
|
moduleApi.saveInstanceSettings({ textSize: Math.min(48, Math.max(10, current + delta)) });
|
|
|
|
|
|
applyInstanceSettings();
|
|
|
|
|
|
}
|
2026-09-30 14:06:52 -04:00
|
|
|
|
const minusBtn = moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bumpTextSize(-2) });
|
|
|
|
|
|
const plusBtn = moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bumpTextSize(2) });
|
2026-08-30 16:39:22 +00:00
|
|
|
|
|
2026-09-10 16:40:46 -04:00
|
|
|
|
// icon and tooltip track the current direction — applyInstanceSettings sets both
|
|
|
|
|
|
const flowBtn = moduleApi.header.addButton({
|
|
|
|
|
|
icon: FLOW_DOWN_SVG,
|
|
|
|
|
|
onClick() {
|
|
|
|
|
|
moduleApi.saveInstanceSettings({ newestFirst: !moduleApi.instanceSettings.newestFirst });
|
|
|
|
|
|
applyInstanceSettings();
|
|
|
|
|
|
// the two directions have opposite "latest" ends — follow the flip
|
|
|
|
|
|
if (pinnedToLatest) scrollToLatest();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-30 14:06:52 -04:00
|
|
|
|
const latestBtn = moduleApi.header.addButton({
|
2026-08-30 16:39:22 +00:00
|
|
|
|
icon: JUMP_SVG,
|
2026-09-15 10:49:58 -04:00
|
|
|
|
title: 'Latest',
|
2026-08-30 16:39:22 +00:00
|
|
|
|
onClick() {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
pinnedToLatest = true;
|
2026-08-30 16:39:22 +00:00
|
|
|
|
jumpBtn.hidden = true;
|
2026-09-10 16:40:46 -04:00
|
|
|
|
scrollToLatest();
|
2026-08-30 16:39:22 +00:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const clearBtn = moduleApi.header.addButton({
|
|
|
|
|
|
icon: CLEAR_SVG,
|
2026-09-15 10:49:58 -04:00
|
|
|
|
title: 'Clear',
|
2026-08-30 16:39:22 +00:00
|
|
|
|
onClick: clearView,
|
|
|
|
|
|
});
|
|
|
|
|
|
clearBtn.classList.add('pt-clear');
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
2026-09-30 14:06:52 -04:00
|
|
|
|
/* ── a narrow tile: the bar keeps Channels, the rest fold into ⋯ ──
|
|
|
|
|
|
Seven controls need ~175 px, and the shell clips whatever the bar
|
|
|
|
|
|
cannot fit — on a 3-column tile among many, or one squeezed by the
|
|
|
|
|
|
fullscreen fit, that used to take the channel filter with it. Below
|
|
|
|
|
|
COMPACT_PX the six secondary controls hide and a single ⋯ menu offers
|
|
|
|
|
|
the same actions, so the filter button is the last thing to go. */
|
|
|
|
|
|
const MORE_SVG = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
|
|
|
|
|
|
const COMPACT_PX = 380; // tile width below which the full bar no longer fits
|
|
|
|
|
|
|
|
|
|
|
|
let moreMenuEl = null;
|
|
|
|
|
|
const moreMenu = moduleApi.header.addMenu({
|
|
|
|
|
|
icon: MORE_SVG,
|
|
|
|
|
|
title: 'More',
|
|
|
|
|
|
build(menu) {
|
|
|
|
|
|
menu.classList.add('pt-menu');
|
|
|
|
|
|
moreMenuEl = menu;
|
|
|
|
|
|
rebuildMoreMenu();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
const moreWrap = moreMenu.button.parentElement; // the shell's .tile-dd
|
|
|
|
|
|
const secondaryBtns = [timeBtn, minusBtn, plusBtn, flowBtn, latestBtn, clearBtn];
|
|
|
|
|
|
|
|
|
|
|
|
function addMoreItem(label, { on = null, onClick } = {}) {
|
|
|
|
|
|
const item = document.createElement('button');
|
|
|
|
|
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
|
|
|
|
|
if (on !== null) {
|
|
|
|
|
|
item.setAttribute('role', 'menuitemcheckbox');
|
|
|
|
|
|
item.setAttribute('aria-checked', String(on));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
item.setAttribute('role', 'menuitem');
|
|
|
|
|
|
}
|
|
|
|
|
|
const check = document.createElement('span');
|
|
|
|
|
|
check.className = 'check';
|
|
|
|
|
|
check.textContent = '✓';
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
|
name.className = 'name';
|
|
|
|
|
|
name.textContent = label;
|
|
|
|
|
|
item.append(check, name);
|
|
|
|
|
|
item.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation(); // the rebuild detaches this item — keep the menu open
|
|
|
|
|
|
onClick();
|
|
|
|
|
|
});
|
|
|
|
|
|
moreMenuEl.appendChild(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function rebuildMoreMenu() {
|
|
|
|
|
|
if (!moreMenuEl) return;
|
|
|
|
|
|
moreMenuEl.innerHTML = '';
|
|
|
|
|
|
const s = moduleApi.instanceSettings;
|
|
|
|
|
|
addMoreItem('Timestamps', { on: Boolean(s.showTimes), onClick: () => timeBtn.click() });
|
|
|
|
|
|
addMoreItem('Newest first', { on: newestFirst(), onClick: () => flowBtn.click() });
|
|
|
|
|
|
const divider = document.createElement('div');
|
|
|
|
|
|
divider.className = 'pt-menu-divider';
|
|
|
|
|
|
moreMenuEl.appendChild(divider);
|
|
|
|
|
|
addMoreItem('Smaller text', { onClick: () => bumpTextSize(-2) });
|
|
|
|
|
|
addMoreItem('Larger text', { onClick: () => bumpTextSize(2) });
|
|
|
|
|
|
addMoreItem('Latest', { onClick: () => { moreMenu.close(); latestBtn.click(); } });
|
|
|
|
|
|
addMoreItem('Clear', { onClick: () => { moreMenu.close(); clearView(); } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let compact = null; // unknown until the first measurement
|
|
|
|
|
|
function applyCompact(width) {
|
|
|
|
|
|
const next = width > 0 && width < COMPACT_PX;
|
|
|
|
|
|
if (next === compact) return;
|
|
|
|
|
|
compact = next;
|
|
|
|
|
|
for (const btn of secondaryBtns) btn.hidden = compact;
|
|
|
|
|
|
moreWrap.hidden = !compact;
|
|
|
|
|
|
if (!compact) moreMenu.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
// the tile's own body tells the width; onResize only knows grid cells
|
|
|
|
|
|
const sizeWatch = new ResizeObserver((entries) => {
|
|
|
|
|
|
applyCompact(entries[0]?.contentRect.width || root.clientWidth);
|
|
|
|
|
|
});
|
|
|
|
|
|
sizeWatch.observe(root);
|
|
|
|
|
|
applyCompact(root.clientWidth);
|
|
|
|
|
|
|
2026-08-30 15:03:12 +00:00
|
|
|
|
function applyInstanceSettings() {
|
|
|
|
|
|
const s = moduleApi.instanceSettings;
|
|
|
|
|
|
const size = Math.min(48, Math.max(10, Number(s.textSize) || 15));
|
|
|
|
|
|
wrap.style.setProperty('--pt-size', size + 'px');
|
|
|
|
|
|
wrap.classList.toggle('show-times', Boolean(s.showTimes));
|
2026-09-10 16:40:46 -04:00
|
|
|
|
wrap.classList.toggle('hide-avatars', s.showAvatars === false);
|
2026-08-30 16:39:22 +00:00
|
|
|
|
timeBtn?.classList.toggle('active', Boolean(s.showTimes));
|
2026-09-10 16:40:46 -04:00
|
|
|
|
|
|
|
|
|
|
const up = newestFirst();
|
|
|
|
|
|
wrap.classList.toggle('newest-first', up);
|
|
|
|
|
|
flowBtn.innerHTML = up ? FLOW_UP_SVG : FLOW_DOWN_SVG;
|
|
|
|
|
|
flowBtn.classList.toggle('active', up);
|
2026-09-15 10:49:58 -04:00
|
|
|
|
flowBtn.title = up ? 'Newest first' : 'Oldest first';
|
2026-09-10 16:40:46 -04:00
|
|
|
|
jumpBtn.textContent = (up ? '↑' : '↓') + ' New messages';
|
2026-09-30 14:06:52 -04:00
|
|
|
|
rebuildMoreMenu(); // its checkmarks mirror the buttons
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
applyInstanceSettings();
|
|
|
|
|
|
|
|
|
|
|
|
/* ── channel visibility toggles ─────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function persistHidden() {
|
|
|
|
|
|
moduleApi.saveInstanceSettings({ hiddenChannels: [...hiddenChannels] });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyFilter(el) {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
el.classList.toggle('hidden-by-filter', hiddenChannels.has(el.dataset.channelId));
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toggleChannel(id) {
|
|
|
|
|
|
if (hiddenChannels.has(id)) hiddenChannels.delete(id);
|
|
|
|
|
|
else hiddenChannels.add(id);
|
|
|
|
|
|
persistHidden();
|
|
|
|
|
|
rebuildChannelMenu();
|
|
|
|
|
|
entryEls.forEach((el) => applyFilter(el));
|
2026-09-10 16:40:46 -04:00
|
|
|
|
if (pinnedToLatest) scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Show exactly this group's channels, hide the rest */
|
|
|
|
|
|
function applyGroup(g) {
|
|
|
|
|
|
hiddenChannels = new Set([...channels.keys()].filter((cid) => !g.channelIds.includes(cid)));
|
|
|
|
|
|
persistHidden();
|
|
|
|
|
|
rebuildChannelMenu();
|
|
|
|
|
|
entryEls.forEach((el) => applyFilter(el));
|
2026-09-10 16:40:46 -04:00
|
|
|
|
if (pinnedToLatest) scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function groupIsActive(g) {
|
|
|
|
|
|
const members = g.channelIds.filter((cid) => channels.has(cid));
|
|
|
|
|
|
if (!members.length) return false;
|
|
|
|
|
|
return [...channels.keys()].every((cid) =>
|
|
|
|
|
|
members.includes(cid) ? !hiddenChannels.has(cid) : hiddenChannels.has(cid)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-10 16:40:46 -04:00
|
|
|
|
/* The button is an icon, so the "some channels are hidden" state shows as
|
|
|
|
|
|
an accent tint plus a compact count beside it. */
|
|
|
|
|
|
function updateChannelsButton() {
|
2026-08-30 16:39:22 +00:00
|
|
|
|
let visibleCount = 0;
|
|
|
|
|
|
for (const id of channels.keys()) {
|
|
|
|
|
|
if (!hiddenChannels.has(id)) visibleCount++;
|
|
|
|
|
|
}
|
2026-09-10 16:40:46 -04:00
|
|
|
|
const filtering = channels.size > 0 && visibleCount !== channels.size;
|
|
|
|
|
|
channelCountEl.textContent = filtering ? `${visibleCount}/${channels.size}` : '';
|
|
|
|
|
|
channelCountEl.hidden = !filtering;
|
|
|
|
|
|
channelsMenu.button.classList.toggle('active', filtering);
|
2026-09-15 10:49:58 -04:00
|
|
|
|
channelsMenu.button.title = filtering ? `Channels ${visibleCount}/${channels.size}` : 'Channels';
|
2026-08-30 16:39:22 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 15:03:12 +00:00
|
|
|
|
function rebuildChannelMenu() {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
updateChannelsButton();
|
2026-08-30 16:39:22 +00:00
|
|
|
|
const channelMenu = channelMenuEl;
|
|
|
|
|
|
if (!channelMenu) return; // menu not opened yet — the label is enough
|
2026-08-30 15:03:12 +00:00
|
|
|
|
channelMenu.innerHTML = '';
|
|
|
|
|
|
|
|
|
|
|
|
if (groups.size) {
|
|
|
|
|
|
for (const g of groups.values()) {
|
|
|
|
|
|
const on = groupIsActive(g);
|
|
|
|
|
|
const item = document.createElement('button');
|
|
|
|
|
|
item.className = 'pt-channel-item pt-group-item' + (on ? ' on' : '');
|
|
|
|
|
|
item.setAttribute('role', 'menuitemradio');
|
|
|
|
|
|
item.setAttribute('aria-checked', String(on));
|
|
|
|
|
|
const check = document.createElement('span');
|
|
|
|
|
|
check.className = 'check';
|
|
|
|
|
|
check.textContent = '✓';
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
|
name.className = 'name';
|
|
|
|
|
|
name.textContent = g.name;
|
|
|
|
|
|
item.append(check, name);
|
|
|
|
|
|
item.addEventListener('click', (e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
applyGroup(g);
|
|
|
|
|
|
});
|
|
|
|
|
|
channelMenu.appendChild(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
const divider = document.createElement('div');
|
|
|
|
|
|
divider.className = 'pt-menu-divider';
|
|
|
|
|
|
channelMenu.appendChild(divider);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [id, info] of channels) {
|
|
|
|
|
|
const on = !hiddenChannels.has(id);
|
|
|
|
|
|
const item = document.createElement('button');
|
|
|
|
|
|
item.className = 'pt-channel-item' + (on ? ' on' : '');
|
|
|
|
|
|
item.setAttribute('role', 'menuitemcheckbox');
|
|
|
|
|
|
item.setAttribute('aria-checked', String(on));
|
|
|
|
|
|
const check = document.createElement('span');
|
|
|
|
|
|
check.className = 'check';
|
|
|
|
|
|
check.textContent = '✓';
|
|
|
|
|
|
const name = document.createElement('span');
|
|
|
|
|
|
name.className = 'name';
|
|
|
|
|
|
name.textContent = info.name;
|
|
|
|
|
|
name.style.color = info.color;
|
|
|
|
|
|
item.append(check, name);
|
|
|
|
|
|
item.addEventListener('click', (e) => {
|
|
|
|
|
|
// keep the menu open: the rebuild detaches this item, which would
|
|
|
|
|
|
// make the outside-click handler think we clicked outside
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
toggleChannel(id);
|
|
|
|
|
|
});
|
|
|
|
|
|
channelMenu.appendChild(item);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── clear (local view only) ────────────────────────────────────── */
|
|
|
|
|
|
|
2026-08-30 16:39:22 +00:00
|
|
|
|
function clearView() {
|
2026-08-30 15:03:12 +00:00
|
|
|
|
entryEls.forEach((el) => el.remove());
|
|
|
|
|
|
entryEls.clear();
|
|
|
|
|
|
emptyEl.hidden = false;
|
2026-08-30 16:39:22 +00:00
|
|
|
|
}
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
|
|
|
|
|
/* ── scrolling ──────────────────────────────────────────────────── */
|
|
|
|
|
|
|
2026-09-10 16:40:46 -04:00
|
|
|
|
/** Newest entry at the top (bottom-up) instead of at the bottom? */
|
|
|
|
|
|
function newestFirst() {
|
|
|
|
|
|
return Boolean(moduleApi.instanceSettings.newestFirst);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 15:03:12 +00:00
|
|
|
|
streamEl.addEventListener('scroll', () => {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
const near = newestFirst()
|
|
|
|
|
|
? streamEl.scrollTop < 60
|
|
|
|
|
|
: streamEl.scrollHeight - streamEl.scrollTop - streamEl.clientHeight < 60;
|
|
|
|
|
|
pinnedToLatest = near;
|
|
|
|
|
|
if (near) jumpBtn.hidden = true;
|
2026-08-30 15:03:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
jumpBtn.addEventListener('click', () => {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
pinnedToLatest = true;
|
2026-08-30 15:03:12 +00:00
|
|
|
|
jumpBtn.hidden = true;
|
2026-09-10 16:40:46 -04:00
|
|
|
|
scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-10 16:40:46 -04:00
|
|
|
|
/** The latest entry is at the top when flipped, at the bottom otherwise. */
|
|
|
|
|
|
function scrollToLatest() {
|
|
|
|
|
|
streamEl.scrollTop = newestFirst() ? 0 : streamEl.scrollHeight;
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function afterInsert(el) {
|
|
|
|
|
|
if (el.classList.contains('hidden-by-filter')) return;
|
2026-09-10 16:40:46 -04:00
|
|
|
|
if (pinnedToLatest) scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
else jumpBtn.hidden = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── entry rendering ────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function channelInfo(entry) {
|
|
|
|
|
|
const known = channels.get(entry.channelId);
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: (known && known.name) || entry.channelName || 'Unknown',
|
|
|
|
|
|
color: (known && known.color) || 'var(--muted)',
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtTime(iso) {
|
|
|
|
|
|
const d = iso ? new Date(iso) : new Date();
|
|
|
|
|
|
return isNaN(d) ? '' : d.toLocaleTimeString([], { hour12: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function upsertEntry(entry) {
|
|
|
|
|
|
if (!entry || typeof entry.text !== 'string') return;
|
|
|
|
|
|
const id = entry.id || entry.channelId + '|' + entry.date;
|
|
|
|
|
|
|
|
|
|
|
|
let el = entryEls.get(id);
|
|
|
|
|
|
if (!el) {
|
|
|
|
|
|
emptyEl.hidden = true;
|
|
|
|
|
|
el = document.createElement('div');
|
|
|
|
|
|
el.className = 'pt-entry';
|
|
|
|
|
|
el.dataset.channelId = entry.channelId || '';
|
|
|
|
|
|
const info = channelInfo(entry);
|
|
|
|
|
|
el.style.setProperty('--ch', info.color);
|
|
|
|
|
|
|
|
|
|
|
|
const time = document.createElement('span');
|
|
|
|
|
|
time.className = 'time';
|
|
|
|
|
|
time.textContent = fmtTime(entry.date);
|
|
|
|
|
|
|
|
|
|
|
|
const avatar = document.createElement('span');
|
|
|
|
|
|
avatar.className = 'avatar';
|
|
|
|
|
|
avatar.style.color = info.color;
|
2026-10-05 13:55:55 -04:00
|
|
|
|
// speech is a person; typed text (from a dashboard, or ProdCom's own
|
|
|
|
|
|
// box) a keyboard; an automation a bolt
|
|
|
|
|
|
avatar.innerHTML = entry.source === 'typed'
|
|
|
|
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
|
|
|
|
'<rect x="3" y="7" width="18" height="11" rx="2"/><path d="M7 11h1M11 11h1M15 11h1M8 14.5h8"/></svg>'
|
|
|
|
|
|
: entry.source === 'automation'
|
|
|
|
|
|
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
|
|
|
|
'<path d="M13 3 5 14h6l-1 7 8-11h-6z"/></svg>'
|
|
|
|
|
|
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">' +
|
|
|
|
|
|
'<circle cx="12" cy="8.5" r="3.2"/>' +
|
|
|
|
|
|
'<path d="M5.5 19c1.6-3.2 3.9-4.8 6.5-4.8s4.9 1.6 6.5 4.8"/></svg>';
|
|
|
|
|
|
if (entry.source === 'typed' || entry.source === 'automation') el.classList.add(entry.source);
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
|
|
|
|
|
const ch = document.createElement('span');
|
|
|
|
|
|
ch.className = 'channel';
|
|
|
|
|
|
ch.textContent = info.name;
|
|
|
|
|
|
ch.style.color = info.color;
|
|
|
|
|
|
|
|
|
|
|
|
const text = document.createElement('span');
|
|
|
|
|
|
text.className = 'text';
|
|
|
|
|
|
|
2026-08-31 20:55:28 +00:00
|
|
|
|
// channel name with its timestamp to the right; the clock button
|
|
|
|
|
|
// only toggles the timestamp's visibility
|
|
|
|
|
|
const chLine = document.createElement('span');
|
|
|
|
|
|
chLine.className = 'channel-line';
|
|
|
|
|
|
chLine.append(ch, time);
|
|
|
|
|
|
|
2026-08-30 15:03:12 +00:00
|
|
|
|
const body = document.createElement('div');
|
|
|
|
|
|
body.className = 'body';
|
2026-08-31 20:55:28 +00:00
|
|
|
|
body.append(chLine, text);
|
2026-08-30 15:03:12 +00:00
|
|
|
|
|
2026-08-31 20:55:28 +00:00
|
|
|
|
el.append(avatar, body);
|
2026-08-30 15:03:12 +00:00
|
|
|
|
entryEls.set(id, el);
|
|
|
|
|
|
applyFilter(el);
|
|
|
|
|
|
entriesEl.appendChild(el);
|
|
|
|
|
|
updateEntryEl(el, entry);
|
|
|
|
|
|
afterInsert(el);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
updateEntryEl(el, entry);
|
2026-09-10 16:40:46 -04:00
|
|
|
|
if (pinnedToLatest && !el.classList.contains('hidden-by-filter')) scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateEntryEl(el, entry) {
|
|
|
|
|
|
el.querySelector('.text').textContent = entry.translatedText || entry.text;
|
|
|
|
|
|
el.classList.toggle('in-progress', entry.inProgress === true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── SSE event parsing ──────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
/* The stream sends JSON objects for new/updated/completed transcript
|
|
|
|
|
|
entries. Be liberal about the envelope shape. */
|
2026-09-15 12:43:08 -04:00
|
|
|
|
/* ── the module's feed: one snapshot, then each change ─────────── */
|
|
|
|
|
|
|
|
|
|
|
|
function applyStatus(st) {
|
|
|
|
|
|
if (!st || typeof st !== 'object') return;
|
2026-10-05 13:55:55 -04:00
|
|
|
|
lastStatus = st;
|
|
|
|
|
|
if (st.state === 'ok') {
|
|
|
|
|
|
// the Send Message tiles point here: typed text goes out as the chosen channel
|
|
|
|
|
|
const as = !isTarget() ? ''
|
|
|
|
|
|
: bus.target.destinationLabel ? ` · sends go out as ${bus.target.destinationLabel}`
|
|
|
|
|
|
: ' · selected — no channel accepts typed text';
|
|
|
|
|
|
moduleApi.setStatus('ok', (st.message || 'Live') + as);
|
|
|
|
|
|
} else moduleApi.setStatus(st.state === 'error' ? 'error' : 'connecting', st.message || 'Connecting to ProdCom…');
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 12:43:08 -04:00
|
|
|
|
function applySnapshot(snap) {
|
|
|
|
|
|
if (!snap || typeof snap !== 'object') return;
|
2026-08-30 15:03:12 +00:00
|
|
|
|
channels.clear();
|
2026-09-15 12:43:08 -04:00
|
|
|
|
for (const ch of snap.channels || []) channels.set(String(ch.id), { name: ch.name, color: ch.color || '#8b98a5' });
|
2026-10-05 13:55:55 -04:00
|
|
|
|
// On the page and able to send: that is what lets the Send Message tiles
|
|
|
|
|
|
// pick ProdCom by default when it is the only transcript showing.
|
|
|
|
|
|
if (channels.size && !bus.transcripts.has(moduleApi.instanceId)) {
|
|
|
|
|
|
bus.transcripts.set(moduleApi.instanceId, { provider: PROVIDER_ID, destination: '' });
|
|
|
|
|
|
emit('transcripts', {});
|
|
|
|
|
|
}
|
|
|
|
|
|
wrap.classList.toggle('is-target', isTarget());
|
2026-08-30 15:03:12 +00:00
|
|
|
|
groups.clear();
|
2026-09-15 12:43:08 -04:00
|
|
|
|
for (const g of snap.groups || []) groups.set(String(g.id), { name: g.name, channelIds: g.channelIds || [] });
|
2026-08-30 15:03:12 +00:00
|
|
|
|
rebuildChannelMenu();
|
2026-09-15 12:43:08 -04:00
|
|
|
|
const list = (snap.entries || []).slice().sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
|
|
|
|
list.forEach(upsertEntry); // dedupes by id, so a snapshot after a reconnect is harmless
|
|
|
|
|
|
applyStatus(snap.status);
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-15 12:43:08 -04:00
|
|
|
|
async function loadState() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await moduleApi.fetch('/state');
|
|
|
|
|
|
if (!res.ok) throw new Error(String(res.status));
|
|
|
|
|
|
applySnapshot(await res.json());
|
|
|
|
|
|
} catch { /* the stream's own snapshot covers it */ }
|
2026-08-30 15:03:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connect() {
|
2026-09-15 12:43:08 -04:00
|
|
|
|
stream = moduleApi.sse('/stream', {
|
2026-08-30 15:03:12 +00:00
|
|
|
|
open() {
|
2026-09-15 12:43:08 -04:00
|
|
|
|
loadState(); // a late joiner, or a reconnect: catch up on what was missed
|
2026-08-30 15:03:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
error() {
|
2026-09-15 12:43:08 -04:00
|
|
|
|
moduleApi.setStatus('error', 'ProdDash ProdCom module unreachable — reconnecting…');
|
|
|
|
|
|
},
|
|
|
|
|
|
events: {
|
|
|
|
|
|
state(ev) {
|
|
|
|
|
|
try { applySnapshot(JSON.parse(ev.data)); } catch { /* a bad frame is skipped */ }
|
|
|
|
|
|
},
|
|
|
|
|
|
entry(ev) {
|
|
|
|
|
|
try { upsertEntry(JSON.parse(ev.data)); } catch { /* skipped */ }
|
|
|
|
|
|
},
|
|
|
|
|
|
clear() {
|
|
|
|
|
|
clearView();
|
|
|
|
|
|
},
|
|
|
|
|
|
status(ev) {
|
|
|
|
|
|
try { applyStatus(JSON.parse(ev.data)); } catch { /* skipped */ }
|
|
|
|
|
|
},
|
2026-08-30 15:03:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ── lifecycle ──────────────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
start() {
|
2026-09-15 12:43:08 -04:00
|
|
|
|
moduleApi.setStatus('connecting', 'Connecting to ProdCom…');
|
|
|
|
|
|
connect();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
stop() {
|
|
|
|
|
|
stopped = true;
|
2026-09-30 14:06:52 -04:00
|
|
|
|
sizeWatch.disconnect();
|
2026-10-05 13:55:55 -04:00
|
|
|
|
offTarget();
|
|
|
|
|
|
if (bus.transcripts.delete(moduleApi.instanceId)) emit('transcripts', {});
|
2026-08-30 15:03:12 +00:00
|
|
|
|
stream?.close();
|
2026-08-30 16:39:22 +00:00
|
|
|
|
root.innerHTML = ''; // header controls are removed by the shell
|
2026-08-30 15:03:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
onResize() {
|
2026-09-10 16:40:46 -04:00
|
|
|
|
if (pinnedToLatest) scrollToLatest();
|
2026-08-30 15:03:12 +00:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|