Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 c379025d30 README: the Slack module joins the list
Every shipped module had an entry under "Included modules" except Slack,
which wasn't mentioned anywhere in the README — so the one module that can
send as the campus was also the one nobody could read about.

Its entry now sits with the others, in their shape: what it does, the tiles
it offers in the + picker (one transcript per configured channel plus the
two single send surfaces), its four admin groups and its tile settings —
all taken from modules/slack/module.json and client.js.

The testing paragraph listed the ProdCom, ProPresenter and PCO mocks but
not the Slack one, though CLAUDE.md has documented it all along; it is now
there with the SLACK_API_BASE line it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:10:03 -04:00
7 changed files with 58 additions and 174 deletions
+28 -9
View File
@@ -231,13 +231,7 @@ below. Nothing is installed without someone clicking.
Tile settings: channel icons, direction and timestamps. Tile settings: channel icons, direction and timestamps.
- **ProPresenter** — what's live and what's next, with slide progress, - **ProPresenter** — what's live and what's next, with slide progress,
driven by the shared `propresenter-core` client (ProPresenter 7/20). driven by the shared `propresenter-core` client (ProPresenter 7/20).
The + picker offers two tiles: **Now / Next**, the full card, and **Now**,
the live item alone with the gradient filling the tile edge to edge — a
wall panel that says one thing.
Admin config: ProPresenter endpoint (host : port), password. Admin config: ProPresenter endpoint (host : port), password.
Tile settings: a **Show** group of switches for the playlist name, live
status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
@@ -271,6 +265,29 @@ below. Nothing is installed without someone clicking.
(gear menu): the top-bar text (placeholders such as (gear menu): the top-bar text (placeholders such as
`{series} • {part} | {serviceType} • {date}`), colors, text size, and a `{series} • {part} | {serviceType} • {date}`), colors, text size, and a
show/hide checklist for every detail. show/hide checklist for every detail.
- **Slack** — live transcripts of this location's Slack channels, plus
PIN-protected sending as the location. The + picker lists one transcript
tile per configured channel, titled `#channel`, and two send surfaces (one
of each per dashboard): **Send message**, a free-text box with the admin's
quick emojis one press away, and **Quick replies**, a grid of preset
buttons. Both post to the channel of whichever transcript was clicked last
— its bottom edge lights up in the accent color — and to the first
configured channel until one is clicked. Messages arrive with avatars,
names and times, Slack's inline formatting and the workspace's custom
emoji; a right-click on one opens the reaction menu, and text size and
jump-to-latest sit in the title bar. Nothing leaves the machine until the
PIN is entered: one PIN unlocks every Slack tile on that dashboard page
until the auto-lock idles out, a reload asks again, and the lock button on
a send tile's title bar ends it early.
Admin config is in four groups: **Slack account** (the location account's
User OAuth Token — `xoxp-…`, so messages are literally from the campus —
or a bot token plus "Send as"), **Channels** (the joined channels to
follow, one transcript tile each), **Transcript** (refresh interval,
messages kept, thread replies) and **Sending** (the switch on the group's
title row, a 4–8 digit PIN, auto-lock minutes, quick replies one per line
as `Label | Message` — add `| #channel` to bind one to a channel — and up
to eight quick emojis).
Tile settings: avatars, timestamps, names and channel notices.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
@@ -278,10 +295,12 @@ To develop modules against mocks instead of real gear:
`node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`), `node tools/prodcom-mock.js` (ProdCom on `127.0.0.1:24480`),
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter slides on
`127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter `127.0.0.1:1599`), `node tools/pp-timers-mock.js 1600` (ProPresenter
timers + timecode on `127.0.0.1:1600`) and `node tools/pco-mock.js` timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
(Planning Center Services on `127.0.0.1:24700` — run ProdDash with (Planning Center Services on `127.0.0.1:24700` — run ProdDash with
`PCO_PLAN_API_BASE=http://127.0.0.1:24700`). See the testing section of the `PCO_PLAN_API_BASE=http://127.0.0.1:24700`) and
[module guide](docs/MODULE-GUIDE.md). `node tools/slack-mock.js 24716` (the Slack Web API on `127.0.0.1:24716` —
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). See the
testing section of the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app ## Install as a desktop web app
+9 -39
View File
@@ -6,12 +6,6 @@
in /admin; the tile only renders. */ in /admin; the tile only renders. */
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
/* "now" is the live item on its own, filling the tile; "full" is that plus
the rest of the playlist. The picker presets `view`; a tile placed before
this module had a picker has neither, and gets the full card. */
const view = String(moduleApi.instanceSettings.view || moduleApi.variant || 'full');
const nowOnly = view === 'now';
let state = null; let state = null;
let feedOffline = false; let feedOffline = false;
let stream = null; let stream = null;
@@ -39,13 +33,11 @@ export default function create({ root, moduleApi }) {
</div>`; </div>`;
const screenEl = root.querySelector('.nn-screen'); const screenEl = root.querySelector('.nn-screen');
if (nowOnly) screenEl.classList.add('nn-only-now');
const topRow = root.querySelector('.nn-top'); const topRow = root.querySelector('.nn-top');
const playlistNameEl = root.querySelector('.nn-playlist'); const playlistNameEl = root.querySelector('.nn-playlist');
const liveEl = root.querySelector('.nn-live'); const liveEl = root.querySelector('.nn-live');
const liveTextEl = root.querySelector('.nn-live-text'); const liveTextEl = root.querySelector('.nn-live-text');
const nowEl = root.querySelector('.nn-now'); const nowEl = root.querySelector('.nn-now');
const nowLabelEl = root.querySelector('.nn-label');
const nowTitleBox = root.querySelector('.nn-title-box'); const nowTitleBox = root.querySelector('.nn-title-box');
let nowTitleEl = root.querySelector('.nn-title'); let nowTitleEl = root.querySelector('.nn-title');
const nowMetaEl = root.querySelector('.nn-meta'); const nowMetaEl = root.querySelector('.nn-meta');
@@ -55,11 +47,9 @@ export default function create({ root, moduleApi }) {
const nextListEl = root.querySelector('.nn-next-list'); const nextListEl = root.querySelector('.nn-next-list');
const nextMoreEl = root.querySelector('.nn-next-more'); const nextMoreEl = root.querySelector('.nn-next-more');
/** A setting, defaulting to on — a Now tile's schema omits the Next ones. */
function prefs() { function prefs() {
return moduleApi.instanceSettings; return moduleApi.instanceSettings; // showPlaylist / showProgress / cleanTitles
} }
const on = (key) => prefs()[key] !== false;
/* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */ /* ── title tidying (same rule as propresenter-core.cleanItemName) ─ */
@@ -75,7 +65,7 @@ export default function create({ root, moduleApi }) {
} }
function displayName(raw) { function displayName(raw) {
return on('cleanTitles') ? cleanItemName(raw) : String(raw || '').trim(); return prefs().cleanTitles ? cleanItemName(raw) : String(raw || '').trim();
} }
function itemTypeTag(type) { function itemTypeTag(type) {
@@ -317,9 +307,7 @@ export default function create({ root, moduleApi }) {
const total = state.currentSlideTotal || activeItem?.slideCount || 0; const total = state.currentSlideTotal || activeItem?.slideCount || 0;
const current = state.currentSlide || 0; const current = state.currentSlide || 0;
if (!on('showSlideCount')) { if (total > 0 && current > 0) {
nowMetaEl.textContent = '';
} else if (total > 0 && current > 0) {
const remaining = Math.max(0, total - current); const remaining = Math.max(0, total - current);
nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`;
} else if (total > 0) { } else if (total > 0) {
@@ -330,7 +318,7 @@ export default function create({ root, moduleApi }) {
nowMetaEl.textContent = ''; nowMetaEl.textContent = '';
} }
if (on('showProgress') && total > 0) { if (prefs().showProgress && total > 0) {
progressEl.hidden = false; progressEl.hidden = false;
const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0;
progressFillEl.style.width = `${pct}%`; progressFillEl.style.width = `${pct}%`;
@@ -361,10 +349,6 @@ export default function create({ root, moduleApi }) {
nameEl.textContent = displayName(item.name) || '(untitled)'; nameEl.textContent = displayName(item.name) || '(untitled)';
row.appendChild(nameEl); row.appendChild(nameEl);
if (!on('showTags')) {
nextListEl.appendChild(row);
continue;
}
if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { if (item.type === 'presentation' && Number.isInteger(item.slideCount)) {
const chip = document.createElement('div'); const chip = document.createElement('div');
chip.className = 'nn-count-chip'; chip.className = 'nn-count-chip';
@@ -382,7 +366,7 @@ export default function create({ root, moduleApi }) {
nextListEl.appendChild(row); nextListEl.appendChild(row);
} }
nextLabelEl.hidden = upcoming.length === 0 || !on('showNextLabel'); nextLabelEl.hidden = upcoming.length === 0;
nextMoreEl.hidden = true; nextMoreEl.hidden = true;
markNextOverflow(); markNextOverflow();
requestAnimationFrame(markNextOverflow); requestAnimationFrame(markNextOverflow);
@@ -390,22 +374,12 @@ export default function create({ root, moduleApi }) {
function render() { function render() {
screenEl.classList.toggle('is-offline', feedOffline); screenEl.classList.toggle('is-offline', feedOffline);
// Trouble always shows, whatever is switched off: a tile that has stopped const showTop = prefs().showPlaylist || feedOffline || !state?.reachable || !state?.enabled;
// updating must never look like one with nothing live. topRow.classList.toggle('hidden-row', !showTop);
const trouble = feedOffline || !state?.reachable || !state?.enabled; playlistNameEl.textContent = prefs().showPlaylist ? state?.playlistName || '' : '';
const wantPlaylist = on('showPlaylist');
const wantLive = on('showLive') || trouble;
topRow.classList.toggle('hidden-row', !wantPlaylist && !wantLive);
liveEl.hidden = !wantLive;
// Hidden, not just emptied: it is flex:1, so an emptied one still holds
// the row open and pushes the live dot off the right edge.
playlistNameEl.hidden = !wantPlaylist;
playlistNameEl.textContent = state?.playlistName || '';
nowLabelEl.hidden = !on('showNowLabel');
screenEl.classList.toggle('nn-no-next', nowOnly || !on('showNext'));
renderLive(); renderLive();
renderNow(); renderNow();
if (!nowOnly && on('showNext')) renderNext(); renderNext();
} }
/* ── sizing: --unit scales the whole card to the tile ───────────── */ /* ── sizing: --unit scales the whole card to the tile ───────────── */
@@ -413,10 +387,6 @@ export default function create({ root, moduleApi }) {
function applyUnit() { function applyUnit() {
const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032)); const unit = Math.max(6.5, Math.min(20, root.clientHeight * 0.032));
screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px'); screenEl.style.setProperty('--unit', unit.toFixed(2) + 'px');
// On a Now tile the top row floats over the gradient, so at the shortest
// sizes it would sit on the title. Below the height it needs, it goes —
// the header dot and the title itself still report trouble.
screenEl.classList.toggle('nn-tight', nowOnly && root.clientHeight < unit * 9);
} }
/* ── live feed ──────────────────────────────────────────────────── */ /* ── live feed ──────────────────────────────────────────────────── */
+6 -15
View File
@@ -1,8 +1,8 @@
{ {
"id": "propresenter-now-next", "id": "propresenter-now-next",
"name": "ProPresenter", "name": "ProPresenter",
"version": "1.1.0", "version": "1.0.1",
"proddash": ">=1.5.0", "proddash": ">=1.1.0",
"description": "What is live in ProPresenter and what comes next", "description": "What is live in ProPresenter and what comes next",
"permissions": [ "permissions": [
{ "kind": "local-network", "reason": "Reaching ProPresenter on this network" } { "kind": "local-network", "reason": "Reaching ProPresenter on this network" }
@@ -20,22 +20,13 @@
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "Password", "label": "API password (only if set in ProPresenter)",
"default": "" "default": ""
} }
}, },
"instanceSchema": { "instanceSchema": {
"cleanTitles": { "type": "switch", "label": "Tidy titles", "default": true }, "showPlaylist": { "type": "boolean", "label": "Show the playlist name", "default": true },
"showPlaylist": { "type": "switch", "label": "Playlist", "default": true, "group": "Show" }, "showProgress": { "type": "boolean", "label": "Show the slide progress bar", "default": true },
"showLive": { "type": "switch", "label": "Live status", "default": true, "group": "Show" }, "cleanTitles": { "type": "boolean", "label": "Tidy titles (hide [ Full ] tags and date codes)", "default": true }
"showNowLabel": { "type": "switch", "label": "Now label", "default": true, "group": "Show" },
"showSlideCount": { "type": "switch", "label": "Slide count", "default": true, "group": "Show" },
"showProgress": { "type": "switch", "label": "Progress bar", "default": true, "group": "Show" },
"showNext": { "type": "switch", "label": "Next list", "default": true, "group": "Show" },
"showNextLabel": { "type": "switch", "label": "Next label", "default": true, "group": "Show" },
"showTags": { "type": "switch", "label": "Item tags", "default": true, "group": "Show" }
},
"instanceGroups": {
"Show": { "collapsed": false, "columns": 2 }
} }
} }
-37
View File
@@ -108,43 +108,6 @@ module.exports = {
}; };
}, },
/**
* Two ways to place this module. "Now / Next" is the full card — what is
* live, then the rest of the playlist. "Now" is the same live item alone,
* edge to edge with the gradient filling the tile: a wall display that says
* one thing. The Now entry offers only the settings it can act on, so its
* gear carries no Next switches.
*/
tiles() {
return [
{
id: 'full',
name: 'Now / Next',
description: 'What is live, and the rest of the playlist',
defaultSize: { w: 4, h: 5 },
minSize: { w: 3, h: 3 },
settings: { view: 'full' },
},
{
id: 'now',
name: 'Now',
description: 'Only what is live, filling the tile',
defaultSize: { w: 4, h: 2 },
minSize: { w: 2, h: 1 },
settings: { view: 'now' },
instanceSchema: {
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
},
instanceGroups: { Show: { collapsed: false, columns: 2 } },
},
];
},
routes() { routes() {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
-48
View File
@@ -21,51 +21,6 @@
justify-content: center; justify-content: center;
} }
/* NEXT switched off, or a Now tile: the list goes and the card takes the room. */
[data-module="propresenter-now-next"] .nn-screen.nn-no-next .nn-next-wrap { display: none; }
[data-module="propresenter-now-next"] .nn-screen.nn-no-next { justify-content: center; }
/* ── the "Now" tile ──
Only what is live, edge to edge: the gradient moves off the card and onto
the whole tile, the card loses its border, radius and box, and the title
sits in the middle of everything. Nothing is inset, so a wall of these
reads as panels of colour rather than cards on a background. */
[data-module="propresenter-now-next"] .nn-screen.nn-only-now {
gap: 0;
background: linear-gradient(
160deg,
color-mix(in srgb, var(--accent) 14%, transparent),
color-mix(in srgb, var(--bg) 70%, transparent)
);
}
[data-module="propresenter-now-next"] .nn-only-now .nn-now {
flex: 1;
height: auto;
min-height: 0;
justify-content: center;
border: none;
border-radius: 0;
background: none;
padding: 0;
}
/* The live dot and playlist ride over the gradient rather than above it. */
[data-module="propresenter-now-next"] .nn-only-now .nn-top {
position: absolute;
inset: var(--nn-pad) var(--nn-pad) auto;
pointer-events: none;
}
[data-module="propresenter-now-next"] .nn-only-now .nn-title-box { margin-top: 0; }
/* The overlay floats, so it takes no room — this keeps the title clear of it
and still centred, by holding back as much at the bottom as at the top. */
[data-module="propresenter-now-next"] .nn-only-now:not(.nn-tight) .nn-now {
padding: calc(var(--unit) * 1.4) 0;
}
/* Too short for the overlay: the title gets the whole panel to itself. */
[data-module="propresenter-now-next"] .nn-only-now.nn-tight .nn-top,
[data-module="propresenter-now-next"] .nn-only-now.nn-tight.is-offline .nn-top {
display: none !important;
}
/* ── top row ── */ /* ── top row ── */
[data-module="propresenter-now-next"] .nn-top { [data-module="propresenter-now-next"] .nn-top {
@@ -104,7 +59,6 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
[data-module="propresenter-now-next"] .nn-playlist[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live { [data-module="propresenter-now-next"] .nn-live {
display: flex; display: flex;
@@ -117,7 +71,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
} }
[data-module="propresenter-now-next"] .nn-live[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-live-dot { [data-module="propresenter-now-next"] .nn-live-dot {
width: calc(var(--unit) * 0.6); width: calc(var(--unit) * 0.6);
@@ -169,7 +122,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent);
} }
[data-module="propresenter-now-next"] .nn-label[hidden] { display: none; }
[data-module="propresenter-now-next"] .nn-title-box { [data-module="propresenter-now-next"] .nn-title-box {
flex: 1; flex: 1;
+5 -16
View File
@@ -141,17 +141,13 @@ const sendTarget = (() => {
/** /**
* Where a send goes, given the configured channels in admin order: the * Where a send goes, given the configured channels in admin order: the
* selection while it is still configured and its transcript is on the * selection while it is still configured and its transcript is on the
* page, else the first configured channel that IS on this page — with one * page; otherwise the first configured channel.
* transcript up, that one — and only if the page shows none, the first
* configured channel. A send must never quietly go to a channel the
* operator cannot see.
*/ */
resolve(configured) { resolve(configured) {
const list = Array.isArray(configured) ? configured : []; const list = Array.isArray(configured) ? configured : [];
if (!list.length) return ''; if (!list.length) return '';
if (id && list.includes(id) && this.hasTranscript(id)) return id; if (id && list.includes(id) && this.hasTranscript(id)) return id;
const onPage = list.find((c) => this.hasTranscript(c)); return list[0];
return onPage || list[0];
}, },
on(fn) { on(fn) {
bus.addEventListener('change', fn); bus.addEventListener('change', fn);
@@ -1104,7 +1100,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (stopped || !state) return; if (stopped || !state) return;
if (token) { if (token) {
if (composeEls) { if (composeEls) {
if (composeEls.line) composeEls.line.textContent = sendingLine(); composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
@@ -1181,21 +1177,14 @@ function createSendTile({ root, moduleApi, mode }) {
let composeEls = null; let composeEls = null;
function buildCompose() { function buildCompose() {
compose.textContent = ''; compose.textContent = '';
// Send message names the channel in the box's own placeholder and locks
// from the title bar, so a line above the field would only say both
// again. Quick replies has no placeholder — there it is the only place
// the target is named, so it stays.
let line = null;
if (mode !== 'send') {
const head = el('div', 'sl-compose-head'); const head = el('div', 'sl-compose-head');
line = el('span', 'sl-sending', sendingLine()); const line = el('span', 'sl-sending', sendingLine());
const lockLink = el('button', 'sl-lock-link', 'Lock'); const lockLink = el('button', 'sl-lock-link', 'Lock');
lockLink.type = 'button'; lockLink.type = 'button';
lockLink.title = 'Lock now'; lockLink.title = 'Lock now';
lockLink.addEventListener('click', () => lock('Locked')); lockLink.addEventListener('click', () => lock('Locked'));
head.append(line, lockLink); head.append(line, lockLink);
compose.appendChild(head); compose.appendChild(head);
}
const foot = el('div', 'sl-compose-foot'); const foot = el('div', 'sl-compose-foot');
const msg = el('div', 'sl-msg'); const msg = el('div', 'sl-msg');
@@ -1473,7 +1462,7 @@ function createSendTile({ root, moduleApi, mode }) {
if (!s.sending?.enabled || !s.sending?.pinSet) return void lock(''); if (!s.sending?.enabled || !s.sending?.pinSet) return void lock('');
if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared if (!composeEls) return void showCompose(); // the page was unlocked before this tile appeared
if (composeEls) { if (composeEls) {
if (composeEls.line) composeEls.line.textContent = sendingLine(); composeEls.line.textContent = sendingLine();
if (composeEls.input) composeEls.input.placeholder = placeholder(); if (composeEls.input) composeEls.input.placeholder = placeholder();
} }
} else renderGate(); } else renderGate();
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"id": "slack", "id": "slack",
"name": "Slack", "name": "Slack",
"version": "1.2.1", "version": "1.2.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location", "description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location",
"client": "client.js", "client": "client.js",
@@ -21,7 +21,7 @@
"help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location." "help": "Create a Slack app in the workspace (api.slack.com/apps → Create New App → From scratch) and, under OAuth & Permissions → User Token Scopes, add channels:history, channels:read, groups:history, groups:read, chat:write, users:read, reactions:write (emoji reactions) and emoji:read (the workspace's custom emoji). Sign in to Slack as this location's account — the one named for the campus — open the app's OAuth & Permissions page and press Install to Workspace (or Reinstall to Workspace), then copy the User OAuth Token (xoxp-…) it shows and paste it here: messages from this machine are then literally from the location. A bot token (xoxb-…) works too; it posts as the app, with \"Send as\" naming the location."
}, },
"Channels": { "Channels": {
"help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and until one is clicked, to the first channel here whose transcript is on that dashboard: with a single transcript up, that one. A send never goes to a channel the dashboard is not showing. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit." "help": "Every channel picked here becomes its own transcript tile in the + picker, titled with the channel's name. The Send message and Quick replies tiles post to whichever transcript was clicked last — its bottom edge lights up in the accent colour — and to the first channel here until one is clicked. Only channels the account has joined are listed; join one in Slack first. Each channel is read on its own schedule, all of them together kept inside Slack's rate limit."
}, },
"Sending": { "Sending": {
"toggle": "sendEnabled", "toggle": "sendEnabled",
@@ -49,7 +49,7 @@
"default": [], "default": [],
"group": "Channels", "group": "Channels",
"optionsRoute": "/channels", "optionsRoute": "/channels",
"help": "One transcript tile per channel; until a transcript is clicked, sends go to the first channel here that the dashboard is actually showing. Only channels the account has joined are listed — join one in Slack first." "help": "One transcript tile per channel; the first one is where sends go until a transcript is clicked. Only channels the account has joined are listed — join one in Slack first."
}, },
"pollSeconds": { "pollSeconds": {
"type": "number", "type": "number",