diff --git a/modules/slack/EMOJI-DATA-LICENSE b/modules/slack/EMOJI-DATA-LICENSE new file mode 100644 index 0000000..85ddb3c --- /dev/null +++ b/modules/slack/EMOJI-DATA-LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Cal Henderson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/slack/client.js b/modules/slack/client.js index 9c6e60e..b23d04c 100644 --- a/modules/slack/client.js +++ b/modules/slack/client.js @@ -29,13 +29,30 @@ function fmtTime(ms) { 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) container.appendChild(document.createTextNode(s.slice(last, m.index))); + if (m.index > last) appendText(container, s.slice(last, m.index)); const tok = m[0]; let node; if (tok.startsWith('```')) { @@ -50,7 +67,7 @@ function renderRich(container, text, depth = 0) { last = m.index + tok.length; } } - if (last < s.length) container.appendChild(document.createTextNode(s.slice(last))); + if (last < s.length) appendText(container, s.slice(last)); } function textSizePx(moduleApi, fallback = 14) { @@ -70,7 +87,6 @@ const NO_CHANNEL = 'No channel configured — pick channels in Admin → Slack const LOCK_SVG = ''; const JUMP_SVG = ''; const BACK_SVG = ''; -const CHECK_SVG = ''; /* ── the send target ────────────────────────────────────────────────── */ @@ -140,6 +156,483 @@ const sendTarget = (() => { }; })(); +/* ── 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 unlock: one PIN entry serves every Slack tile on this page ── */ +/* The same kind of thing as sendTarget — a fact about the whole page, not + one tile: the operator has entered the PIN. It holds the server's unlock + token in memory only (a reload asks again) so that unlocking Send message + also unlocks Quick replies and lets a transcript react. */ +const unlockStore = (() => { + const bus = new EventTarget(); + let token = ''; + let expiresAt = 0; + let reason = ''; + return { + get() { + return token && expiresAt > Date.now() ? token : ''; + }, + expiresAt: () => expiresAt, + reason: () => reason, + set(t, exp) { + token = String(t || ''); + expiresAt = Number(exp) || 0; + reason = ''; + bus.dispatchEvent(new Event('change')); + }, + /** A send or reaction slid the server's expiry along. */ + touch(exp) { + if (token && Number(exp) > 0) expiresAt = Number(exp); + }, + clear(why = '') { + if (!token) return; + token = ''; + expiresAt = 0; + reason = why; + bus.dispatchEvent(new Event('change')); + }, + on(fn) { + bus.addEventListener('change', fn); + return () => bus.removeEventListener('change', fn); + }, + }; +})(); + +/** + * The PIN pad: 1–9, 0 and ⌫ under a row of dots, one per digit of the PIN. + * It submits by itself once as many digits as the PIN has are in (the + * server says how many, never which), or at eight. On success the page-wide + * unlock is set and `onUnlocked` runs. + */ +function createPinPad({ moduleApi, heading = 'Enter PIN', subtitle, pinLength, onUnlocked }) { + let pinBuf = ''; + let busy = false; + let error = ''; + let note = ''; + const pad = el('div', 'sl-pad'); + const head = el('div', 'sl-pad-head'); + const sub = el('span', 'sl-pad-sub'); + head.append(el('b', '', heading), sub); + const dots = el('div', 'sl-dots'); + for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sl-dot')); + const keys = el('div', 'sl-keys'); + const key = (label, cls, onClick, html) => { + const b = el('button', 'sl-key' + (cls ? ' ' + cls : '')); + b.type = 'button'; + if (html) b.innerHTML = html; // our own SVG constants only + else b.textContent = label; + b.addEventListener('click', (e) => { + e.preventDefault(); + onClick(); + }); + return b; + }; + for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => press(d))); + keys.appendChild(el('span', 'sl-key sl-key-blank')); + keys.appendChild(key('0', '', () => press('0'))); + const back = key('⌫', 'sl-key-back', backspace, BACK_SVG); + back.title = 'Backspace'; + keys.appendChild(back); + const msg = el('div', 'sl-pad-msg'); + // Shown instead of the keys when the tile is too short for them (style.css). + const hint = el('div', 'sl-pad-hint', 'Type the PIN, or make the tile taller for a keypad.'); + pad.append(head, dots, keys, hint, msg); + + const wanted = () => Math.min(8, Math.max(0, Math.trunc(Number(pinLength?.()) || 0))); + + function refresh() { + const text = subtitle ? String(subtitle() || '') : ''; + sub.textContent = text ? ` · ${text}` : ''; + const n = Math.min(8, pinBuf.length); + // One dot per digit of the PIN; while its length is unknown, 4 and one spare. + const slots = wanted() || Math.max(4, n + (n < 8 ? 1 : 0)); + [...dots.children].forEach((d, i) => { + d.classList.toggle('on', i < n); + d.hidden = i >= slots; + }); + pad.classList.toggle('is-busy', busy); + msg.textContent = error || note; + msg.classList.toggle('is-error', Boolean(error)); + } + + function press(d) { + if (busy || pinBuf.length >= 8) return; + pinBuf += d; + error = ''; + refresh(); + const len = wanted(); + if ((len && pinBuf.length >= len) || pinBuf.length === 8) submit(); + } + + function backspace() { + if (busy) return; + pinBuf = pinBuf.slice(0, -1); + error = ''; + refresh(); + } + + function shake(message) { + error = message; + note = ''; + pinBuf = ''; + refresh(); + pad.classList.remove('sl-shake'); + void pad.offsetWidth; // restart the animation + pad.classList.add('sl-shake'); + } + + async function submit() { + if (busy || pinBuf.length < 4) return; + busy = true; + refresh(); + try { + const res = await moduleApi.fetch('/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin: pinBuf }), + }); + const body = await readJson(res); + if (!res.ok || !body.token) { + shake(body.error || `Could not unlock (HTTP ${res.status})`); + return; + } + pinBuf = ''; + error = ''; + note = ''; + unlockStore.set(body.token, Number(body.expiresAt) || Date.now() + 10 * 60000); + onUnlocked?.(body); + } catch { + shake('ProdDash unreachable — try again'); + } finally { + busy = false; + refresh(); + } + } + + /** Keyboard: digits, Backspace, Enter (submit early), Escape (clear). True when handled. */ + function onKey(e) { + if (/^[0-9]$/.test(e.key)) press(e.key); + else if (e.key === 'Backspace') backspace(); + else if (e.key === 'Enter') submit(); + else if (e.key === 'Escape') { + pinBuf = ''; + error = ''; + refresh(); + } else return false; + e.preventDefault(); + return true; + } + + refresh(); + return { + el: pad, + refresh, + onKey, + setNote(text) { + note = String(text || ''); + error = ''; + refresh(); + }, + }; +} + +/* ── 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 token = unlockStore.get(); + 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) { + unlockStore.clear(body.error || 'Locked — enter the PIN'); + return 'locked'; + } + if (!res.ok) return body.error || `Could not react (HTTP ${res.status})`; + unlockStore.touch(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(); + menu.textContent = ''; + menu.hidden = false; + if (!unlockStore.get()) { + // Locked: the PIN first. A pending toggle (a click on a reaction pill) + // is carried out the moment the PIN is in. + const pad = createPinPad({ + moduleApi: ctx.moduleApi, + heading: 'Enter PIN to react', + subtitle: () => (ctx?.sendingAs ? `as ${ctx.sendingAs}` : ''), + pinLength: () => ctx?.pinLength || 0, + onUnlocked() { + if (!ctx) return; + if (ctx.pending) { + const { code, remove } = ctx.pending; + ctx.pending = null; + pick(code, remove); + } else render(); + }, + }); + if (unlockStore.reason()) pad.setNote(unlockStore.reason()); + menu.appendChild(pad.el); + menu.onkeydown = (e) => pad.onKey(e); + menu.focus({ preventScroll: true }); + place(x, y); + return; + } + menu.onkeydown = null; + 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'); + const quick = el('div', 'sl-emoji-quickrow'); + for (const q of Array.isArray(ctx.quick) ? ctx.quick : []) { + const code = q.name || emojiStore.nameOf(q.char); + if (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`. `quick` is the admin's + * quick emoji list; `pending` { code, remove } performs that one toggle + * (a click on a reaction pill) instead of showing the picker. + */ + open({ moduleApi, x, y, channel, ts, quick, sendingAs, pinLength, pending = null }) { + close(); + ctx = { moduleApi, channel, ts, quick, sendingAs, pinLength, 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(); + } + }; + document.addEventListener('pointerdown', onDown, true); + document.addEventListener('keydown', onKey, true); + window.addEventListener('resize', close); + cleanup = () => { + document.removeEventListener('pointerdown', onDown, true); + document.removeEventListener('keydown', onKey, true); + window.removeEventListener('resize', close); + }; + }, + close, + }; +})(); + /* ── the factory ────────────────────────────────────────────────────── */ export default function create({ root, moduleApi }) { @@ -234,6 +727,12 @@ function createTranscriptTile({ root, moduleApi }) { 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) && sendTarget.resolve(lastState?.channelOrder) === channelId; @@ -247,24 +746,85 @@ function createTranscriptTile({ root, moduleApi }) { if (lastState) reportStatus(lastState); } - /* entries */ + /* 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 buildLine(m) { - // A message or a thread reply: name + time, then the text. - const line = el('div', m.parent ? 'sl-reply' : 'sl-msg'); + 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-msg'); + line.dataset.ts = m.ts; line.classList.toggle('sl-me', Boolean(m.me)); line.classList.toggle('sl-bot', Boolean(m.bot)); - line.appendChild(buildMeta(m)); + 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)')); - line.appendChild(text); - return line; + 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) { @@ -287,25 +847,53 @@ function createTranscriptTile({ root, moduleApi }) { node.appendChild(line); return; } - node.appendChild(buildLine(m)); + 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)); - node.appendChild(list); + for (const r of m.replies) list.appendChild(buildLine(r, true).line); + body.appendChild(list); } } else if (m.replyCount > 0) { - node.appendChild(el('div', 'sl-replycount', `${m.replyCount} ${m.replyCount === 1 ? 'reply' : 'replies'}`)); + 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, + quick: lastState.quickEmojis || [], + sendingAs: lastState.sendingAs || '', + pinLength: Number(lastState.pinLength) || 0, + pending, + }); + } + entriesEl.addEventListener('contextmenu', (e) => { + const line = e.target.closest('.sl-msg, .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); @@ -371,6 +959,7 @@ function createTranscriptTile({ root, moduleApi }) { 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; @@ -430,6 +1019,8 @@ function createTranscriptTile({ root, moduleApi }) { stopped = true; clearTimeout(targetTimer); offTarget(); + offEmoji(); + emojiMenu.close(); sendTarget.detach(moduleApi.instanceId); stream?.close(); root.innerHTML = ''; // header controls are removed by the shell @@ -443,14 +1034,12 @@ function createTranscriptTile({ root, moduleApi }) { /* ── send + quick replies ───────────────────────────────────────────── */ function createSendTile({ root, moduleApi, mode }) { - let token = ''; // the unlock token — memory only; a remount shows the pad again - let expiresAt = 0; + let token = unlockStore.get(); // the page's unlock (unlockStore) — memory only, gone on reload let state = null; // latest snapshot: sending flags, identity, channels, status let stream = null; let ticker = null; let stopped = false; let busy = false; - let pinBuf = ''; let gateNote = ''; // one line under the pad ("Locked after inactivity") let gateError = ''; // the server's refusal ("Wrong PIN", "locked for 30 s") let gateKind = ''; // what the gate currently shows, to avoid needless rebuilds @@ -516,6 +1105,17 @@ function createSendTile({ root, moduleApi, mode }) { clearTimeout(targetTimer); targetTimer = setTimeout(repaintTarget, 50); }); + // Another Slack tile (or the reaction menu) unlocked or locked the page. + const offUnlock = unlockStore.on(() => { + if (stopped) return; + const t = unlockStore.get(); + if (t && t !== token) { + token = t; + gateNote = ''; + gateError = ''; + showCompose(); + } else if (!t && token) lock(unlockStore.reason() || 'Locked', true); + }); function authHeaders(extra = {}) { return { Authorization: `Bearer ${token}`, ...extra }; @@ -535,8 +1135,20 @@ function createSendTile({ root, moduleApi, mode }) { if (kind !== gateKind) { gateKind = kind; gate.textContent = ''; - if (kind === 'pad') gate.appendChild(buildPad()); - else { + if (kind === 'pad') { + pinPad = createPinPad({ + moduleApi, + subtitle: () => sendingLine().replace(/^Sending/, 'sending'), + pinLength: () => Number(state?.pinLength) || 0, + onUnlocked() { + token = unlockStore.get(); + gateNote = ''; + gateError = ''; + showCompose(); + }, + }); + gate.appendChild(pinPad.el); + } else { const note = el('div', 'sl-note'); note.textContent = kind === 'connecting' ? 'Connecting to ProdDash…' : kind === 'off' ? 'Sending is off — turn it on in Admin → Slack → Sending' @@ -545,113 +1157,15 @@ function createSendTile({ root, moduleApi, mode }) { gate.appendChild(note); } } - if (kind === 'pad') refreshPad(); - } - - let padEls = null; - function buildPad() { - const pad = el('div', 'sl-pad'); - const head = el('div', 'sl-pad-head'); - head.append(el('b', '', 'Enter PIN'), el('span', 'sl-pad-sub')); - const dots = el('div', 'sl-dots'); - for (let i = 0; i < 8; i += 1) dots.appendChild(el('span', 'sl-dot')); - const keys = el('div', 'sl-keys'); - const key = (label, cls, onClick, html) => { - const b = el('button', 'sl-key' + (cls ? ' ' + cls : '')); - b.type = 'button'; - if (html) b.innerHTML = html; // our own SVG constants only - else b.textContent = label; - b.addEventListener('click', (e) => { - e.preventDefault(); - onClick(); - wrap.focus({ preventScroll: true }); - }); - return b; - }; - for (const d of ['1', '2', '3', '4', '5', '6', '7', '8', '9']) keys.appendChild(key(d, '', () => pressDigit(d))); - const back = key('⌫', 'sl-key-back', () => { pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); }, BACK_SVG); - back.title = 'Backspace'; - keys.appendChild(back); - keys.appendChild(key('0', '', () => pressDigit('0'))); - const ok = key('Unlock', 'sl-key-ok', submitPin, CHECK_SVG); - ok.title = 'Unlock'; - keys.appendChild(ok); - const msg = el('div', 'sl-pad-msg'); - pad.append(head, dots, keys, msg); - padEls = { pad, sub: head.lastChild, dots, msg, ok }; - return pad; - } - - function refreshPad() { - if (!padEls) return; - padEls.sub.textContent = ` · ${sendingLine().replace(/^Sending/, 'sending')}`; - const n = Math.min(8, pinBuf.length); - [...padEls.dots.children].forEach((d, i) => { - d.classList.toggle('on', i < n); - // Show as many slots as could still be typed: 4 minimum, up to 8. - d.hidden = i >= Math.max(4, n + (n < 8 ? 1 : 0)); - }); - padEls.ok.disabled = pinBuf.length < 4 || busy; - padEls.msg.textContent = gateError || gateNote; - padEls.msg.classList.toggle('is-error', Boolean(gateError)); - } - - function pressDigit(d) { - if (busy || pinBuf.length >= 8) return; - pinBuf += d; - gateError = ''; - refreshPad(); - if (pinBuf.length === 8) submitPin(); - } - - function shake(message) { - gateError = message; - gateNote = ''; - pinBuf = ''; - refreshPad(); - if (!padEls) return; - padEls.pad.classList.remove('sl-shake'); - // restart the animation - void padEls.pad.offsetWidth; - padEls.pad.classList.add('sl-shake'); - } - - async function submitPin() { - if (busy || pinBuf.length < 4) return; - busy = true; - refreshPad(); - try { - const res = await moduleApi.fetch('/unlock', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin: pinBuf }), - }); - const body = await readJson(res); - if (!res.ok || !body.token) { - shake(body.error || `Could not unlock (HTTP ${res.status})`); - return; - } - token = String(body.token); - expiresAt = Number(body.expiresAt) || Date.now() + 10 * 60000; - pinBuf = ''; - gateError = ''; - gateNote = ''; - await showCompose(); - } catch { - shake('ProdDash unreachable — try again'); - } finally { - busy = false; - refreshPad(); + if (kind === 'pad' && pinPad) { + pinPad.setNote(gateError || gateNote); + pinPad.refresh(); } } + let pinPad = null; wrap.addEventListener('keydown', (e) => { - if (!gate.hidden && gateKind === 'pad') { - if (/^[0-9]$/.test(e.key)) { e.preventDefault(); pressDigit(e.key); } - else if (e.key === 'Backspace') { e.preventDefault(); pinBuf = pinBuf.slice(0, -1); gateError = ''; refreshPad(); } - else if (e.key === 'Enter') { e.preventDefault(); submitPin(); } - else if (e.key === 'Escape') { pinBuf = ''; gateError = ''; refreshPad(); } - } + if (!gate.hidden && gateKind === 'pad' && pinPad) pinPad.onKey(e); }); /* ── unlocked: compose or the preset grid ── */ @@ -690,7 +1204,19 @@ function createSendTile({ root, moduleApi, mode }) { sendBtn = el('button', 'sl-sendbtn', 'Send'); sendBtn.type = 'button'; sendBtn.addEventListener('click', sendText); - foot.append(msg, countdown, sendBtn); + // The admin's quick emojis: one press sends one, no confirmation. + const quick = el('div', 'sl-emoji-sendrow'); + for (const q of Array.isArray(state?.quickEmojis) ? state.quickEmojis : []) { + const label = q.char || `:${q.name}:`; + const b = el('button', 'sl-emoji-send'); + b.type = 'button'; + b.title = `Send ${label}`; + b.setAttribute('aria-label', `Send ${label}`); + b.appendChild(q.name && (!q.char || emojiStore.isCustom(q.name)) ? emojiStore.node(q.name) : el('span', 'sl-emoji-char', q.char)); + b.addEventListener('click', () => sendEmoji(q, b)); + quick.appendChild(b); + } + foot.append(msg, countdown, quick, sendBtn); } else { grid = el('div', 'sl-grid'); compose.appendChild(grid); @@ -731,14 +1257,13 @@ function createSendTile({ root, moduleApi, mode }) { composeEls.msg.className = 'sl-msg is-error'; } - async function sendText() { - if (busy || !composeEls?.input) return; - const text = composeEls.input.value.trim(); - if (!text) return; + /** POST one message to the selected channel; true when it landed. */ + async function postText(text, what) { const channel = targetId(); - if (!channel) return void showError(NO_CHANNEL); - busy = true; - composeEls.sendBtn.disabled = true; + if (!channel) { + showError(NO_CHANNEL); + return false; + } try { const res = await moduleApi.fetch('/send', { method: 'POST', @@ -746,13 +1271,31 @@ function createSendTile({ root, moduleApi, mode }) { body: JSON.stringify({ text, channel }), }); const body = await readJson(res); - if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); - if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`); - composeEls.input.value = ''; - if (body.expiresAt) expiresAt = Number(body.expiresAt); - flash(`Sent to ${body.channel || targetName()} ✓`); + if (res.status === 401) { + lock(body.error || 'Locked — enter the PIN', true); + return false; + } + if (!res.ok) { + showError(body.error || `Send failed (HTTP ${res.status})`); + return false; + } + unlockStore.touch(body.expiresAt); + flash(`Sent ${what || ''}to ${body.channel || targetName()} ✓`); + return true; } catch { showError('ProdDash unreachable — not sent'); + return false; + } + } + + async function sendText() { + if (busy || !composeEls?.input) return; + const text = composeEls.input.value.trim(); + if (!text) return; + busy = true; + composeEls.sendBtn.disabled = true; + try { + if (await postText(text)) composeEls.input.value = ''; } finally { busy = false; if (composeEls?.sendBtn) { @@ -762,6 +1305,26 @@ function createSendTile({ root, moduleApi, mode }) { } } + /** A quick emoji: the character (or :code: for a custom one) as a message of its own. */ + async function sendEmoji(q, btn) { + if (busy) return; + busy = true; + btn.disabled = true; + btn.classList.add('is-busy'); + try { + const text = q.char || `:${q.name}:`; + if (await postText(text, `${text} `)) { + btn.classList.add('is-sent'); + setTimeout(() => btn.classList.remove('is-sent'), 900); + } + } finally { + busy = false; + btn.disabled = false; + btn.classList.remove('is-busy'); + composeEls?.input?.focus(); + } + } + async function loadPresets() { if (!composeEls?.grid) return; composeEls.grid.textContent = ''; @@ -772,7 +1335,7 @@ function createSendTile({ root, moduleApi, mode }) { if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`); presets = Array.isArray(body.presets) ? body.presets : []; - if (body.expiresAt) expiresAt = Number(body.expiresAt); + unlockStore.touch(body.expiresAt); renderPresets(); } catch (err) { composeEls.grid.textContent = ''; @@ -815,7 +1378,7 @@ function createSendTile({ root, moduleApi, mode }) { const body = await readJson(res); if (res.status === 401) return void lock(body.error || 'Locked — enter the PIN', true); if (!res.ok) return void showError(body.error || `Send failed (HTTP ${res.status})`); - if (body.expiresAt) expiresAt = Number(body.expiresAt); + unlockStore.touch(body.expiresAt); if (composeEls) { composeEls.msg.textContent = ''; composeEls.msg.className = 'sl-msg'; @@ -844,25 +1407,24 @@ function createSendTile({ root, moduleApi, mode }) { function lock(note, fromServer = false) { const had = token; token = ''; - expiresAt = 0; - pinBuf = ''; presets = null; composeEls = null; compose.textContent = ''; if (had && !fromServer) { moduleApi.fetch('/lock', { method: 'POST', headers: { Authorization: `Bearer ${had}` } }).catch(() => {}); } + if (had) unlockStore.clear(note || 'Locked'); // every Slack tile on the page locks with this one gateNote = note || ''; gateError = ''; gateKind = ''; // rebuild the pad fresh - padEls = null; + pinPad = null; renderGate(); moduleApi.setStatus(statusState(), `${statusMessage()} · locked`); } function tickLock() { if (!token) return; - const left = expiresAt - Date.now(); + const left = unlockStore.expiresAt() - Date.now(); if (left <= 0) return void lock('Locked after inactivity'); if (composeEls) { const cd = composeEls.countdown; @@ -887,11 +1449,13 @@ function createSendTile({ root, moduleApi, mode }) { function applyState(s) { if (stopped || !s || typeof s !== 'object') return; state = s; + emojiStore.sync(moduleApi, s.emojiVersion); moduleApi.setStatus(statusState(), `${statusMessage()} · ${token ? 'unlocked' : 'locked'}`); if (token) { // Sending switched off or the PIN removed while unlocked: the server // will refuse the next send; say so now instead of on failure. 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) { composeEls.line.textContent = sendingLine(); if (composeEls.input) composeEls.input.placeholder = placeholder(); @@ -926,6 +1490,7 @@ function createSendTile({ root, moduleApi, mode }) { return { start() { moduleApi.setStatus('connecting', 'Connecting to Slack…'); + token = unlockStore.get(); renderGate(); connect(); ticker = setInterval(tickLock, 1000); @@ -936,9 +1501,9 @@ function createSendTile({ root, moduleApi, mode }) { clearTimeout(flash.timer); clearTimeout(targetTimer); offTarget(); + offUnlock(); stream?.close(); - token = ''; // the unlock dies with the tile - root.innerHTML = ''; + root.innerHTML = ''; // the page's unlock outlives a remount; a reload asks again }, }; } diff --git a/modules/slack/emoji.json b/modules/slack/emoji.json new file mode 100644 index 0000000..8adcf92 --- /dev/null +++ b/modules/slack/emoji.json @@ -0,0 +1 @@ +{"source":"iamcal/emoji-data (MIT) — short names → emoji; :name::skin-tone-N: appends the modifier","names":{"100":"💯","1234":"🔢","+1":"👍","-1":"👎","8ball":"🎱","a":"🅰️","ab":"🆎","abacus":"🧮","abc":"🔤","abcd":"🔡","accept":"🉑","accordion":"🪗","adhesive_bandage":"🩹","admission_tickets":"🎟️","adult":"🧑","aerial_tramway":"🚡","airplane":"✈️","airplane_arriving":"🛬","airplane_departure":"🛫","alarm_clock":"⏰","alembic":"⚗️","alien":"👽","ambulance":"🚑","amphora":"🏺","anatomical_heart":"🫀","anchor":"⚓","angel":"👼","anger":"💢","angry":"😠","anguished":"😧","ant":"🐜","apple":"🍎","aquarius":"♒","aries":"♈","arrow_backward":"◀️","arrow_double_down":"⏬","arrow_double_up":"⏫","arrow_down":"⬇️","arrow_down_small":"🔽","arrow_forward":"▶️","arrow_heading_down":"⤵️","arrow_heading_up":"⤴️","arrow_left":"⬅️","arrow_lower_left":"↙️","arrow_lower_right":"↘️","arrow_right":"➡️","arrow_right_hook":"↪️","arrow_up":"⬆️","arrow_up_down":"↕️","arrow_up_small":"🔼","arrow_upper_left":"↖️","arrow_upper_right":"↗️","arrows_clockwise":"🔃","arrows_counterclockwise":"🔄","art":"🎨","articulated_lorry":"🚛","artist":"🧑‍🎨","astonished":"😲","astronaut":"🧑‍🚀","athletic_shoe":"👟","atm":"🏧","atom_symbol":"⚛️","auto_rickshaw":"🛺","avocado":"🥑","axe":"🪓","b":"🅱️","baby":"👶","baby_bottle":"🍼","baby_chick":"🐤","baby_symbol":"🚼","back":"🔙","bacon":"🥓","badger":"🦡","badminton_racquet_and_shuttlecock":"🏸","bagel":"🥯","baggage_claim":"🛄","baguette_bread":"🥖","bald_man":"👨‍🦲","bald_person":"🧑‍🦲","bald_woman":"👩‍🦲","ballet_shoes":"🩰","balloon":"🎈","ballot_box_with_ballot":"🗳️","ballot_box_with_check":"☑️","bamboo":"🎍","banana":"🍌","bangbang":"‼️","banjo":"🪕","bank":"🏦","bar_chart":"📊","barber":"💈","barely_sunny":"🌥️","baseball":"⚾","basket":"🧺","basketball":"🏀","bat":"🦇","bath":"🛀","bathtub":"🛁","battery":"🔋","beach_with_umbrella":"🏖️","beans":"🫘","bear":"🐻","bearded_person":"🧔","beaver":"🦫","bed":"🛏️","bee":"🐝","beer":"🍺","beers":"🍻","beetle":"🪲","beginner":"🔰","bell":"🔔","bell_pepper":"🫑","bellhop_bell":"🛎️","bento":"🍱","beverage_box":"🧃","bicyclist":"🚴","bike":"🚲","bikini":"👙","billed_cap":"🧢","biohazard_sign":"☣️","bird":"🐦","birthday":"🎂","bison":"🦬","biting_lip":"🫦","black_bird":"🐦‍⬛","black_cat":"🐈‍⬛","black_circle":"⚫","black_circle_for_record":"⏺️","black_heart":"🖤","black_joker":"🃏","black_large_square":"⬛","black_left_pointing_double_triangle_with_vertical_bar":"⏮️","black_medium_small_square":"◾","black_medium_square":"◼️","black_nib":"✒️","black_right_pointing_double_triangle_with_vertical_bar":"⏭️","black_right_pointing_triangle_with_double_vertical_bar":"⏯️","black_small_square":"▪️","black_square_button":"🔲","black_square_for_stop":"⏹️","blond-haired-man":"👱‍♂️","blond-haired-woman":"👱‍♀️","blossom":"🌼","blowfish":"🐡","blue_book":"📘","blue_car":"🚙","blue_heart":"💙","blueberries":"🫐","blush":"😊","boar":"🐗","boat":"⛵","bomb":"💣","bone":"🦴","book":"📖","bookmark":"🔖","bookmark_tabs":"📑","books":"📚","boom":"💥","boomerang":"🪃","boot":"👢","bouquet":"💐","bow":"🙇","bow_and_arrow":"🏹","bowl_with_spoon":"🥣","bowling":"🎳","boxing_glove":"🥊","boy":"👦","brain":"🧠","bread":"🍞","breast-feeding":"🤱","bricks":"🧱","bride_with_veil":"👰","bridge_at_night":"🌉","briefcase":"💼","briefs":"🩲","broccoli":"🥦","broken_chain":"⛓️‍💥","broken_heart":"💔","broom":"🧹","brown_heart":"🤎","brown_mushroom":"🍄‍🟫","bubble_tea":"🧋","bubbles":"🫧","bucket":"🪣","bug":"🐛","building_construction":"🏗️","bulb":"💡","bullettrain_front":"🚅","bullettrain_side":"🚄","burrito":"🌯","bus":"🚌","busstop":"🚏","bust_in_silhouette":"👤","busts_in_silhouette":"👥","butter":"🧈","butterfly":"🦋","cactus":"🌵","cake":"🍰","calendar":"📆","call_me_hand":"🤙","calling":"📲","camel":"🐫","camera":"📷","camera_with_flash":"📸","camping":"🏕️","cancer":"♋","candle":"🕯️","candy":"🍬","canned_food":"🥫","canoe":"🛶","capital_abcd":"🔠","capricorn":"♑","car":"🚗","card_file_box":"🗃️","card_index":"📇","card_index_dividers":"🗂️","carousel_horse":"🎠","carpentry_saw":"🪚","carrot":"🥕","cat":"🐱","cat2":"🐈","cd":"💿","chains":"⛓️","chair":"🪑","champagne":"🍾","chart":"💹","chart_with_downwards_trend":"📉","chart_with_upwards_trend":"📈","checkered_flag":"🏁","cheese_wedge":"🧀","cherries":"🍒","cherry_blossom":"🌸","chess_pawn":"♟️","chestnut":"🌰","chicken":"🐔","child":"🧒","children_crossing":"🚸","chipmunk":"🐿️","chocolate_bar":"🍫","chopsticks":"🥢","christmas_tree":"🎄","church":"⛪","cinema":"🎦","circus_tent":"🎪","city_sunrise":"🌇","city_sunset":"🌆","cityscape":"🏙️","cl":"🆑","clap":"👏","clapper":"🎬","classical_building":"🏛️","clinking_glasses":"🥂","clipboard":"📋","clock1":"🕐","clock10":"🕙","clock1030":"🕥","clock11":"🕚","clock1130":"🕦","clock12":"🕛","clock1230":"🕧","clock130":"🕜","clock2":"🕑","clock230":"🕝","clock3":"🕒","clock330":"🕞","clock4":"🕓","clock430":"🕟","clock5":"🕔","clock530":"🕠","clock6":"🕕","clock630":"🕡","clock7":"🕖","clock730":"🕢","clock8":"🕗","clock830":"🕣","clock9":"🕘","clock930":"🕤","closed_book":"📕","closed_lock_with_key":"🔐","closed_umbrella":"🌂","cloud":"☁️","clown_face":"🤡","clubs":"♣️","cn":"🇨🇳","coat":"🧥","cockroach":"🪳","cocktail":"🍸","coconut":"🥥","coffee":"☕","coffin":"⚰️","coin":"🪙","cold_face":"🥶","cold_sweat":"😰","collision":"💥","comet":"☄️","compass":"🧭","compression":"🗜️","computer":"💻","confetti_ball":"🎊","confounded":"😖","confused":"😕","congratulations":"㊗️","construction":"🚧","construction_worker":"👷","control_knobs":"🎛️","convenience_store":"🏪","cook":"🧑‍🍳","cookie":"🍪","cooking":"🍳","cool":"🆒","cop":"👮","copyright":"©️","coral":"🪸","corn":"🌽","couch_and_lamp":"🛋️","couple":"👫","couple_with_heart":"💑","couplekiss":"💏","cow":"🐮","cow2":"🐄","crab":"🦀","credit_card":"💳","crescent_moon":"🌙","cricket":"🦗","cricket_bat_and_ball":"🏏","crocodile":"🐊","croissant":"🥐","crossed_fingers":"🤞","crossed_flags":"🎌","crossed_swords":"⚔️","crown":"👑","crutch":"🩼","cry":"😢","crying_cat_face":"😿","crystal_ball":"🔮","cucumber":"🥒","cup_with_straw":"🥤","cupcake":"🧁","cupid":"💘","curling_stone":"🥌","curly_haired_man":"👨‍🦱","curly_haired_person":"🧑‍🦱","curly_haired_woman":"👩‍🦱","curly_loop":"➰","currency_exchange":"💱","curry":"🍛","custard":"🍮","customs":"🛃","cut_of_meat":"🥩","cyclone":"🌀","dagger_knife":"🗡️","dancer":"💃","dancers":"👯","dango":"🍡","dark_sunglasses":"🕶️","dart":"🎯","dash":"💨","date":"📅","de":"🇩🇪","deaf_man":"🧏‍♂️","deaf_person":"🧏","deaf_woman":"🧏‍♀️","deciduous_tree":"🌳","deer":"🦌","department_store":"🏬","derelict_house_building":"🏚️","desert":"🏜️","desert_island":"🏝️","desktop_computer":"🖥️","diamond_shape_with_a_dot_inside":"💠","diamonds":"♦️","disappointed":"😞","disappointed_relieved":"😥","disguised_face":"🥸","diving_mask":"🤿","diya_lamp":"🪔","dizzy":"💫","dizzy_face":"😵","dna":"🧬","do_not_litter":"🚯","dodo":"🦤","dog":"🐶","dog2":"🐕","dollar":"💵","dolls":"🎎","dolphin":"🐬","donkey":"🫏","door":"🚪","dotted_line_face":"🫥","double_vertical_bar":"⏸️","doughnut":"🍩","dove_of_peace":"🕊️","dragon":"🐉","dragon_face":"🐲","dress":"👗","dromedary_camel":"🐪","drooling_face":"🤤","drop_of_blood":"🩸","droplet":"💧","drum_with_drumsticks":"🥁","duck":"🦆","dumpling":"🥟","dvd":"📀","e-mail":"📧","eagle":"🦅","ear":"👂","ear_of_rice":"🌾","ear_with_hearing_aid":"🦻","earth_africa":"🌍","earth_americas":"🌎","earth_asia":"🌏","egg":"🥚","eggplant":"🍆","eight":"8️⃣","eight_pointed_black_star":"✴️","eight_spoked_asterisk":"✳️","eject":"⏏️","electric_plug":"🔌","elephant":"🐘","elevator":"🛗","elf":"🧝","email":"✉️","empty_nest":"🪹","end":"🔚","envelope":"✉️","envelope_with_arrow":"📩","es":"🇪🇸","euro":"💶","european_castle":"🏰","european_post_office":"🏤","evergreen_tree":"🌲","exclamation":"❗","exploding_head":"🤯","expressionless":"😑","eye":"👁️","eye-in-speech-bubble":"👁️‍🗨️","eyeglasses":"👓","eyes":"👀","face_exhaling":"😮‍💨","face_holding_back_tears":"🥹","face_in_clouds":"😶‍🌫️","face_palm":"🤦","face_vomiting":"🤮","face_with_bags_under_eyes":"🫩","face_with_cowboy_hat":"🤠","face_with_diagonal_mouth":"🫤","face_with_finger_covering_closed_lips":"🤫","face_with_hand_over_mouth":"🤭","face_with_head_bandage":"🤕","face_with_monocle":"🧐","face_with_one_eyebrow_raised":"🤨","face_with_open_eyes_and_hand_over_mouth":"🫢","face_with_open_mouth_vomiting":"🤮","face_with_peeking_eye":"🫣","face_with_raised_eyebrow":"🤨","face_with_rolling_eyes":"🙄","face_with_spiral_eyes":"😵‍💫","face_with_symbols_on_mouth":"🤬","face_with_thermometer":"🤒","facepunch":"👊","factory":"🏭","factory_worker":"🧑‍🏭","fairy":"🧚","falafel":"🧆","fallen_leaf":"🍂","family":"👪","family_adult_adult_child":"🧑‍🧑‍🧒","family_adult_adult_child_child":"🧑‍🧑‍🧒‍🧒","family_adult_child":"🧑‍🧒","family_adult_child_child":"🧑‍🧒‍🧒","farmer":"🧑‍🌾","fast_forward":"⏩","fax":"📠","fearful":"😨","feather":"🪶","feet":"🐾","female-artist":"👩‍🎨","female-astronaut":"👩‍🚀","female-construction-worker":"👷‍♀️","female-cook":"👩‍🍳","female-detective":"🕵️‍♀️","female-doctor":"👩‍⚕️","female-factory-worker":"👩‍🏭","female-farmer":"👩‍🌾","female-firefighter":"👩‍🚒","female-guard":"💂‍♀️","female-judge":"👩‍⚖️","female-mechanic":"👩‍🔧","female-office-worker":"👩‍💼","female-pilot":"👩‍✈️","female-police-officer":"👮‍♀️","female-scientist":"👩‍🔬","female-singer":"👩‍🎤","female-student":"👩‍🎓","female-teacher":"👩‍🏫","female-technologist":"👩‍💻","female_elf":"🧝‍♀️","female_fairy":"🧚‍♀️","female_genie":"🧞‍♀️","female_mage":"🧙‍♀️","female_sign":"♀️","female_superhero":"🦸‍♀️","female_supervillain":"🦹‍♀️","female_vampire":"🧛‍♀️","female_zombie":"🧟‍♀️","fencer":"🤺","ferris_wheel":"🎡","ferry":"⛴️","field_hockey_stick_and_ball":"🏑","file_cabinet":"🗄️","file_folder":"📁","film_frames":"🎞️","film_projector":"📽️","fingerprint":"🫆","fire":"🔥","fire_engine":"🚒","fire_extinguisher":"🧯","firecracker":"🧨","firefighter":"🧑‍🚒","fireworks":"🎆","first_place_medal":"🥇","first_quarter_moon":"🌓","first_quarter_moon_with_face":"🌛","fish":"🐟","fish_cake":"🍥","fishing_pole_and_fish":"🎣","fist":"✊","five":"5️⃣","flag-ac":"🇦🇨","flag-ad":"🇦🇩","flag-ae":"🇦🇪","flag-af":"🇦🇫","flag-ag":"🇦🇬","flag-ai":"🇦🇮","flag-al":"🇦🇱","flag-am":"🇦🇲","flag-ao":"🇦🇴","flag-aq":"🇦🇶","flag-ar":"🇦🇷","flag-as":"🇦🇸","flag-at":"🇦🇹","flag-au":"🇦🇺","flag-aw":"🇦🇼","flag-ax":"🇦🇽","flag-az":"🇦🇿","flag-ba":"🇧🇦","flag-bb":"🇧🇧","flag-bd":"🇧🇩","flag-be":"🇧🇪","flag-bf":"🇧🇫","flag-bg":"🇧🇬","flag-bh":"🇧🇭","flag-bi":"🇧🇮","flag-bj":"🇧🇯","flag-bl":"🇧🇱","flag-bm":"🇧🇲","flag-bn":"🇧🇳","flag-bo":"🇧🇴","flag-bq":"🇧🇶","flag-br":"🇧🇷","flag-bs":"🇧🇸","flag-bt":"🇧🇹","flag-bv":"🇧🇻","flag-bw":"🇧🇼","flag-by":"🇧🇾","flag-bz":"🇧🇿","flag-ca":"🇨🇦","flag-cc":"🇨🇨","flag-cd":"🇨🇩","flag-cf":"🇨🇫","flag-cg":"🇨🇬","flag-ch":"🇨🇭","flag-ci":"🇨🇮","flag-ck":"🇨🇰","flag-cl":"🇨🇱","flag-cm":"🇨🇲","flag-cn":"🇨🇳","flag-co":"🇨🇴","flag-cp":"🇨🇵","flag-cr":"🇨🇷","flag-cu":"🇨🇺","flag-cv":"🇨🇻","flag-cw":"🇨🇼","flag-cx":"🇨🇽","flag-cy":"🇨🇾","flag-cz":"🇨🇿","flag-de":"🇩🇪","flag-dg":"🇩🇬","flag-dj":"🇩🇯","flag-dk":"🇩🇰","flag-dm":"🇩🇲","flag-do":"🇩🇴","flag-dz":"🇩🇿","flag-ea":"🇪🇦","flag-ec":"🇪🇨","flag-ee":"🇪🇪","flag-eg":"🇪🇬","flag-eh":"🇪🇭","flag-england":"🏴󠁧󠁢󠁥󠁮󠁧󠁿","flag-er":"🇪🇷","flag-es":"🇪🇸","flag-et":"🇪🇹","flag-eu":"🇪🇺","flag-fi":"🇫🇮","flag-fj":"🇫🇯","flag-fk":"🇫🇰","flag-fm":"🇫🇲","flag-fo":"🇫🇴","flag-fr":"🇫🇷","flag-ga":"🇬🇦","flag-gb":"🇬🇧","flag-gd":"🇬🇩","flag-ge":"🇬🇪","flag-gf":"🇬🇫","flag-gg":"🇬🇬","flag-gh":"🇬🇭","flag-gi":"🇬🇮","flag-gl":"🇬🇱","flag-gm":"🇬🇲","flag-gn":"🇬🇳","flag-gp":"🇬🇵","flag-gq":"🇬🇶","flag-gr":"🇬🇷","flag-gs":"🇬🇸","flag-gt":"🇬🇹","flag-gu":"🇬🇺","flag-gw":"🇬🇼","flag-gy":"🇬🇾","flag-hk":"🇭🇰","flag-hm":"🇭🇲","flag-hn":"🇭🇳","flag-hr":"🇭🇷","flag-ht":"🇭🇹","flag-hu":"🇭🇺","flag-ic":"🇮🇨","flag-id":"🇮🇩","flag-ie":"🇮🇪","flag-il":"🇮🇱","flag-im":"🇮🇲","flag-in":"🇮🇳","flag-io":"🇮🇴","flag-iq":"🇮🇶","flag-ir":"🇮🇷","flag-is":"🇮🇸","flag-it":"🇮🇹","flag-je":"🇯🇪","flag-jm":"🇯🇲","flag-jo":"🇯🇴","flag-jp":"🇯🇵","flag-ke":"🇰🇪","flag-kg":"🇰🇬","flag-kh":"🇰🇭","flag-ki":"🇰🇮","flag-km":"🇰🇲","flag-kn":"🇰🇳","flag-kp":"🇰🇵","flag-kr":"🇰🇷","flag-kw":"🇰🇼","flag-ky":"🇰🇾","flag-kz":"🇰🇿","flag-la":"🇱🇦","flag-lb":"🇱🇧","flag-lc":"🇱🇨","flag-li":"🇱🇮","flag-lk":"🇱🇰","flag-lr":"🇱🇷","flag-ls":"🇱🇸","flag-lt":"🇱🇹","flag-lu":"🇱🇺","flag-lv":"🇱🇻","flag-ly":"🇱🇾","flag-ma":"🇲🇦","flag-mc":"🇲🇨","flag-md":"🇲🇩","flag-me":"🇲🇪","flag-mf":"🇲🇫","flag-mg":"🇲🇬","flag-mh":"🇲🇭","flag-mk":"🇲🇰","flag-ml":"🇲🇱","flag-mm":"🇲🇲","flag-mn":"🇲🇳","flag-mo":"🇲🇴","flag-mp":"🇲🇵","flag-mq":"🇲🇶","flag-mr":"🇲🇷","flag-ms":"🇲🇸","flag-mt":"🇲🇹","flag-mu":"🇲🇺","flag-mv":"🇲🇻","flag-mw":"🇲🇼","flag-mx":"🇲🇽","flag-my":"🇲🇾","flag-mz":"🇲🇿","flag-na":"🇳🇦","flag-nc":"🇳🇨","flag-ne":"🇳🇪","flag-nf":"🇳🇫","flag-ng":"🇳🇬","flag-ni":"🇳🇮","flag-nl":"🇳🇱","flag-no":"🇳🇴","flag-np":"🇳🇵","flag-nr":"🇳🇷","flag-nu":"🇳🇺","flag-nz":"🇳🇿","flag-om":"🇴🇲","flag-pa":"🇵🇦","flag-pe":"🇵🇪","flag-pf":"🇵🇫","flag-pg":"🇵🇬","flag-ph":"🇵🇭","flag-pk":"🇵🇰","flag-pl":"🇵🇱","flag-pm":"🇵🇲","flag-pn":"🇵🇳","flag-pr":"🇵🇷","flag-ps":"🇵🇸","flag-pt":"🇵🇹","flag-pw":"🇵🇼","flag-py":"🇵🇾","flag-qa":"🇶🇦","flag-re":"🇷🇪","flag-ro":"🇷🇴","flag-rs":"🇷🇸","flag-ru":"🇷🇺","flag-rw":"🇷🇼","flag-sa":"🇸🇦","flag-sark":"🇨🇶","flag-sb":"🇸🇧","flag-sc":"🇸🇨","flag-scotland":"🏴󠁧󠁢󠁳󠁣󠁴󠁿","flag-sd":"🇸🇩","flag-se":"🇸🇪","flag-sg":"🇸🇬","flag-sh":"🇸🇭","flag-si":"🇸🇮","flag-sj":"🇸🇯","flag-sk":"🇸🇰","flag-sl":"🇸🇱","flag-sm":"🇸🇲","flag-sn":"🇸🇳","flag-so":"🇸🇴","flag-sr":"🇸🇷","flag-ss":"🇸🇸","flag-st":"🇸🇹","flag-sv":"🇸🇻","flag-sx":"🇸🇽","flag-sy":"🇸🇾","flag-sz":"🇸🇿","flag-ta":"🇹🇦","flag-tc":"🇹🇨","flag-td":"🇹🇩","flag-tf":"🇹🇫","flag-tg":"🇹🇬","flag-th":"🇹🇭","flag-tj":"🇹🇯","flag-tk":"🇹🇰","flag-tl":"🇹🇱","flag-tm":"🇹🇲","flag-tn":"🇹🇳","flag-to":"🇹🇴","flag-tr":"🇹🇷","flag-tt":"🇹🇹","flag-tv":"🇹🇻","flag-tw":"🇹🇼","flag-tz":"🇹🇿","flag-ua":"🇺🇦","flag-ug":"🇺🇬","flag-um":"🇺🇲","flag-un":"🇺🇳","flag-us":"🇺🇸","flag-uy":"🇺🇾","flag-uz":"🇺🇿","flag-va":"🇻🇦","flag-vc":"🇻🇨","flag-ve":"🇻🇪","flag-vg":"🇻🇬","flag-vi":"🇻🇮","flag-vn":"🇻🇳","flag-vu":"🇻🇺","flag-wales":"🏴󠁧󠁢󠁷󠁬󠁳󠁿","flag-wf":"🇼🇫","flag-ws":"🇼🇸","flag-xk":"🇽🇰","flag-ye":"🇾🇪","flag-yt":"🇾🇹","flag-za":"🇿🇦","flag-zm":"🇿🇲","flag-zw":"🇿🇼","flags":"🎏","flamingo":"🦩","flashlight":"🔦","flatbread":"🫓","fleur_de_lis":"⚜️","flipper":"🐬","floppy_disk":"💾","flower_playing_cards":"🎴","flushed":"😳","flute":"🪈","fly":"🪰","flying_disc":"🥏","flying_saucer":"🛸","fog":"🌫️","foggy":"🌁","folding_hand_fan":"🪭","fondue":"🫕","foot":"🦶","football":"🏈","footprints":"👣","fork_and_knife":"🍴","fortune_cookie":"🥠","fountain":"⛲","four":"4️⃣","four_leaf_clover":"🍀","fox_face":"🦊","fr":"🇫🇷","frame_with_picture":"🖼️","free":"🆓","fried_egg":"🍳","fried_shrimp":"🍤","fries":"🍟","frog":"🐸","frowning":"😦","fuelpump":"⛽","full_moon":"🌕","full_moon_with_face":"🌝","funeral_urn":"⚱️","game_die":"🎲","garlic":"🧄","gb":"🇬🇧","gear":"⚙️","gem":"💎","gemini":"♊","genie":"🧞","ghost":"👻","gift":"🎁","gift_heart":"💝","ginger_root":"🫚","giraffe_face":"🦒","girl":"👧","glass_of_milk":"🥛","globe_with_meridians":"🌐","gloves":"🧤","goal_net":"🥅","goat":"🐐","goggles":"🥽","golf":"⛳","golfer":"🏌️","goose":"🪿","gorilla":"🦍","grapes":"🍇","green_apple":"🍏","green_book":"📗","green_heart":"💚","green_salad":"🥗","grey_exclamation":"❕","grey_heart":"🩶","grey_question":"❔","grimacing":"😬","grin":"😁","grinning":"😀","grinning_face_with_one_large_and_one_small_eye":"🤪","grinning_face_with_star_eyes":"🤩","guardsman":"💂","guide_dog":"🦮","guitar":"🎸","gun":"🔫","hair_pick":"🪮","haircut":"💇","hamburger":"🍔","hammer":"🔨","hammer_and_pick":"⚒️","hammer_and_wrench":"🛠️","hamsa":"🪬","hamster":"🐹","hand":"✋","hand_with_index_and_middle_fingers_crossed":"🤞","hand_with_index_finger_and_thumb_crossed":"🫰","handbag":"👜","handball":"🤾","handshake":"🤝","hankey":"💩","harp":"🪉","hash":"#️⃣","hatched_chick":"🐥","hatching_chick":"🐣","head_shaking_horizontally":"🙂‍↔️","head_shaking_vertically":"🙂‍↕️","headphones":"🎧","headstone":"🪦","health_worker":"🧑‍⚕️","hear_no_evil":"🙉","heart":"❤️","heart_decoration":"💟","heart_eyes":"😍","heart_eyes_cat":"😻","heart_hands":"🫶","heart_on_fire":"❤️‍🔥","heartbeat":"💓","heartpulse":"💗","hearts":"♥️","heavy_check_mark":"✔️","heavy_division_sign":"➗","heavy_dollar_sign":"💲","heavy_equals_sign":"🟰","heavy_exclamation_mark":"❗","heavy_heart_exclamation_mark_ornament":"❣️","heavy_minus_sign":"➖","heavy_multiplication_x":"✖️","heavy_plus_sign":"➕","hedgehog":"🦔","helicopter":"🚁","helmet_with_white_cross":"⛑️","herb":"🌿","hibiscus":"🌺","high_brightness":"🔆","high_heel":"👠","hiking_boot":"🥾","hindu_temple":"🛕","hippopotamus":"🦛","hocho":"🔪","hole":"🕳️","honey_pot":"🍯","honeybee":"🐝","hook":"🪝","horse":"🐴","horse_racing":"🏇","hospital":"🏥","hot_face":"🥵","hot_pepper":"🌶️","hotdog":"🌭","hotel":"🏨","hotsprings":"♨️","hourglass":"⌛","hourglass_flowing_sand":"⏳","house":"🏠","house_buildings":"🏘️","house_with_garden":"🏡","hugging_face":"🤗","hushed":"😯","hut":"🛖","hyacinth":"🪻","i_love_you_hand_sign":"🤟","ice_cream":"🍨","ice_cube":"🧊","ice_hockey_stick_and_puck":"🏒","ice_skate":"⛸️","icecream":"🍦","id":"🆔","identification_card":"🪪","ideograph_advantage":"🉐","imp":"👿","inbox_tray":"📥","incoming_envelope":"📨","index_pointing_at_the_viewer":"🫵","infinity":"♾️","information_desk_person":"💁","information_source":"ℹ️","innocent":"😇","interrobang":"⁉️","iphone":"📱","it":"🇮🇹","izakaya_lantern":"🏮","jack_o_lantern":"🎃","japan":"🗾","japanese_castle":"🏯","japanese_goblin":"👺","japanese_ogre":"👹","jar":"🫙","jeans":"👖","jellyfish":"🪼","jigsaw":"🧩","joy":"😂","joy_cat":"😹","joystick":"🕹️","jp":"🇯🇵","judge":"🧑‍⚖️","juggling":"🤹","kaaba":"🕋","kangaroo":"🦘","key":"🔑","keyboard":"⌨️","keycap_star":"*️⃣","keycap_ten":"🔟","khanda":"🪯","kimono":"👘","kiss":"💋","kissing":"😗","kissing_cat":"😽","kissing_closed_eyes":"😚","kissing_heart":"😘","kissing_smiling_eyes":"😙","kite":"🪁","kiwifruit":"🥝","kneeling_person":"🧎","knife":"🔪","knife_fork_plate":"🍽️","knot":"🪢","koala":"🐨","koko":"🈁","kr":"🇰🇷","lab_coat":"🥼","label":"🏷️","lacrosse":"🥍","ladder":"🪜","lady_beetle":"🐞","ladybug":"🐞","lantern":"🏮","large_blue_circle":"🔵","large_blue_diamond":"🔷","large_blue_square":"🟦","large_brown_circle":"🟤","large_brown_square":"🟫","large_green_circle":"🟢","large_green_square":"🟩","large_orange_circle":"🟠","large_orange_diamond":"🔶","large_orange_square":"🟧","large_purple_circle":"🟣","large_purple_square":"🟪","large_red_square":"🟥","large_yellow_circle":"🟡","large_yellow_square":"🟨","last_quarter_moon":"🌗","last_quarter_moon_with_face":"🌜","latin_cross":"✝️","laughing":"😆","leafless_tree":"🪾","leafy_green":"🥬","leaves":"🍃","ledger":"📒","left-facing_fist":"🤛","left_luggage":"🛅","left_right_arrow":"↔️","left_speech_bubble":"🗨️","leftwards_arrow_with_hook":"↩️","leftwards_hand":"🫲","leftwards_pushing_hand":"🫷","leg":"🦵","lemon":"🍋","leo":"♌","leopard":"🐆","level_slider":"🎚️","libra":"♎","light_blue_heart":"🩵","light_rail":"🚈","lightning":"🌩️","lightning_cloud":"🌩️","lime":"🍋‍🟩","link":"🔗","linked_paperclips":"🖇️","lion_face":"🦁","lips":"👄","lipstick":"💄","lizard":"🦎","llama":"🦙","lobster":"🦞","lock":"🔒","lock_with_ink_pen":"🔏","lollipop":"🍭","long_drum":"🪘","loop":"➿","lotion_bottle":"🧴","lotus":"🪷","loud_sound":"🔊","loudspeaker":"📢","love_hotel":"🏩","love_letter":"💌","low_battery":"🪫","low_brightness":"🔅","lower_left_ballpoint_pen":"🖊️","lower_left_crayon":"🖍️","lower_left_fountain_pen":"🖋️","lower_left_paintbrush":"🖌️","luggage":"🧳","lungs":"🫁","lying_face":"🤥","m":"Ⓜ️","mag":"🔍","mag_right":"🔎","mage":"🧙","magic_wand":"🪄","magnet":"🧲","mahjong":"🀄","mailbox":"📫","mailbox_closed":"📪","mailbox_with_mail":"📬","mailbox_with_no_mail":"📭","male-artist":"👨‍🎨","male-astronaut":"👨‍🚀","male-construction-worker":"👷‍♂️","male-cook":"👨‍🍳","male-detective":"🕵️‍♂️","male-doctor":"👨‍⚕️","male-factory-worker":"👨‍🏭","male-farmer":"👨‍🌾","male-firefighter":"👨‍🚒","male-guard":"💂‍♂️","male-judge":"👨‍⚖️","male-mechanic":"👨‍🔧","male-office-worker":"👨‍💼","male-pilot":"👨‍✈️","male-police-officer":"👮‍♂️","male-scientist":"👨‍🔬","male-singer":"👨‍🎤","male-student":"👨‍🎓","male-teacher":"👨‍🏫","male-technologist":"👨‍💻","male_elf":"🧝‍♂️","male_fairy":"🧚‍♂️","male_genie":"🧞‍♂️","male_mage":"🧙‍♂️","male_sign":"♂️","male_superhero":"🦸‍♂️","male_supervillain":"🦹‍♂️","male_vampire":"🧛‍♂️","male_zombie":"🧟‍♂️","mammoth":"🦣","man":"👨","man-biking":"🚴‍♂️","man-bouncing-ball":"⛹️‍♂️","man-bowing":"🙇‍♂️","man-boy":"👨‍👦","man-boy-boy":"👨‍👦‍👦","man-cartwheeling":"🤸‍♂️","man-facepalming":"🤦‍♂️","man-frowning":"🙍‍♂️","man-gesturing-no":"🙅‍♂️","man-gesturing-ok":"🙆‍♂️","man-getting-haircut":"💇‍♂️","man-getting-massage":"💆‍♂️","man-girl":"👨‍👧","man-girl-boy":"👨‍👧‍👦","man-girl-girl":"👨‍👧‍👧","man-golfing":"🏌️‍♂️","man-heart-man":"👨‍❤️‍👨","man-juggling":"🤹‍♂️","man-kiss-man":"👨‍❤️‍💋‍👨","man-lifting-weights":"🏋️‍♂️","man-man-boy":"👨‍👨‍👦","man-man-boy-boy":"👨‍👨‍👦‍👦","man-man-girl":"👨‍👨‍👧","man-man-girl-boy":"👨‍👨‍👧‍👦","man-man-girl-girl":"👨‍👨‍👧‍👧","man-mountain-biking":"🚵‍♂️","man-playing-handball":"🤾‍♂️","man-playing-water-polo":"🤽‍♂️","man-pouting":"🙎‍♂️","man-raising-hand":"🙋‍♂️","man-rowing-boat":"🚣‍♂️","man-running":"🏃‍♂️","man-shrugging":"🤷‍♂️","man-surfing":"🏄‍♂️","man-swimming":"🏊‍♂️","man-tipping-hand":"💁‍♂️","man-walking":"🚶‍♂️","man-wearing-turban":"👳‍♂️","man-with-bunny-ears-partying":"👯‍♂️","man-woman-boy":"👨‍👩‍👦","man-woman-boy-boy":"👨‍👩‍👦‍👦","man-woman-girl":"👨‍👩‍👧","man-woman-girl-boy":"👨‍👩‍👧‍👦","man-woman-girl-girl":"👨‍👩‍👧‍👧","man-wrestling":"🤼‍♂️","man_and_woman_holding_hands":"👫","man_climbing":"🧗‍♂️","man_dancing":"🕺","man_feeding_baby":"👨‍🍼","man_in_business_suit_levitating":"🕴️","man_in_lotus_position":"🧘‍♂️","man_in_manual_wheelchair":"👨‍🦽","man_in_manual_wheelchair_facing_right":"👨‍🦽‍➡️","man_in_motorized_wheelchair":"👨‍🦼","man_in_motorized_wheelchair_facing_right":"👨‍🦼‍➡️","man_in_steamy_room":"🧖‍♂️","man_in_tuxedo":"🤵‍♂️","man_kneeling":"🧎‍♂️","man_kneeling_facing_right":"🧎‍♂️‍➡️","man_running_facing_right":"🏃‍♂️‍➡️","man_standing":"🧍‍♂️","man_walking_facing_right":"🚶‍♂️‍➡️","man_with_beard":"🧔‍♂️","man_with_gua_pi_mao":"👲","man_with_probing_cane":"👨‍🦯","man_with_turban":"👳","man_with_veil":"👰‍♂️","man_with_white_cane_facing_right":"👨‍🦯‍➡️","mango":"🥭","mans_shoe":"👞","mantelpiece_clock":"🕰️","manual_wheelchair":"🦽","maple_leaf":"🍁","maracas":"🪇","martial_arts_uniform":"🥋","mask":"😷","massage":"💆","mate_drink":"🧉","meat_on_bone":"🍖","mechanic":"🧑‍🔧","mechanical_arm":"🦾","mechanical_leg":"🦿","medal":"🎖️","medical_symbol":"⚕️","mega":"📣","melon":"🍈","melting_face":"🫠","memo":"📝","men-with-bunny-ears-partying":"👯‍♂️","men_holding_hands":"👬","mending_heart":"❤️‍🩹","menorah_with_nine_branches":"🕎","mens":"🚹","mermaid":"🧜‍♀️","merman":"🧜‍♂️","merperson":"🧜","metro":"🚇","microbe":"🦠","microphone":"🎤","microscope":"🔬","middle_finger":"🖕","military_helmet":"🪖","milky_way":"🌌","minibus":"🚐","minidisc":"💽","mirror":"🪞","mirror_ball":"🪩","mobile_phone_off":"📴","money_mouth_face":"🤑","money_with_wings":"💸","moneybag":"💰","monkey":"🐒","monkey_face":"🐵","monorail":"🚝","moon":"🌔","moon_cake":"🥮","moose":"🫎","mortar_board":"🎓","mosque":"🕌","mosquito":"🦟","mostly_sunny":"🌤️","mother_christmas":"🤶","motor_boat":"🛥️","motor_scooter":"🛵","motorized_wheelchair":"🦼","motorway":"🛣️","mount_fuji":"🗻","mountain":"⛰️","mountain_bicyclist":"🚵","mountain_cableway":"🚠","mountain_railway":"🚞","mouse":"🐭","mouse2":"🐁","mouse_trap":"🪤","movie_camera":"🎥","moyai":"🗿","mrs_claus":"🤶","muscle":"💪","mushroom":"🍄","musical_keyboard":"🎹","musical_note":"🎵","musical_score":"🎼","mute":"🔇","mx_claus":"🧑‍🎄","nail_care":"💅","name_badge":"📛","national_park":"🏞️","nauseated_face":"🤢","nazar_amulet":"🧿","necktie":"👔","negative_squared_cross_mark":"❎","nerd_face":"🤓","nest_with_eggs":"🪺","nesting_dolls":"🪆","neutral_face":"😐","new":"🆕","new_moon":"🌑","new_moon_with_face":"🌚","newspaper":"📰","ng":"🆖","night_with_stars":"🌃","nine":"9️⃣","ninja":"🥷","no_bell":"🔕","no_bicycles":"🚳","no_entry":"⛔","no_entry_sign":"🚫","no_good":"🙅","no_mobile_phones":"📵","no_mouth":"😶","no_pedestrians":"🚷","no_smoking":"🚭","non-potable_water":"🚱","nose":"👃","notebook":"📓","notebook_with_decorative_cover":"📔","notes":"🎶","nut_and_bolt":"🔩","o":"⭕","o2":"🅾️","ocean":"🌊","octagonal_sign":"🛑","octopus":"🐙","oden":"🍢","office":"🏢","office_worker":"🧑‍💼","oil_drum":"🛢️","ok":"🆗","ok_hand":"👌","ok_woman":"🙆","old_key":"🗝️","older_adult":"🧓","older_man":"👴","older_woman":"👵","olive":"🫒","om_symbol":"🕉️","on":"🔛","oncoming_automobile":"🚘","oncoming_bus":"🚍","oncoming_police_car":"🚔","oncoming_taxi":"🚖","one":"1️⃣","one-piece_swimsuit":"🩱","onion":"🧅","open_book":"📖","open_file_folder":"📂","open_hands":"👐","open_mouth":"😮","ophiuchus":"⛎","orange_book":"📙","orange_heart":"🧡","orangutan":"🦧","orthodox_cross":"☦️","otter":"🦦","outbox_tray":"📤","owl":"🦉","ox":"🐂","oyster":"🦪","package":"📦","page_facing_up":"📄","page_with_curl":"📃","pager":"📟","palm_down_hand":"🫳","palm_tree":"🌴","palm_up_hand":"🫴","palms_up_together":"🤲","pancakes":"🥞","panda_face":"🐼","paperclip":"📎","parachute":"🪂","parking":"🅿️","parrot":"🦜","part_alternation_mark":"〽️","partly_sunny":"⛅","partly_sunny_rain":"🌦️","partying_face":"🥳","passenger_ship":"🛳️","passport_control":"🛂","paw_prints":"🐾","pea_pod":"🫛","peace_symbol":"☮️","peach":"🍑","peacock":"🦚","peanuts":"🥜","pear":"🍐","pencil":"📝","pencil2":"✏️","penguin":"🐧","pensive":"😔","people_holding_hands":"🧑‍🤝‍🧑","people_hugging":"🫂","performing_arts":"🎭","persevere":"😣","person_climbing":"🧗","person_doing_cartwheel":"🤸","person_feeding_baby":"🧑‍🍼","person_frowning":"🙍","person_in_lotus_position":"🧘","person_in_manual_wheelchair":"🧑‍🦽","person_in_manual_wheelchair_facing_right":"🧑‍🦽‍➡️","person_in_motorized_wheelchair":"🧑‍🦼","person_in_motorized_wheelchair_facing_right":"🧑‍🦼‍➡️","person_in_steamy_room":"🧖","person_in_tuxedo":"🤵","person_kneeling_facing_right":"🧎‍➡️","person_running_facing_right":"🏃‍➡️","person_walking_facing_right":"🚶‍➡️","person_with_ball":"⛹️","person_with_blond_hair":"👱","person_with_crown":"🫅","person_with_headscarf":"🧕","person_with_pouting_face":"🙎","person_with_probing_cane":"🧑‍🦯","person_with_white_cane_facing_right":"🧑‍🦯‍➡️","petri_dish":"🧫","phoenix":"🐦‍🔥","phone":"☎️","pick":"⛏️","pickup_truck":"🛻","pie":"🥧","pig":"🐷","pig2":"🐖","pig_nose":"🐽","pill":"💊","pilot":"🧑‍✈️","pinata":"🪅","pinched_fingers":"🤌","pinching_hand":"🤏","pineapple":"🍍","pink_heart":"🩷","pirate_flag":"🏴‍☠️","pisces":"♓","pizza":"🍕","placard":"🪧","place_of_worship":"🛐","playground_slide":"🛝","pleading_face":"🥺","plunger":"🪠","point_down":"👇","point_left":"👈","point_right":"👉","point_up":"☝️","point_up_2":"👆","polar_bear":"🐻‍❄️","police_car":"🚓","poodle":"🐩","poop":"💩","popcorn":"🍿","post_office":"🏣","postal_horn":"📯","postbox":"📮","potable_water":"🚰","potato":"🥔","potted_plant":"🪴","pouch":"👝","poultry_leg":"🍗","pound":"💷","pouring_liquid":"🫗","pouting_cat":"😾","pray":"🙏","prayer_beads":"📿","pregnant_man":"🫃","pregnant_person":"🫄","pregnant_woman":"🤰","pretzel":"🥨","prince":"🤴","princess":"👸","printer":"🖨️","probing_cane":"🦯","punch":"👊","purple_heart":"💜","purse":"👛","pushpin":"📌","put_litter_in_its_place":"🚮","question":"❓","rabbit":"🐰","rabbit2":"🐇","raccoon":"🦝","racehorse":"🐎","racing_car":"🏎️","racing_motorcycle":"🏍️","radio":"📻","radio_button":"🔘","radioactive_sign":"☢️","rage":"😡","railway_car":"🚃","railway_track":"🛤️","rain_cloud":"🌧️","rainbow":"🌈","rainbow-flag":"🏳️‍🌈","raised_back_of_hand":"🤚","raised_hand":"✋","raised_hand_with_fingers_splayed":"🖐️","raised_hands":"🙌","raising_hand":"🙋","ram":"🐏","ramen":"🍜","rat":"🐀","razor":"🪒","receipt":"🧾","recycle":"♻️","red_car":"🚗","red_circle":"🔴","red_envelope":"🧧","red_haired_man":"👨‍🦰","red_haired_person":"🧑‍🦰","red_haired_woman":"👩‍🦰","registered":"®️","relaxed":"☺️","relieved":"😌","reminder_ribbon":"🎗️","repeat":"🔁","repeat_one":"🔂","restroom":"🚻","reversed_hand_with_middle_finger_extended":"🖕","revolving_hearts":"💞","rewind":"⏪","rhinoceros":"🦏","ribbon":"🎀","rice":"🍚","rice_ball":"🍙","rice_cracker":"🍘","rice_scene":"🎑","right-facing_fist":"🤜","right_anger_bubble":"🗯️","rightwards_hand":"🫱","rightwards_pushing_hand":"🫸","ring":"💍","ring_buoy":"🛟","ringed_planet":"🪐","robot_face":"🤖","rock":"🪨","rocket":"🚀","roll_of_paper":"🧻","rolled_up_newspaper":"🗞️","roller_coaster":"🎢","roller_skate":"🛼","rolling_on_the_floor_laughing":"🤣","rooster":"🐓","root_vegetable":"🫜","rose":"🌹","rosette":"🏵️","rotating_light":"🚨","round_pushpin":"📍","rowboat":"🚣","ru":"🇷🇺","rugby_football":"🏉","runner":"🏃","running":"🏃","running_shirt_with_sash":"🎽","sa":"🈂️","safety_pin":"🧷","safety_vest":"🦺","sagittarius":"♐","sailboat":"⛵","sake":"🍶","salt":"🧂","saluting_face":"🫡","sandal":"👡","sandwich":"🥪","santa":"🎅","sari":"🥻","satellite":"🛰️","satellite_antenna":"📡","satisfied":"😆","sauropod":"🦕","saxophone":"🎷","scales":"⚖️","scarf":"🧣","school":"🏫","school_satchel":"🎒","scientist":"🧑‍🔬","scissors":"✂️","scooter":"🛴","scorpion":"🦂","scorpius":"♏","scream":"😱","scream_cat":"🙀","screwdriver":"🪛","scroll":"📜","seal":"🦭","seat":"💺","second_place_medal":"🥈","secret":"㊙️","see_no_evil":"🙈","seedling":"🌱","selfie":"🤳","serious_face_with_symbols_covering_mouth":"🤬","service_dog":"🐕‍🦺","seven":"7️⃣","sewing_needle":"🪡","shaking_face":"🫨","shallow_pan_of_food":"🥘","shamrock":"☘️","shark":"🦈","shaved_ice":"🍧","sheep":"🐑","shell":"🐚","shield":"🛡️","shinto_shrine":"⛩️","ship":"🚢","shirt":"👕","shit":"💩","shocked_face_with_exploding_head":"🤯","shoe":"👞","shopping_bags":"🛍️","shopping_trolley":"🛒","shorts":"🩳","shovel":"🪏","shower":"🚿","shrimp":"🦐","shrug":"🤷","shushing_face":"🤫","sign_of_the_horns":"🤘","signal_strength":"📶","singer":"🧑‍🎤","six":"6️⃣","six_pointed_star":"🔯","skateboard":"🛹","ski":"🎿","skier":"⛷️","skin-tone-2":"🏻","skin-tone-3":"🏼","skin-tone-4":"🏽","skin-tone-5":"🏾","skin-tone-6":"🏿","skull":"💀","skull_and_crossbones":"☠️","skunk":"🦨","sled":"🛷","sleeping":"😴","sleeping_accommodation":"🛌","sleepy":"😪","sleuth_or_spy":"🕵️","slightly_frowning_face":"🙁","slightly_smiling_face":"🙂","slot_machine":"🎰","sloth":"🦥","small_airplane":"🛩️","small_blue_diamond":"🔹","small_orange_diamond":"🔸","small_red_triangle":"🔺","small_red_triangle_down":"🔻","smile":"😄","smile_cat":"😸","smiley":"😃","smiley_cat":"😺","smiling_face_with_3_hearts":"🥰","smiling_face_with_smiling_eyes_and_hand_covering_mouth":"🤭","smiling_face_with_tear":"🥲","smiling_imp":"😈","smirk":"😏","smirk_cat":"😼","smoking":"🚬","snail":"🐌","snake":"🐍","sneezing_face":"🤧","snow_capped_mountain":"🏔️","snow_cloud":"🌨️","snowboarder":"🏂","snowflake":"❄️","snowman":"☃️","snowman_without_snow":"⛄","soap":"🧼","sob":"😭","soccer":"⚽","socks":"🧦","softball":"🥎","soon":"🔜","sos":"🆘","sound":"🔉","space_invader":"👾","spades":"♠️","spaghetti":"🍝","sparkle":"❇️","sparkler":"🎇","sparkles":"✨","sparkling_heart":"💖","speak_no_evil":"🙊","speaker":"🔈","speaking_head_in_silhouette":"🗣️","speech_balloon":"💬","speedboat":"🚤","spider":"🕷️","spider_web":"🕸️","spiral_calendar_pad":"🗓️","spiral_note_pad":"🗒️","splatter":"🫟","spock-hand":"🖖","sponge":"🧽","spoon":"🥄","sports_medal":"🏅","squid":"🦑","stadium":"🏟️","staff_of_aesculapius":"⚕️","standing_person":"🧍","star":"⭐","star-struck":"🤩","star2":"🌟","star_and_crescent":"☪️","star_of_david":"✡️","stars":"🌠","station":"🚉","statue_of_liberty":"🗽","steam_locomotive":"🚂","stethoscope":"🩺","stew":"🍲","stopwatch":"⏱️","straight_ruler":"📏","strawberry":"🍓","stuck_out_tongue":"😛","stuck_out_tongue_closed_eyes":"😝","stuck_out_tongue_winking_eye":"😜","student":"🧑‍🎓","studio_microphone":"🎙️","stuffed_flatbread":"🥙","sun_behind_cloud":"🌥️","sun_behind_rain_cloud":"🌦️","sun_small_cloud":"🌤️","sun_with_face":"🌞","sunflower":"🌻","sunglasses":"😎","sunny":"☀️","sunrise":"🌅","sunrise_over_mountains":"🌄","superhero":"🦸","supervillain":"🦹","surfer":"🏄","sushi":"🍣","suspension_railway":"🚟","swan":"🦢","sweat":"😓","sweat_drops":"💦","sweat_smile":"😅","sweet_potato":"🍠","swimmer":"🏊","symbols":"🔣","synagogue":"🕍","syringe":"💉","t-rex":"🦖","table_tennis_paddle_and_ball":"🏓","taco":"🌮","tada":"🎉","takeout_box":"🥡","tamale":"🫔","tanabata_tree":"🎋","tangerine":"🍊","taurus":"♉","taxi":"🚕","tea":"🍵","teacher":"🧑‍🏫","teapot":"🫖","technologist":"🧑‍💻","teddy_bear":"🧸","telephone":"☎️","telephone_receiver":"📞","telescope":"🔭","tennis":"🎾","tent":"⛺","test_tube":"🧪","the_horns":"🤘","thermometer":"🌡️","thinking_face":"🤔","third_place_medal":"🥉","thong_sandal":"🩴","thought_balloon":"💭","thread":"🧵","three":"3️⃣","three_button_mouse":"🖱️","thumbsdown":"👎","thumbsup":"👍","thunder_cloud_and_rain":"⛈️","ticket":"🎫","tiger":"🐯","tiger2":"🐅","timer_clock":"⏲️","tired_face":"😫","tm":"™️","toilet":"🚽","tokyo_tower":"🗼","tomato":"🍅","tongue":"👅","toolbox":"🧰","tooth":"🦷","toothbrush":"🪥","top":"🔝","tophat":"🎩","tornado":"🌪️","tornado_cloud":"🌪️","trackball":"🖲️","tractor":"🚜","traffic_light":"🚥","train":"🚋","train2":"🚆","tram":"🚊","transgender_flag":"🏳️‍⚧️","transgender_symbol":"⚧️","triangular_flag_on_post":"🚩","triangular_ruler":"📐","trident":"🔱","triumph":"😤","troll":"🧌","trolleybus":"🚎","trophy":"🏆","tropical_drink":"🍹","tropical_fish":"🐠","truck":"🚚","trumpet":"🎺","tshirt":"👕","tulip":"🌷","tumbler_glass":"🥃","turkey":"🦃","turtle":"🐢","tv":"📺","twisted_rightwards_arrows":"🔀","two":"2️⃣","two_hearts":"💕","two_men_holding_hands":"👬","two_women_holding_hands":"👭","u5272":"🈹","u5408":"🈴","u55b6":"🈺","u6307":"🈯","u6708":"🈷️","u6709":"🈶","u6e80":"🈵","u7121":"🈚","u7533":"🈸","u7981":"🈲","u7a7a":"🈳","uk":"🇬🇧","umbrella":"☂️","umbrella_on_ground":"⛱️","umbrella_with_rain_drops":"☔","unamused":"😒","underage":"🔞","unicorn_face":"🦄","unlock":"🔓","up":"🆙","upside_down_face":"🙃","us":"🇺🇸","v":"✌️","vampire":"🧛","vertical_traffic_light":"🚦","vhs":"📼","vibration_mode":"📳","video_camera":"📹","video_game":"🎮","violin":"🎻","virgo":"♍","volcano":"🌋","volleyball":"🏐","vs":"🆚","waffle":"🧇","walking":"🚶","waning_crescent_moon":"🌘","waning_gibbous_moon":"🌖","warning":"⚠️","wastebasket":"🗑️","watch":"⌚","water_buffalo":"🐃","water_polo":"🤽","watermelon":"🍉","wave":"👋","waving_black_flag":"🏴","waving_white_flag":"🏳️","wavy_dash":"〰️","waxing_crescent_moon":"🌒","waxing_gibbous_moon":"🌔","wc":"🚾","weary":"😩","wedding":"💒","weight_lifter":"🏋️","whale":"🐳","whale2":"🐋","wheel":"🛞","wheel_of_dharma":"☸️","wheelchair":"♿","white_check_mark":"✅","white_circle":"⚪","white_flower":"💮","white_frowning_face":"☹️","white_haired_man":"👨‍🦳","white_haired_person":"🧑‍🦳","white_haired_woman":"👩‍🦳","white_heart":"🤍","white_large_square":"⬜","white_medium_small_square":"◽","white_medium_square":"◻️","white_small_square":"▫️","white_square_button":"🔳","wilted_flower":"🥀","wind_blowing_face":"🌬️","wind_chime":"🎐","window":"🪟","wine_glass":"🍷","wing":"🪽","wink":"😉","wireless":"🛜","wolf":"🐺","woman":"👩","woman-biking":"🚴‍♀️","woman-bouncing-ball":"⛹️‍♀️","woman-bowing":"🙇‍♀️","woman-boy":"👩‍👦","woman-boy-boy":"👩‍👦‍👦","woman-cartwheeling":"🤸‍♀️","woman-facepalming":"🤦‍♀️","woman-frowning":"🙍‍♀️","woman-gesturing-no":"🙅‍♀️","woman-gesturing-ok":"🙆‍♀️","woman-getting-haircut":"💇‍♀️","woman-getting-massage":"💆‍♀️","woman-girl":"👩‍👧","woman-girl-boy":"👩‍👧‍👦","woman-girl-girl":"👩‍👧‍👧","woman-golfing":"🏌️‍♀️","woman-heart-man":"👩‍❤️‍👨","woman-heart-woman":"👩‍❤️‍👩","woman-juggling":"🤹‍♀️","woman-kiss-man":"👩‍❤️‍💋‍👨","woman-kiss-woman":"👩‍❤️‍💋‍👩","woman-lifting-weights":"🏋️‍♀️","woman-mountain-biking":"🚵‍♀️","woman-playing-handball":"🤾‍♀️","woman-playing-water-polo":"🤽‍♀️","woman-pouting":"🙎‍♀️","woman-raising-hand":"🙋‍♀️","woman-rowing-boat":"🚣‍♀️","woman-running":"🏃‍♀️","woman-shrugging":"🤷‍♀️","woman-surfing":"🏄‍♀️","woman-swimming":"🏊‍♀️","woman-tipping-hand":"💁‍♀️","woman-walking":"🚶‍♀️","woman-wearing-turban":"👳‍♀️","woman-with-bunny-ears-partying":"👯‍♀️","woman-woman-boy":"👩‍👩‍👦","woman-woman-boy-boy":"👩‍👩‍👦‍👦","woman-woman-girl":"👩‍👩‍👧","woman-woman-girl-boy":"👩‍👩‍👧‍👦","woman-woman-girl-girl":"👩‍👩‍👧‍👧","woman-wrestling":"🤼‍♀️","woman_and_man_holding_hands":"👫","woman_climbing":"🧗‍♀️","woman_feeding_baby":"👩‍🍼","woman_in_lotus_position":"🧘‍♀️","woman_in_manual_wheelchair":"👩‍🦽","woman_in_manual_wheelchair_facing_right":"👩‍🦽‍➡️","woman_in_motorized_wheelchair":"👩‍🦼","woman_in_motorized_wheelchair_facing_right":"👩‍🦼‍➡️","woman_in_steamy_room":"🧖‍♀️","woman_in_tuxedo":"🤵‍♀️","woman_kneeling":"🧎‍♀️","woman_kneeling_facing_right":"🧎‍♀️‍➡️","woman_running_facing_right":"🏃‍♀️‍➡️","woman_standing":"🧍‍♀️","woman_walking_facing_right":"🚶‍♀️‍➡️","woman_with_beard":"🧔‍♀️","woman_with_probing_cane":"👩‍🦯","woman_with_veil":"👰‍♀️","woman_with_white_cane_facing_right":"👩‍🦯‍➡️","womans_clothes":"👚","womans_flat_shoe":"🥿","womans_hat":"👒","women-with-bunny-ears-partying":"👯‍♀️","women_holding_hands":"👭","womens":"🚺","wood":"🪵","woozy_face":"🥴","world_map":"🗺️","worm":"🪱","worried":"😟","wrench":"🔧","wrestlers":"🤼","writing_hand":"✍️","x":"❌","x-ray":"🩻","yarn":"🧶","yawning_face":"🥱","yellow_heart":"💛","yen":"💴","yin_yang":"☯️","yo-yo":"🪀","yum":"😋","zany_face":"🤪","zap":"⚡","zebra_face":"🦓","zero":"0️⃣","zipper_mouth_face":"🤐","zombie":"🧟","zzz":"💤"}} diff --git a/modules/slack/module.json b/modules/slack/module.json index a4b8c41..2bdd632 100644 --- a/modules/slack/module.json +++ b/modules/slack/module.json @@ -1,25 +1,31 @@ { "id": "slack", "name": "Slack", - "version": "1.1.0", + "version": "1.2.0", "proddash": ">=1.4.0", "description": "Live transcripts of this location's Slack channels, plus PIN-protected sending as the location", "client": "client.js", "server": "server.js", "style": "style.css", - "minSize": { "w": 2, "h": 2 }, - "defaultSize": { "w": 4, "h": 4 }, + "minSize": { + "w": 2, + "h": 2 + }, + "defaultSize": { + "w": 4, + "h": 4 + }, "configGroups": { "Slack account": { "collapsed": "whenSet", - "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 and users:read (users:read.email is not needed). 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": { "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": { "toggle": "sendEnabled", - "help": "Messages leave this machine only from a Send or Quick replies tile, and only after the PIN is entered on that tile — every tile asks again after Auto-lock minutes without a send. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected." + "help": "Messages and emoji reactions leave this machine only after the PIN is entered — on a Send or Quick replies tile, or in the reaction menu a right-click on a transcript message opens. One PIN unlocks every Slack tile on that dashboard page until Auto-lock minutes pass without a send; a reload asks again. Sending stays refused until a 4–8 digit PIN is set here. A message goes to the channel of the transcript clicked last. Quick emojis are sent with one press from the Send message tile and offered first when reacting. Quick replies are one per line, Label | Message, with an optional | #channel-name to bind that one to a channel: a bound reply always goes there, whichever transcript is selected." } }, "configSchema": { @@ -75,7 +81,7 @@ "label": "PIN", "default": "", "group": "Sending", - "help": "4–8 digits; asked every time a send tile opens." + "help": "4–8 digits; the pad unlocks by itself once they are all in." }, "pinIdleMinutes": { "type": "number", @@ -89,14 +95,30 @@ "default": "", "group": "Sending", "help": "One per line: Label | Message. Add | #channel-name to bind one to a channel — it then ignores which transcript is selected." + }, + "quickEmojis": { + "type": "string", + "label": "Quick emojis", + "default": "👍 🙏 ✅ 👀 🎉", + "group": "Sending", + "help": "Emoji or :codes: separated by spaces, eight at most. One press sends one from the Send message tile; they also head the reaction menu." } }, - "instanceGroups": { - "Transcript": { "collapsed": false } - }, "instanceSchema": { - "showTimes": { "type": "switch", "label": "Timestamps", "default": true, "group": "Transcript" }, - "showNames": { "type": "switch", "label": "Names", "default": true, "group": "Transcript" }, - "showSystem": { "type": "switch", "label": "System lines", "default": true, "group": "Transcript" } + "showTimes": { + "type": "switch", + "label": "Timestamps", + "default": true + }, + "showNames": { + "type": "switch", + "label": "Names", + "default": true + }, + "showSystem": { + "type": "switch", + "label": "System lines", + "default": true + } } } diff --git a/modules/slack/server.js b/modules/slack/server.js index c8e064a..d312cf1 100644 --- a/modules/slack/server.js +++ b/modules/slack/server.js @@ -17,6 +17,9 @@ * POST /lock Bearer → forget that unlock token * GET /quick Bearer → preset labels + indexes (never the message text) * POST /send Bearer, { text, channel } → chat.postMessage + * POST /react Bearer, { channel, ts, name, remove? } → reactions.add / .remove + * GET /emoji { standard: {code: emoji}, custom: {name: {url}|{char}}, version } + * GET /emoji-image/, GET /avatar/ images proxied from Slack's CDN * POST /quick Bearer, { index, channel } → post that preset * * `channel` on /send and /quick must be one of the configured channel ids @@ -205,6 +208,10 @@ function describeError(err, ctx = {}) { case 'msg_too_long': return 'Message too long for Slack (msg_too_long)'; case 'no_text': return 'Nothing to send (no_text)'; case 'restricted_action': return `Posting in ${chan} is restricted by a workspace admin (restricted_action)`; + case 'invalid_name': return 'Slack has no emoji by that name (invalid_name)'; + case 'too_many_reactions': return 'Slack allows 23 different reactions on a message (too_many_reactions)'; + case 'too_many_emoji': return 'That message already carries as many reactions as Slack allows (too_many_emoji)'; + case 'message_not_found': return 'That message is gone (message_not_found)'; case 'bad_response': return `Slack answered with something that isn't JSON (HTTP ${err.status})`; default: return `Slack error: ${err?.code || err?.message || err}${err?.detail ? ` — ${err.detail}` : ''}`; } @@ -217,14 +224,118 @@ function isConfigError(err) { /* ── mrkdwn → plain text with a few inline marks ────────────────────── */ -const EMOJI = { - '+1': '👍', thumbsup: '👍', '-1': '👎', thumbsdown: '👎', white_check_mark: '✅', heavy_check_mark: '✔️', - x: '❌', pray: '🙏', tada: '🎉', fire: '🔥', eyes: '👀', heart: '❤️', clap: '👏', warning: '⚠️', - rotating_light: '🚨', smile: '😄', grinning: '😀', joy: '😂', sweat_smile: '😅', thinking_face: '🤔', - wave: '👋', ok_hand: '👌', raised_hands: '🙌', bell: '🔔', microphone: '🎤', musical_note: '🎵', - video_camera: '📹', tv: '📺', church: '⛪', zap: '⚡', 100: '💯', point_right: '👉', muscle: '💪', - slightly_smiling_face: '🙂', raised_hand: '✋', satellite_antenna: '📡', -}; +/* ── emoji: Slack's :codes: → characters; the workspace's custom set; images ── */ + +/** Slack's short names → emoji — modules/slack/emoji.json, built from iamcal/emoji-data (MIT). */ +const EMOJI_NAMES = require('./emoji.json').names; +/** One code as it sits between colons: "+1", "+1::skin-tone-3", "party_parrot". */ +const EMOJI_CODE_RE = /^([a-z0-9_+-]{1,100})(?:::(skin-tone-[2-6]))?$/i; +/** How long the workspace's custom emoji list is believed before emoji.list is asked again. */ +const EMOJI_LIST_TTL_MS = 60 * 60 * 1000; +let emojiReverse = null; // character (variation selector stripped) → the name Slack knows it by + +/** The character for a standard code, '' for a custom or unknown one. */ +function emojiChar(code) { + const m = EMOJI_CODE_RE.exec(String(code || '')); + if (!m) return ''; + const base = EMOJI_NAMES[m[1].toLowerCase()]; + if (!base) return ''; + const tone = m[2] ? EMOJI_NAMES[m[2].toLowerCase()] : ''; + return tone ? base.replace(/\uFE0F/g, '') + tone : base; +} + +/** The short name for a character ('' if Slack has none) — a reaction needs the name, not the glyph. */ +function emojiName(char) { + if (!emojiReverse) { + emojiReverse = new Map(); + for (const [name, ch] of Object.entries(EMOJI_NAMES)) { + if (name.startsWith('skin-tone-')) continue; + const key = ch.replace(/\uFE0F/g, ''); + if (!emojiReverse.has(key)) emojiReverse.set(key, name); // keys are sorted: "+1" wins over "thumbsup" + } + } + const s = String(char || '').replace(/\uFE0F/g, ''); + const tone = /[\u{1F3FB}-\u{1F3FF}]$/u.exec(s); + if (tone) { + const base = emojiReverse.get(s.slice(0, -tone[0].length)); + const toneName = Object.keys(EMOJI_NAMES).find((n) => n.startsWith('skin-tone-') && EMOJI_NAMES[n] === tone[0]); + return base && toneName ? `${base}::${toneName}` : ''; + } + return emojiReverse.get(s) || ''; +} + +/** + * The admin's "Quick emojis" — "👍 🙏 :white_check_mark: :party_parrot:" → + * [{ name, char }]. A standard emoji gets both; a custom code keeps its name + * and no character (the browser draws the image); a glyph Slack has no name + * for can be sent but not reacted with (name ''). Eight at most. + */ +function parseQuickEmojis(text) { + const out = []; + for (const tok of String(text || '').split(/\s+/).filter(Boolean)) { + const code = /^:([^:\s]+(?:::skin-tone-[2-6])?):$/i.exec(tok); + if (code) { + const name = code[1].toLowerCase(); + if (EMOJI_CODE_RE.test(name)) out.push({ name, char: emojiChar(name) }); + } else out.push({ name: emojiName(tok), char: tok }); + if (out.length >= 8) break; + } + return out; +} + +/* Avatars and custom emoji are images on Slack's CDN; browsers only talk to + ProdDash, so the module fetches them and keeps them a while. */ +const IMAGE_CACHE_MAX = 400; +const IMAGE_TTL_MS = 6 * 60 * 60 * 1000; +const IMAGE_MAX_BYTES = 2 * 1024 * 1024; +const imageCache = new Map(); // url → { buf, type, at } — module-wide, survives a re-init +const imageInFlight = new Map(); + +function fetchImage(url) { + const hit = imageCache.get(url); + if (hit && Date.now() - hit.at < IMAGE_TTL_MS) return Promise.resolve(hit); + if (imageInFlight.has(url)) return imageInFlight.get(url); + const p = (async () => { + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), redirect: 'follow' }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const type = String(response.headers.get('content-type') || 'image/png').split(';')[0].trim(); + if (!type.startsWith('image/')) throw new Error(`not an image (${type})`); + const buf = Buffer.from(await response.arrayBuffer()); + if (buf.length > IMAGE_MAX_BYTES) throw new Error('too large'); + if (imageCache.size >= IMAGE_CACHE_MAX) imageCache.delete(imageCache.keys().next().value); + const entry = { buf, type, at: Date.now() }; + imageCache.set(url, entry); + return entry; + })().finally(() => imageInFlight.delete(url)); + imageInFlight.set(url, p); + return p; +} + +/** Only https, or plain http on this machine (the mock) — never a URL a message could smuggle in. */ +function imageUrlAllowed(url) { + try { + const u = new URL(String(url || '')); + return u.protocol === 'https:' || (u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname)); + } catch { + return false; + } +} + +/** Answer with the image at `url`: 404 with none, 502 when the CDN fails — the then falls back. */ +async function serveImage(res, url) { + if (!imageUrlAllowed(url)) { + res.writeHead(404, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); + return void res.end('No image'); + } + try { + const img = await fetchImage(url); + res.writeHead(200, { 'Content-Type': img.type, 'Content-Length': img.buf.length, 'Cache-Control': 'private, max-age=3600' }); + res.end(img.buf); + } catch { + res.writeHead(502, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' }); + res.end('Image unavailable'); + } +} function unescapeSlack(s) { return s.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&'); @@ -233,7 +344,8 @@ function unescapeSlack(s) { /** * Slack mrkdwn → plain text. Mentions, channel links, broadcasts and URLs are * spelled out; *bold* _italic_ ~strike~ `code` markers are kept for the - * client to style; :shortcodes: in the small map become the emoji. + * client to style; standard :codes: become their emoji, and a custom one + * stays as :code: for the client to draw from the workspace's set. */ function renderMrkdwn(raw, lookupUser, lookupChannel) { let text = String(raw || ''); @@ -260,7 +372,7 @@ function renderMrkdwn(raw, lookupUser, lookupChannel) { return url.replace(/^mailto:/, ''); }); text = unescapeSlack(text); - text = text.replace(/:([a-z0-9_+-]+):/g, (m, code) => EMOJI[code] || m); + text = text.replace(/:([a-z0-9_+-]+(?:::skin-tone-[2-6])?):/gi, (m, code) => emojiChar(code) || m); return text; } @@ -395,6 +507,7 @@ module.exports = { const pinOk = PIN_RE.test(pin); const idleMs = Math.min(240, Math.max(1, Number(config.pinIdleMinutes) || 10)) * 60 * 1000; const quickReplies = parseQuickReplies(config.quickReplies); + const quickEmojis = parseQuickEmojis(config.quickEmojis); /* ── state (all inside this closure so a re-init starts clean) ── */ let stopped = false; @@ -457,6 +570,92 @@ module.exports = { return channelNames.get(id) || pollers.get(id)?.name || ''; } + /** A user's picture as Slack lists it (the 72 px one fits a transcript row). */ + function profileImage(u) { + const p = u?.profile || {}; + return String(p.image_72 || p.image_48 || p.image_32 || p.image_original || ''); + } + + /* Bot messages carry their own icon; remembered by bot so a later message + from the same bot without icons (a plain bot_message) still has one. */ + const botIcons = new Map(); // bot id / username → image url + function botIconOf(m) { + const icons = m.icons || m.bot_profile?.icons || {}; + const url = String(icons.image_72 || icons.image_64 || icons.image_48 || icons.image_36 || ''); + const key = String(m.bot_id || m.username || m.bot_profile?.id || 'bot'); + if (url) botIcons.set(key, url); + const emoji = icons.emoji ? emojiChar(String(icons.emoji).replace(/^:|:$/g, '')) : ''; + return { key, url: url || botIcons.get(key) || '', emoji }; + } + + /** [{ name, count, me }] for a message's reactions, or nothing. `me` = this account reacted. */ + function reactionsOf(m) { + if (!Array.isArray(m.reactions) || !m.reactions.length) return undefined; + const meId = identity?.userId || ''; + const out = []; + for (const r of m.reactions) { + const name = String(r?.name || ''); + if (!EMOJI_CODE_RE.test(name)) continue; + const users = Array.isArray(r.users) ? r.users : []; + const count = Math.max(0, Math.trunc(Number(r.count)) || users.length); + if (count > 0) out.push({ name, count, me: Boolean(meId && users.includes(meId)) }); + } + return out.length ? out : undefined; + } + + /* ── the workspace's custom emoji (emoji.list, hourly) ── */ + let customEmoji = new Map(); // name → { url } | { char } (an alias of a standard emoji) + let customEmojiSig = ''; + let emojiVersion = 0; // bumps when the set changes; tiles refetch /emoji then + let emojiCheckedAt = 0; + let emojiPromise = null; + let emojiScopeMissing = false; + + function loadCustomEmoji() { + if (emojiScopeMissing || Date.now() - emojiCheckedAt < EMOJI_LIST_TTL_MS) return Promise.resolve(); + if (!emojiPromise) { + emojiPromise = (async () => { + emojiCheckedAt = Date.now(); + const data = await slackCall(token, 'emoji.list', {}); + const raw = data.emoji && typeof data.emoji === 'object' ? data.emoji : {}; + const resolve = (name, depth) => { + const v = raw[name]; + if (typeof v !== 'string' || depth > 5) return null; + if (v.startsWith('alias:')) { + const to = v.slice('alias:'.length); + if (to in raw) return resolve(to, depth + 1); + const ch = emojiChar(to); + return ch ? { char: ch } : null; + } + return imageUrlAllowed(v) ? { url: v } : null; + }; + const next = new Map(); + for (const name of Object.keys(raw).sort()) { + if (!EMOJI_CODE_RE.test(name) || name.includes('::')) continue; + const e = resolve(name, 0); + if (e) next.set(name, e); + } + const sig = JSON.stringify([...next]); + if (sig !== customEmojiSig) { + customEmoji = next; + customEmojiSig = sig; + emojiVersion += 1; + invalidateAll(); + log(`${next.size} custom emoji in the workspace`); + } + })().catch((err) => { + if (err.code === 'missing_scope') { + emojiScopeMissing = true; + log('custom emoji stay as :codes: — the token lacks the emoji:read scope (add it under OAuth & Permissions, reinstall, paste the new token); standard emoji are unaffected'); + } else { + emojiCheckedAt = Date.now() - EMOJI_LIST_TTL_MS + RETRY_NETWORK_MS; // ask again soon + if (err.code !== 'ratelimited') log(`emoji.list: ${describeError(err, { name: identity?.name })}`); + } + }).finally(() => { emojiPromise = null; }); + } + return emojiPromise; + } + function invalidateAll() { for (const ch of pollers.values()) ch.messagesCache = null; } @@ -471,7 +670,7 @@ module.exports = { const data = await slackCall(token, 'users.info', { user: id }); const u = data.user || {}; const name = String(u.profile?.display_name || u.real_name || u.profile?.real_name || u.name || id); - users.set(id, { name, at: Date.now() }); + users.set(id, { name, image: profileImage(u), at: Date.now() }); changed = true; } catch (err) { // Unknown or deactivated: remember the raw id for a while so we @@ -571,6 +770,7 @@ module.exports = { let text = textFor(m); if (m.subtype === 'channel_join' || m.subtype === 'group_join') text = `${name} joined ${chanLabel}`; else if (m.subtype === 'channel_leave' || m.subtype === 'group_leave') text = `${name} left ${chanLabel}`; + const icon = m.user ? null : botIconOf(m); const out = { id: String(m.ts), ts: String(m.ts), @@ -582,6 +782,11 @@ module.exports = { subtype: String(m.subtype || ''), bot: Boolean(m.bot_id) || m.subtype === 'bot_message', me: Boolean(identity && m.user && m.user === identity.userId), + // the picture in the row: a user's profile image or a bot's icon, by + // key for GET /avatar/; a bot with only an :emoji: icon shows that + avatar: m.user ? `u/${m.user}` : icon?.url ? `b/${icon.key}` : '', + avatarEmoji: icon?.emoji || '', + reactions: reactionsOf(m), }; if (parentTs) out.parent = parentTs; else { @@ -689,6 +894,10 @@ module.exports = { historyCount, pollSeconds: intervalMs / 1000, sending: { enabled: sendEnabled, pinSet: pinOk, idleMinutes: idleMs / 60000 }, + // the pad submits by itself once this many digits are in (never the PIN) + pinLength: pinOk ? pin.length : 0, + quickEmojis, + emojiVersion, status: { state: h.status, message: h.message, lastPollAt }, apiBase: API_BASE, }; @@ -771,7 +980,7 @@ module.exports = { const u = await slackCall(token, 'users.info', { user: userId }); const name = String(u.user?.profile?.display_name || u.user?.real_name || u.user?.profile?.real_name || '').trim(); if (name) id.name = name; - users.set(userId, { name: id.name, at: Date.now() }); + users.set(userId, { name: id.name, image: profileImage(u.user), at: Date.now() }); } catch { /* the handle will do */ } } identity = id; @@ -875,6 +1084,7 @@ module.exports = { retry = wait + 100 + ch.index * 100; } else { await ensureAuth(); + await loadCustomEmoji(); if (!ch.checked || ch.errorKind === 'membership') await checkChannel(ch); await readHistory(ch); if (ch.error) log(`${labelOf(ch)}: reading again`); @@ -973,6 +1183,41 @@ module.exports = { return slackCall(token, 'chat.postMessage', params, { json: true }); } + /** + * Add or remove a reaction on behalf of an unlock — the same gate as a + * send, since it is the location's name on it. The message's channel must + * be one of the configured ones. `already_reacted` / `no_reaction` count + * as done: Slack already holds the state that was asked for. + */ + async function react(req, res, unlock, { channel, ts, name, remove }) { + if (!identity || authError) { + return sendJson(res, 502, { error: authError || 'Slack token not checked yet — try again in a moment' }); + } + const target = String(channel ?? '').trim() ? chooseTarget(channel) : { error: 'Which channel?' }; + if (target.error) return sendJson(res, 400, { error: target.error, code: 'bad_channel' }); + const stamp = String(ts ?? '').trim(); + if (!/^\d{1,16}\.\d{1,9}$/.test(stamp)) return sendJson(res, 400, { error: 'Which message?' }); + const code = String(name ?? '').trim().replace(/^:|:$/g, '').toLowerCase(); + if (!EMOJI_CODE_RE.test(code)) return sendJson(res, 400, { error: 'Not an emoji name' }); + const targetName = lookupChannel(target.id) || target.id; + try { + await slackCall(token, remove ? 'reactions.remove' : 'reactions.add', { channel: target.id, timestamp: stamp, name: code }); + } catch (err) { + if (err.code !== 'already_reacted' && err.code !== 'no_reaction') { + const message = describeError(err, { name: identity.name, channel: targetName }); + log(`reaction :${code}: in #${targetName} failed: ${message}`); + const status = ['invalid_name', 'too_many_reactions', 'too_many_emoji'].includes(err.code) ? 400 + : err.code === 'message_not_found' ? 404 : 502; + return sendJson(res, status, { error: message, code: err.code }); + } + } + const u = unlocks.get(unlock.token); + if (u) u.expiresAt = Date.now() + idleMs; + log(`${remove ? 'removed' : 'added'} :${code}: on ${stamp} in #${targetName} as ${sendingAs()}`); + if (pollers.has(target.id)) pollSoon(pollers.get(target.id)); + return sendJson(res, 200, { ok: true, expiresAt: u ? u.expiresAt : unlock.expiresAt, channel: `#${targetName}` }); + } + /** * Post on behalf of an unlock; answers the response. `targetId` is an * already-validated configured channel; `presetChannel` (a quick reply's @@ -1055,6 +1300,23 @@ module.exports = { quickReplies, sendGate, deliver, + react, + /** Everything a tile needs to draw emoji: the shipped table plus the workspace's own. */ + emojiTable() { + const custom = {}; + for (const [name, e] of customEmoji) { + custom[name] = e.url ? { url: `/api/modules/slack/emoji-image/${encodeURIComponent(name)}` } : { char: e.char }; + } + return { standard: EMOJI_NAMES, custom, version: emojiVersion }; + }, + customEmojiUrl: (name) => customEmoji.get(String(name || ''))?.url || '', + /** "u/U123" → that user's profile image; "b/" → that bot's icon. */ + avatarUrl(key) { + const [kind, id] = String(key || '').split('/'); + if (kind === 'u') return users.get(id)?.image || ''; + if (kind === 'b') return botIcons.get(id) || ''; + return ''; + }, unlockFor, chooseTarget, namesReady, @@ -1238,6 +1500,36 @@ module.exports = { current.lock(req, res); }, + // The emoji a tile may need to draw — fetched once per page, again when the version bumps. + 'GET /emoji': (req, res) => { + if (!current) return unmounted(res); + sendJson(res, 200, current.emojiTable()); + }, + + // Pictures proxied from Slack's CDN: a custom emoji by name, a profile image or bot icon by key. + 'GET /emoji-image/*': guard(async (req, res) => { + if (!current) return unmounted(res); + await serveImage(res, current.customEmojiUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, '')))); + }), + 'GET /avatar/*': guard(async (req, res) => { + if (!current) return unmounted(res); + await serveImage(res, current.avatarUrl(decodeURIComponent(String(req.wildcard || '').replace(/^\/+/, '')))); + }), + + // A reaction as the location — PIN-gated like a send. + 'POST /react': guard(async (req, res) => { + if (!current) return unmounted(res); + const unlock = current.sendGate(req, res); + if (!unlock) return; + let body; + try { + body = await readJson(req); + } catch (err) { + return sendJson(res, 400, { error: err.message }); + } + await current.react(req, res, unlock, { channel: body.channel, ts: body.ts, name: body.name, remove: Boolean(body.remove) }); + }), + // Labels and indexes only — the message text never leaves the server. 'GET /quick': (req, res) => { if (!current) return unmounted(res); diff --git a/modules/slack/style.css b/modules/slack/style.css index 2cf6624..d94accb 100644 --- a/modules/slack/style.css +++ b/modules/slack/style.css @@ -1,5 +1,7 @@ /* Slack module — scoped to its tiles, shell variables only. - --sl-size (per tile, from the Text size setting) drives every font size. */ + --sl-size (per tile, from the header's A− / A+) drives every font size. + Transcript rows follow the ProdCom transcript's layout: a square picture, + then a name line with the time, the text, the reactions. */ [data-module="slack"] .sl-wrap { --sl-size: 14px; @@ -74,9 +76,9 @@ [data-module="slack"] .sl-empty.is-error { color: var(--danger); } [data-module="slack"] .sl-entry { - padding: 4px 6px; + padding: 5px 6px; border-radius: 8px; - line-height: 1.4; + line-height: 1.45; } [data-module="slack"] .sl-entry:hover { background: var(--bg); } [data-module="slack"] .sl-entry.sl-new { animation: sl-fade-in 0.25s ease-out; } @@ -85,9 +87,46 @@ to { opacity: 1; transform: none; } } -/* a message from this machine's own account */ -[data-module="slack"] .sl-entry.sl-me { - box-shadow: inset 3px 0 0 color-mix(in srgb, var(--accent) 55%, transparent); +/* one row: the picture, then everything else */ +[data-module="slack"] .sl-msg, +[data-module="slack"] .sl-reply { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} +[data-module="slack"] .sl-body { flex: 1; min-width: 0; } + +/* the square: a profile image, a bot's icon or emoji, or initials on a tint + of a hue the name hashes to — the same size and corner as ProdCom's */ +[data-module="slack"] .sl-avatar { + flex: none; + width: calc(var(--sl-size) * 1.9); + height: calc(var(--sl-size) * 1.9); + border-radius: 26%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--sl-hue, 150) 45% 50% / 0.2); + color: hsl(var(--sl-hue, 150) 55% 60%); + font-size: 0.72em; + font-weight: 700; + letter-spacing: 0.02em; + user-select: none; + -webkit-user-select: none; +} +[data-module="slack"] .sl-avatar-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +[data-module="slack"] .sl-avatar-emoji { font-size: 1.25em; line-height: 1; } +[data-module="slack"] .sl-reply .sl-avatar { + width: calc(var(--sl-size) * 1.5); + height: calc(var(--sl-size) * 1.5); + font-size: 0.6em; } [data-module="slack"] .sl-meta { @@ -95,11 +134,12 @@ align-items: baseline; gap: 8px; min-width: 0; - margin-bottom: 1px; + margin-bottom: 2px; } [data-module="slack"] .sl-name { font-weight: 700; - font-size: 0.86em; + font-size: 0.82em; + letter-spacing: 0.01em; color: var(--accent); overflow: hidden; text-overflow: ellipsis; @@ -148,6 +188,52 @@ white-space: nowrap; } +/* a custom emoji is an image the size of a character */ +[data-module="slack"] .sl-emoji { + display: inline-block; + width: 1.25em; + height: 1.25em; + object-fit: contain; + vertical-align: -0.28em; +} + +/* reactions under a message: pills with the emoji and its count; the + location's own carry the accent — a click adds yours or takes it back */ +[data-module="slack"] .sl-reactions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} +[data-module="slack"] .sl-reaction { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 7px 1px 5px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text); + font: inherit; + font-size: 0.78em; + line-height: 1.4; + cursor: pointer; + transition: background 0.1s, border-color 0.1s; +} +[data-module="slack"] .sl-reaction:hover { border-color: var(--accent); } +[data-module="slack"] .sl-reaction.is-me { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 16%, transparent); +} +[data-module="slack"] .sl-reaction .sl-emoji, +[data-module="slack"] .sl-reaction .sl-emoji-char { font-size: 1.15em; line-height: 1; vertical-align: middle; } +[data-module="slack"] .sl-reaction-count { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--muted); +} +[data-module="slack"] .sl-reaction.is-me .sl-reaction-count { color: var(--text); } + /* thread replies nest under their parent; without them, a muted count */ [data-module="slack"] .sl-replies { margin: 4px 0 0 10px; @@ -293,13 +379,8 @@ [data-module="slack"] .sl-key:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); } [data-module="slack"] .sl-key svg { width: 1.1em; height: 1.1em; } [data-module="slack"] .sl-key-back { color: var(--muted); } -[data-module="slack"] .sl-key-ok { background: var(--accent); color: var(--accent-text); border-color: transparent; } -[data-module="slack"] .sl-key-ok:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); } -[data-module="slack"] .sl-key-ok:disabled { - background: color-mix(in srgb, var(--accent) 25%, var(--panel)); - color: var(--muted); - cursor: default; -} +[data-module="slack"] .sl-key-blank { visibility: hidden; } +[data-module="slack"] .sl-pad.is-busy .sl-keys { opacity: 0.6; pointer-events: none; } [data-module="slack"] .sl-pad-msg { flex: none; @@ -311,6 +392,24 @@ } [data-module="slack"] .sl-pad-msg.is-error { color: var(--danger); font-weight: 600; } +/* A tile too short for the keypad (the smallest Send message / Quick replies + tiles) shows the dots and a hint instead — digits typed on the keyboard + still land, and a taller tile brings the keys back. .sl-wrap is the size + container the height is measured against. */ +[data-module="slack"] .sl-pad-hint { + display: none; + flex: none; + text-align: center; + font-size: 0.78em; + line-height: 1.3; + color: var(--muted); +} +@container (max-height: 185px) { + [data-module="slack"] .sl-pad .sl-keys { display: none; } + [data-module="slack"] .sl-pad .sl-pad-hint { display: block; } + [data-module="slack"] .sl-pad { justify-content: center; } +} + /* ── send / quick replies: unlocked ── */ [data-module="slack"] .sl-compose { gap: 6px; } @@ -396,6 +495,40 @@ [data-module="slack"] .sl-sendbtn:hover { background: color-mix(in srgb, var(--accent) 85%, var(--text)); } [data-module="slack"] .sl-sendbtn:disabled { opacity: 0.6; cursor: default; } +/* quick emojis: one press each sends it as a message of its own */ +[data-module="slack"] .sl-emoji-sendrow { + flex: none; + display: flex; + gap: 4px; + min-width: 0; + overflow: hidden; +} +[data-module="slack"] .sl-emoji-send { + flex: none; + width: 2.1em; + height: 2.1em; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: color-mix(in srgb, var(--text) 5%, var(--panel)); + color: var(--text); + font: inherit; + font-size: 1.1em; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + transition: background 0.1s, border-color 0.1s; +} +[data-module="slack"] .sl-emoji-send:hover { border-color: var(--accent); background: var(--border); } +[data-module="slack"] .sl-emoji-send:active { background: color-mix(in srgb, var(--accent) 30%, var(--panel)); } +[data-module="slack"] .sl-emoji-send.is-busy { opacity: 0.6; } +[data-module="slack"] .sl-emoji-send.is-sent { background: var(--accent); border-color: transparent; } +[data-module="slack"] .sl-emoji-send .sl-emoji { width: 1.2em; height: 1.2em; vertical-align: middle; } + /* the preset grid: big targets, labels wrap */ [data-module="slack"] .sl-grid { flex: 1; @@ -445,3 +578,99 @@ color: var(--muted); } [data-module="slack"] .sl-quick.is-sent .sl-quick-chan { color: inherit; } + +/* ── the reaction menu (on document.body, so nothing clips it) ── */ + +[data-module="slack"].sl-emoji-menu { + --sl-size: 14px; + position: fixed; + z-index: 90; + width: 292px; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--panel); + color: var(--text); + font-size: var(--sl-size); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55); + overflow: hidden; +} +[data-module="slack"].sl-emoji-menu:focus { outline: none; } +[data-module="slack"].sl-emoji-menu.is-busy { opacity: 0.7; pointer-events: none; } +[data-module="slack"] .sl-emoji-head { + font-size: 0.8em; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="slack"] .sl-emoji-quickrow { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +[data-module="slack"] .sl-emoji-quickrow:empty { display: none; } +[data-module="slack"] .sl-emoji-btn { + width: 34px; + height: 34px; + padding: 0; + border: 1px solid transparent; + border-radius: 8px; + background: none; + color: var(--text); + font: inherit; + font-size: 20px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s, border-color 0.1s; +} +[data-module="slack"] .sl-emoji-quickrow .sl-emoji-btn { font-size: 22px; width: 38px; height: 38px; background: color-mix(in srgb, var(--text) 5%, var(--panel)); border-color: var(--border); } +[data-module="slack"] .sl-emoji-btn:hover { background: var(--border); border-color: var(--accent); } +[data-module="slack"] .sl-emoji-btn .sl-emoji { width: 1.1em; height: 1.1em; vertical-align: middle; } +[data-module="slack"] .sl-emoji-search { + width: 100%; + box-sizing: border-box; + padding: 6px 9px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 0.9em; +} +[data-module="slack"] .sl-emoji-search:focus { outline: none; border-color: var(--accent); } +[data-module="slack"] .sl-emoji-results { + display: flex; + flex-wrap: wrap; + gap: 2px; + max-height: 150px; + overflow-y: auto; + min-height: 34px; +} +[data-module="slack"] .sl-emoji-none { + width: 100%; + padding: 8px 4px; + text-align: center; + color: var(--muted); + font-size: 0.8em; +} +[data-module="slack"] .sl-emoji-msg { + min-height: 1em; + font-size: 0.78em; + color: var(--muted); +} +[data-module="slack"] .sl-emoji-msg:empty { display: none; } +[data-module="slack"] .sl-emoji-msg.is-error { color: var(--danger); font-weight: 600; } + +/* the PIN pad inside the menu: fixed sizes, no tile to scale with */ +[data-module="slack"] .sl-emoji-menu .sl-pad { max-width: 100%; gap: 6px; } +[data-module="slack"] .sl-emoji-menu .sl-keys { grid-template-rows: repeat(4, 40px); } +[data-module="slack"] .sl-emoji-menu .sl-key { font-size: 17px; } diff --git a/tools/slack-mock.js b/tools/slack-mock.js index e61871c..af8c86f 100644 --- a/tools/slack-mock.js +++ b/tools/slack-mock.js @@ -18,10 +18,16 @@ * conversations.history newest-first window (limit); not_in_channel for * #announcements * conversations.replies a thread's parent + replies - * users.info display names for the seeded users + * users.info display names and profile images (SVG squares + * served by this mock under /img/u/.svg) * chat.postMessage appends to the channel (echoes `username` for * bots as a bot_message); text containing "!429" * answers HTTP 429 with Retry-After: 3 + * emoji.list four custom emoji: two images (/img/e/.svg), + * an alias of a standard emoji, an alias of a custom + * reactions.add/.remove on a message (or reply) by channel + timestamp; + * already_reacted / no_reaction / message_not_found + * as Slack answers them; name "nope" → invalid_name * * #production is seeded with mrkdwn, mentions, a link, a thread, a bot * attachment and a file; it gains a new fake message every ~8 s, edits one @@ -78,6 +84,53 @@ function push(channelId, msg) { return m; } +/** A message or a reply by timestamp, anywhere in a channel. */ +function findMessage(channelId, stamp) { + for (const m of history.get(channelId) || []) { + if (m.ts === stamp) return m; + for (const r of m.replies || []) if (r.ts === stamp) return r; + } + return null; +} + +/** Slack's reactions array: [{ name, users, count }], edited in place. */ +function addReaction(m, name, userId) { + m.reactions = m.reactions || []; + let r = m.reactions.find((x) => x.name === name); + if (r && r.users.includes(userId)) return 'already_reacted'; + if (!r) { + r = { name, users: [], count: 0 }; + m.reactions.push(r); + } + r.users.push(userId); + r.count = r.users.length; + return ''; +} + +function removeReaction(m, name, userId) { + const r = (m.reactions || []).find((x) => x.name === name); + if (!r || !r.users.includes(userId)) return 'no_reaction'; + r.users = r.users.filter((u) => u !== userId); + r.count = r.users.length; + if (!r.count) m.reactions = m.reactions.filter((x) => x !== r); + if (!m.reactions.length) delete m.reactions; + return ''; +} + +/* A square SVG with an initial, one hue per id — the mock's stand-in for a + profile image or a custom emoji picture. */ +const IMAGE_HUES = { U001: 150, U002: 210, U003: 330, U004: 30, U005: 270, UBOT: 0, party_parrot: 120, waters: 200 }; +function squareSvg(id, letter) { + const hue = IMAGE_HUES[id] ?? 180; + return `${letter}`; +} +const CUSTOM_EMOJI = { + party_parrot: (port) => `http://127.0.0.1:${port}/img/e/party_parrot.svg`, + waters: (port) => `http://127.0.0.1:${port}/img/e/waters.svg`, + thumbsup_all: () => 'alias:+1', + parrot: () => 'alias:party_parrot', +}; + function seed() { const min = 60000; const at = (minsAgo) => ts(-minsAgo * min); @@ -97,7 +150,9 @@ function seed() { attachments: [{ fallback: 'Plan updated: Sunday Service — Sept 14 (2 items changed)', title: 'Plan updated', text: 'Sunday Service — Sept 14 (2 items changed)' }], }); push('C001', { ts: at(30), user: 'U005', text: 'Lower thirds for the announcements: ', files: [{ id: 'F001', name: 'lower-thirds-0914.png', title: 'lower-thirds-0914.png' }] }); - push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } }); + const fixed = push('C001', { ts: at(25), user: 'U004', text: 'Audio fixed — Dante patch was it. Thanks <@U002> :pray:', edited: { user: 'U004', ts: at(24) } }); + fixed.reactions = [{ name: '+1', users: ['U002', 'U003', 'U001'], count: 3 }, { name: 'party_parrot', users: ['U005'], count: 1 }, { name: 'pray::skin-tone-3', users: ['U002'], count: 1 }]; + push('C001', { ts: at(23), user: 'U002', text: 'Custom emoji check :party_parrot: :waters: and an unknown :not_an_emoji: stay put' }); push('C001', { ts: at(20), user: 'U002', text: ' 10 minutes to doors. Campuses, post "ready" when your pre-service loop is running.' }); push('C001', { ts: at(15), user: 'U001', text: 'Apollo Beach ready' }); push('C001', { ts: at(14), user: 'U004', text: 'North Attleboro ready' }); @@ -245,6 +300,13 @@ const server = http.createServer(async (req, res) => { const summary = Object.entries(params).filter(([k]) => k !== 'token').map(([k, v]) => `${k}=${String(v).slice(0, 40)}`).join(' '); console.log(`[slack-mock] ${req.method} ${url.pathname} ${summary}`); + const img = url.pathname.match(/^\/img\/(u|e)\/([A-Za-z0-9_+-]+)\.svg$/); + if (img) { + const id = img[2]; + const letter = img[1] === 'u' ? (USERS[id]?.display_name || USERS[id]?.real_name || id)[0].toUpperCase() : id[0].toUpperCase(); + res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' }); + return res.end(squareSvg(id, letter)); + } if (!url.pathname.startsWith('/api/')) return send(res, 404, { ok: false, error: 'unknown_method' }); const token = tokenOf(req, params); @@ -260,7 +322,8 @@ const server = http.createServer(async (req, res) => { case 'users.info': { const u = USERS[String(params.user)]; if (!u) return send(res, 200, { ok: false, error: 'user_not_found' }); - return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name } } }); + const img = `http://127.0.0.1:${PORT}/img/u/${params.user}.svg`; + return send(res, 200, { ok: true, user: { id: params.user, name: u.name, real_name: u.real_name, is_bot: Boolean(u.is_bot), profile: { display_name: u.display_name, real_name: u.real_name, image_48: img, image_72: img } } }); } case 'conversations.list': { @@ -301,6 +364,27 @@ const server = http.createServer(async (req, res) => { return send(res, 200, { ok: true, messages: [publicMessage(parent), ...(parent.replies || []).slice(0, limit - 1)], has_more: false }); } + case 'emoji.list': { + const emoji = {}; + for (const [name, v] of Object.entries(CUSTOM_EMOJI)) emoji[name] = v(PORT); + return send(res, 200, { ok: true, emoji, cache_ts: '1700000000.000000' }); + } + + case 'reactions.add': + case 'reactions.remove': { + const c = findChannel(params.channel); + if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' }); + if (!c.is_member) return send(res, 200, { ok: false, error: 'not_in_channel' }); + const name = String(params.name || ''); + if (!name || name === 'nope') return send(res, 200, { ok: false, error: 'invalid_name' }); + const m = findMessage(c.id, String(params.timestamp || '')); + if (!m) return send(res, 200, { ok: false, error: 'message_not_found' }); + const err = method === 'reactions.add' ? addReaction(m, name, who.user_id) : removeReaction(m, name, who.user_id); + if (err) return send(res, 200, { ok: false, error: err }); + console.log(`[slack-mock] ${method === 'reactions.add' ? '➕' : '➖'} :${name}: on ${m.ts} in #${c.name} by ${who.user}`); + return send(res, 200, { ok: true }); + } + case 'chat.postMessage': { const c = findChannel(params.channel); if (!c) return send(res, 200, { ok: false, error: 'channel_not_found' });