@homie-rocks/studio 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ /**
2
+ * ROOM CHAT ON THE PAGE (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): one component for the play page, the big
3
+ * screen (the TV view) and the watch page. It speaks on the page's own watch socket, so every game has it, old builds
4
+ * too, with no game code:
5
+ *
6
+ * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD),
7
+ * its sheet (the room's last minutes, the room's emoji, its quick lines, typing where the rules allow,
8
+ * Report on a line, and for the studio's owner Remove, Mute and Kick), a short ticker of new lines where the
9
+ * status chip sits, and reactions floating up the screen
10
+ * tv the room's lines in a corner (no typing: a television is read across a room) and the float
11
+ * watch a Chat button in the watch page's band, its panel over the stage, and the float
12
+ *
13
+ * The float is the TV's own (homie.rocks's live rooms paint reactions on the television the same way): at most six a
14
+ * second, eighteen at once, each rising for 2.4 s with a little sway. The five reactions and their order are
15
+ * homie.rocks's (fire, clap, laugh, heart, wow), and so are the message shapes (`say` and `react` up, `line` and
16
+ * `react` down) and the "One line at a time." of its slow mode.
17
+ *
18
+ * Names and messages are always text (textContent), never markup. Nothing here is stored but this browser's two
19
+ * switches (localStorage): "Show chat on this screen" and "Show my messages over my character".
20
+ */
21
+ import { REACTIONS } from './chat.mjs';
22
+ import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
23
+ import { NET_PALETTE } from './room.mjs';
24
+
25
+ /** What a page's chat starts from (`window.__HOMIE_CHAT`): its surface, the game, and who this browser is to the studio. */
26
+ export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null, place = null } = {}) {
27
+ const screen = g?.screen?.chat;
28
+ const ticker = screen === false ? false : typeof screen === 'string' ? screen : screen && typeof screen === 'object' && typeof screen.at === 'string' ? screen.at : null;
29
+ return {
30
+ surface, game: g.id, name: g.name, owner: Boolean(owner), acct: Boolean(acct), member: Boolean(member), palette: NET_PALETTE,
31
+ reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })),
32
+ ...(room ? { room } : {}),
33
+ // game.json "screen": { "chat": "bottom-right" | false }: where new lines show for a moment (false: only the pill's count).
34
+ ...(ticker !== null ? { ticker } : {}),
35
+ ...(place ? { place } : {}),
36
+ account: '/account/',
37
+ };
38
+ }
39
+
40
+ export const CHAT_CSS = `
41
+ /* ROOM CHAT (section 19). */
42
+ .cpill .cico { width: 15px; height: 15px; flex: none; }
43
+ .cpill .cbadge { min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 8px; background: var(--hot); color: #0b0b10; font: 800 10px/16px ui-sans-serif, system-ui, sans-serif; text-align: center; }
44
+ .cpill.dim .cbadge { display: inline-block; }
45
+ .cpill.icon-only span.cword { display: none; }
46
+ .csheet { display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 16px)); }
47
+ .chead { display: flex; align-items: center; gap: 8px; margin: 0 2px; }
48
+ .chead b { font: 800 15px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
49
+ .chead span { flex: 1; min-width: 0; color: #aab3c7; font-weight: 500; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
50
+ .chead button { flex: none; width: 30px; height: 30px; border-radius: 50%; border: 0; background: rgba(255,255,255,.07); color: inherit; font: 600 15px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
51
+ .cfeed { list-style: none; margin: 0; padding: 0 2px; min-height: 48px; max-height: clamp(96px, 30vh, 280px); overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 2px; font: 500 14px/1.38 ui-sans-serif, system-ui, -apple-system, sans-serif; -webkit-user-select: text; user-select: text; touch-action: pan-y; }
52
+ .cfeed .cempty { color: #7d8699; font-size: 13px; padding: 10px 4px; cursor: default; }
53
+ .cfeed li { padding: 3px 6px; border-radius: 8px; overflow-wrap: anywhere; cursor: pointer; }
54
+ .cfeed li:hover { background: rgba(255,255,255,.04); }
55
+ .cfeed li.cmine { cursor: default; }
56
+ .cfeed .cn { font-weight: 800; margin-right: 6px; }
57
+ .cfeed .ctag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 999px; font: 800 10px/1.6 ui-monospace, Menlo, monospace; letter-spacing: .06em; vertical-align: 1px; background: color-mix(in srgb, var(--hot) 22%, transparent); color: #fff; }
58
+ .cfeed li.creact { opacity: .7; font-size: 1.05em; }
59
+ .cfeed li.creact .cg { font-size: 1.45em; line-height: 1; vertical-align: -.12em; }
60
+ .cfeed li.cstudio { border: 1px solid color-mix(in srgb, var(--hot) 60%, transparent); }
61
+ .cfeed li.cstudio .cn { color: var(--hot); }
62
+ .cfeed li.cpending { opacity: .55; }
63
+ .cfeed li.cheld { color: #ffb3bd; }
64
+ .cfeed li .cwhy { display: block; font-size: 12px; color: #ff8a9a; font-weight: 600; }
65
+ .cfeed li.cgone { opacity: .45; text-decoration: line-through; }
66
+ .cacts { display: flex; flex-wrap: wrap; gap: 6px; margin: 5px 0 2px; }
67
+ .cacts button { min-height: 30px; padding: 0 10px; border-radius: 9px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: inherit; font: 700 12px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
68
+ .cacts button.warn { color: #ffc27a; border-color: rgba(255,179,92,.55); }
69
+ .cacts button.bad { color: #ff8a9a; border-color: rgba(255,107,125,.55); }
70
+ .cacts button.bad.armed { background: #ff6b7d; color: #16080a; }
71
+ .cnew { align-self: center; margin-top: -4px; padding: 3px 10px; border-radius: 999px; border: 0; background: var(--hot); color: #0b0b10; font: 800 11.5px/1.3 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
72
+ .creacts { display: flex; gap: 6px; justify-content: space-between; }
73
+ .creacts button { width: 2.5rem; height: 2.5rem; flex: none; border-radius: 50%; border: 1px solid rgba(255,255,255,.16); background: rgba(255,255,255,.04); font: 1.25rem/1 "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; cursor: pointer; touch-action: manipulation; transition: transform .12s ease-out, border-color .12s; padding: 0; }
74
+ .creacts button.pop { transform: scale(1.16); border-color: var(--hot); }
75
+ .creacts button[disabled] { opacity: .35; cursor: default; }
76
+ .clines { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; touch-action: pan-x; padding: 1px 0 2px; }
77
+ .clines::-webkit-scrollbar { display: none; }
78
+ .clines button { flex: none; min-height: 32px; padding: 0 11px; border-radius: 999px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12.5px/1 ui-sans-serif, system-ui, sans-serif; white-space: nowrap; cursor: pointer; touch-action: manipulation; }
79
+ .csay { display: flex; gap: 6px; margin: 0; }
80
+ .csay input { flex: 1; min-width: 0; min-height: 40px; box-sizing: border-box; border-radius: 11px; border: 1px solid rgba(255,255,255,.18); background: rgba(255,255,255,.05); color: inherit; font: 500 16px/1.2 ui-sans-serif, system-ui, sans-serif; padding: 0 11px; -webkit-user-select: text; user-select: text; }
81
+ .csay input:focus { outline: 2px solid color-mix(in srgb, var(--hot) 70%, transparent); outline-offset: 0; }
82
+ .csay button { min-height: 40px; padding: 0 14px; border-radius: 11px; border: 0; background: var(--hot); color: #0b0b10; font: 800 14px/1 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
83
+ .cnote { margin: 0 2px; color: #aab3c7; font: 500 12.5px/1.4 ui-sans-serif, system-ui, sans-serif; }
84
+ .cnote a { color: var(--hot); font-weight: 700; text-decoration: none; }
85
+ .copts { display: grid; gap: 2px; border-top: 1px solid rgba(255,255,255,.08); padding-top: 6px; margin: 0 2px; }
86
+ .copts label { display: flex; justify-content: space-between; align-items: center; gap: 10px; min-height: 30px; font: 600 12.5px/1.25 ui-sans-serif, system-ui, sans-serif; cursor: pointer; }
87
+ .copts input { width: 18px; height: 18px; flex: none; accent-color: var(--hot); }
88
+ .creport { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0 2px; }
89
+ .creport p { grid-column: 1 / -1; margin: 0; color: #c3cad9; font: 600 12.5px/1.35 ui-sans-serif, system-ui, sans-serif; }
90
+ .creport button { min-height: 32px; padding: 0 8px; border-radius: 9px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-align: left; }
91
+ .ctick { position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
92
+ .ctick.at-bottom-left { left: max(10px, env(safe-area-inset-left)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-start; }
93
+ .ctick.at-bottom-right { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-end; }
94
+ .ctick.at-bottom-center { left: 50%; transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: center; }
95
+ .ctick.at-top-left { left: max(10px, env(safe-area-inset-left)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-start; flex-direction: column-reverse; }
96
+ .ctick.at-top-right { right: max(10px, env(safe-area-inset-right)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-end; flex-direction: column-reverse; }
97
+ .ctick.at-top-center { left: 50%; transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: center; flex-direction: column-reverse; }
98
+ .ctick div { padding: 5px 10px; border-radius: 12px; background: rgba(6,9,16,.66); color: #eef1f8; font: 600 13px/1.3 ui-sans-serif, system-ui, -apple-system, sans-serif; overflow-wrap: anywhere; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ctin .2s ease-out; transition: opacity .5s; }
99
+ .ctick div b { font-weight: 800; margin-right: 6px; }
100
+ .ctick div.cout { opacity: 0; }
101
+ @keyframes ctin { from { opacity: 0; transform: translateY(6px); } }
102
+ .cfloat { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; }
103
+ .ctv { position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
104
+ .ctv:empty { display: none; }
105
+ .ctv.at-bottom-left { left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); }
106
+ .ctv.at-bottom-right { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); }
107
+ .ctv.at-top-left { left: max(16px, env(safe-area-inset-left)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
108
+ .ctv.at-top-right { right: max(16px, env(safe-area-inset-right)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
109
+ .ctv div { overflow-wrap: anywhere; transition: opacity 1s; }
110
+ .ctv div.cout { opacity: 0; }
111
+ .ctv b { font-weight: 800; margin-right: .4em; }
112
+ .ctv .cg { font-size: 1.5em; line-height: 1; vertical-align: -.12em; }
113
+ .ctv .creact { opacity: .75; }
114
+ .cwatch { flex: none; display: inline-flex; align-items: center; gap: .45em; min-height: 2.4em; padding: 0 .9em 0 .75em; border-radius: 11px; border: 1px solid rgba(255,255,255,.18); background: transparent; color: #eef1f8; font: inherit; font-weight: 800; cursor: pointer; }
115
+ .cwatch .cico { width: 1.1em; height: 1.1em; }
116
+ .cwatch[aria-expanded="true"] { border-color: var(--hot); }
117
+ .cwatch .cbadge { min-width: 1.3em; height: 1.3em; padding: 0 .3em; box-sizing: border-box; border-radius: .65em; background: var(--hot); color: #0b0b10; font-size: .72em; line-height: 1.3em; text-align: center; }
118
+ .cpanel { position: absolute; z-index: 8; top: 10px; right: max(10px, env(safe-area-inset-right)); box-sizing: border-box; padding: 12px; border-radius: 16px; background: rgba(8,12,22,.94); border: 1px solid rgba(255,255,255,.16); box-shadow: 0 18px 50px rgba(0,0,0,.5); color: #eef1f8; font-size: 13px; }
119
+ @media (max-width: 640px) {
120
+ .cpanel { top: auto; bottom: 0; left: 0; right: 0; width: auto; border-radius: 18px 18px 0 0; padding-bottom: max(12px, env(safe-area-inset-bottom)); }
121
+ .cpanel.csheet { width: auto; }
122
+ .cwatch .cword { display: none; }
123
+ }
124
+ @media (max-height: 460px) { .cfeed { max-height: 26vh; } .clines { display: none; } }
125
+ @media (prefers-reduced-motion: reduce) { .ctick div { animation: none; } .creacts button { transition: none; } }
126
+ `;
127
+
128
+ /**
129
+ * The owner's chat tools (a page served to the studio's signed-in owner only; nobody else's page has a byte of it):
130
+ * Remove a line from every screen, Mute its sender for 10 minutes, or Kick them out of the room (a second tap), each
131
+ * taking the sender's lines down too. The controls go to the studio's own /_studio/api with the owner's session.
132
+ */
133
+ export const CHAT_OWNER_JS = String.raw`(function () {
134
+ 'use strict';
135
+ var st = window.__homieChat; var cfg = window.__HOMIE_CHAT;
136
+ if (!st || !cfg || !cfg.owner) return;
137
+ function call(path, body, done) {
138
+ fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
139
+ .then(function (r) { return r.json().catch(function () { return { ok: false, message: 'HTTP ' + r.status }; }); })
140
+ .then(function (j) { st.pick = null; st.flash(j.ok ? done : (j.message || 'That did not work.')); st.render(); })
141
+ .catch(function () { st.flash('The studio did not answer.'); });
142
+ }
143
+ function button(cls, text, fn) { var b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.onclick = function (e) { e.stopPropagation(); fn(); }; return b; }
144
+ st.ownerActs = function (m, acts) {
145
+ acts.append(
146
+ button('bad', 'Remove', function () { call('/_studio/api/chat/remove', { game: cfg.game, room: st.room, id: m.id }, 'Removed from every screen.'); }),
147
+ button('warn', 'Mute 10 min', function () { call('/_studio/api/mute', { game: cfg.game, room: st.room, line: m.id, minutes: 10, purge: true }, 'Muted for 10 min; their messages are down.'); }),
148
+ button('bad' + (st.armed === m.id ? ' armed' : ''), st.armed === m.id ? 'Tap again to remove them' : 'Kick', function () {
149
+ if (st.armed !== m.id) { st.armed = m.id; st.render(); return; }
150
+ st.armed = null;
151
+ call('/_studio/api/kick', { game: cfg.game, room: st.room, line: m.id, minutes: 10, purge: true }, 'Removed from the room for 10 min.');
152
+ }));
153
+ };
154
+ }());`;
155
+
156
+ /* The chat component (the site's own origin; it never touches the game's frame except to tell it the two switches). */
157
+ export const CHAT_JS = String.raw`(function () {
158
+ 'use strict';
159
+ var cfg = window.__HOMIE_CHAT;
160
+ if (!cfg) return;
161
+ var surface = cfg.surface;
162
+ var palette = cfg.palette || [];
163
+ var reduce = false;
164
+ try { reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
165
+ var st = { ws: null, room: cfg.room || null, rules: null, now: null, lines: [], open: false, unread: 0, pending: {}, seq: 0, show: true, bubble: true, armed: null, pick: null, report: null, log: [], got: {}, sent: {} };
166
+ window.__homieChat = st;
167
+ try { st.show = localStorage.getItem('homie-chat-show') !== '0'; st.bubble = localStorage.getItem('homie-chat-bubble') !== '0'; } catch (e) {}
168
+ function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
169
+ function now() { return Date.now(); }
170
+ // A name's colour: the player's seat colour (the game's own), else homie.rocks's room chat hue from the name.
171
+ function hueOf(s) { var h = 2166136261; s = String(s || ''); for (var i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0; return h % 360; }
172
+ function colourOf(m) { return typeof m.colour === 'number' && palette.length ? palette[m.colour % palette.length] : 'oklch(.82 .13 ' + hueOf(m.name) + ')'; }
173
+ function mySeat() { var s = window.__shell; return s && typeof s.seat === 'number' ? s.seat : null; }
174
+ var ICON = '<svg class="cico" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.3 9.6 9.6 0 0 1-3.6-.7L3 21l1.6-4.5A8 8 0 0 1 3 11.5 8.4 8.4 0 0 1 12 3.2a8.4 8.4 0 0 1 9 8.3Z"/></svg>';
175
+
176
+ /* ------------------------------------------------------------ the float: the TV's reactions, on this screen */
177
+ var canvas = null; var ctx = null; var parts = []; var spent = []; var raf = 0;
178
+ function floatLayer() {
179
+ if (canvas) return;
180
+ var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
181
+ if (!host) return;
182
+ canvas = el('canvas', 'cfloat'); canvas.setAttribute('aria-hidden', 'true');
183
+ if (surface === 'watch') { canvas.style.position = 'absolute'; }
184
+ host.appendChild(canvas);
185
+ ctx = canvas.getContext ? canvas.getContext('2d') : null;
186
+ if (!ctx) { canvas = null; return; }
187
+ }
188
+ function burst(glyph, id) {
189
+ if (!st.show || !glyph) return;
190
+ if (reduce) { tick({ name: '', glyph: glyph, react: true }); return; }
191
+ floatLayer();
192
+ if (!canvas) return;
193
+ var t = performance.now();
194
+ while (spent.length && spent[0] < t - 1000) spent.shift();
195
+ // At most six a second and eighteen at once (the TV's own numbers): a storm stays a storm, never a wall.
196
+ if (spent.length >= 6 || parts.length >= 18) return;
197
+ spent.push(t);
198
+ parts.push({ glyph: glyph, at: t, x: 0.1 + Math.random() * 0.8, sway: Math.random() * 2 - 1, id: id || null, seen: false });
199
+ if (!raf) raf = requestAnimationFrame(paint);
200
+ }
201
+ function paint(t) {
202
+ raf = 0;
203
+ if (!canvas) return;
204
+ var dpr = Math.min(2, window.devicePixelRatio || 1);
205
+ var w = canvas.clientWidth; var h = canvas.clientHeight;
206
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); }
207
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
208
+ ctx.clearRect(0, 0, w, h);
209
+ ctx.font = Math.max(20, Math.min(40, h * 0.05)) + 'px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
210
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
211
+ parts = parts.filter(function (p) { return t - p.at < 2400; });
212
+ for (var i = 0; i < parts.length; i++) {
213
+ var p = parts[i]; var age = (t - p.at) / 2400;
214
+ if (age < 0) continue;
215
+ ctx.globalAlpha = Math.min(1, age * 8) * (1 - age);
216
+ ctx.fillText(p.glyph, w * p.x + Math.sin(age * 5) * p.sway * Math.min(48, w * 0.025), h * (0.9 - age * 0.55));
217
+ // When a reaction is first on this screen (what a latency check reads: the line's id and the wall clock).
218
+ if (!p.seen) { p.seen = true; if (p.id) st.got[p.id] = Date.now(); }
219
+ }
220
+ ctx.globalAlpha = 1;
221
+ if (parts.length) raf = requestAnimationFrame(paint);
222
+ }
223
+
224
+ /* ------------------------------------------------------------ the ticker: new lines, for a moment, where the chip sits */
225
+ var tickEl = null;
226
+ function tickPlace() {
227
+ if (cfg.ticker) return cfg.ticker;
228
+ var s = window.__shell && window.__shell.share;
229
+ return s && s.at === 'bottom-left' ? 'bottom-right' : 'bottom-left';
230
+ }
231
+ function tick(m) {
232
+ if (surface === 'tv' || cfg.ticker === false || !st.show || st.open) return;
233
+ if (!tickEl) {
234
+ var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
235
+ if (!host) return;
236
+ tickEl = el('div', 'ctick'); tickEl.setAttribute('aria-hidden', 'true'); host.appendChild(tickEl); if (surface === 'watch') tickEl.style.position = 'absolute';
237
+ }
238
+ tickEl.className = 'ctick at-' + tickPlace();
239
+ var row = el('div');
240
+ if (m.name) { var b = el('b', '', m.name); b.style.color = colourOf(m); row.appendChild(b); }
241
+ row.appendChild(document.createTextNode(m.glyph && m.react ? m.glyph : (m.text || '')));
242
+ tickEl.appendChild(row);
243
+ while (tickEl.children.length > 3) tickEl.removeChild(tickEl.firstChild);
244
+ setTimeout(function () { row.classList.add('cout'); setTimeout(function () { if (row.parentNode) row.remove(); }, 600); }, 4500);
245
+ }
246
+
247
+ /* ------------------------------------------------------------ the TV's corner */
248
+ var tvEl = null;
249
+ function tvLine(m) {
250
+ if (!st.show) return;
251
+ if (!tvEl) {
252
+ if (!document.body) return;
253
+ tvEl = el('div', 'ctv');
254
+ var join = document.querySelector('[data-join]');
255
+ var at = join && /join-bottom-left/.test(join.className) ? 'bottom-right' : join && /join-top-left|join-top-right/.test(join.className) ? 'bottom-left' : 'bottom-left';
256
+ tvEl.className = 'ctv at-' + (cfg.ticker && cfg.ticker !== 'bottom-center' && cfg.ticker !== 'top-center' ? cfg.ticker : at);
257
+ document.body.appendChild(tvEl);
258
+ }
259
+ var row = el('div', m.t === 'react' ? 'creact' : '');
260
+ row.setAttribute('data-line', m.id || '');
261
+ var b = el('b', '', m.by === 'studio' ? 'Studio' : m.name); b.style.color = m.by === 'studio' ? 'var(--hot)' : colourOf(m); row.appendChild(b);
262
+ if (m.t === 'react') row.appendChild(el('span', 'cg', m.glyph || '')); else row.appendChild(document.createTextNode(m.text || ''));
263
+ tvEl.appendChild(row);
264
+ while (tvEl.children.length > 6) tvEl.removeChild(tvEl.firstChild);
265
+ setTimeout(function () { row.classList.add('cout'); setTimeout(function () { if (row.parentNode) row.remove(); }, 1100); }, 20000);
266
+ }
267
+
268
+ /* ------------------------------------------------------------ the pill, the sheet, the panel */
269
+ var pill = null; var badge = null; var sheet = null; var feed = null; var newBtn = null; var reactsEl = null; var linesEl = null; var form = null; var input = null; var note = null; var where = null;
270
+ var bubbleBox = null; var showBox = null;
271
+ function build() {
272
+ if (surface === 'tv' || sheet) return;
273
+ if (surface === 'play') {
274
+ var band = document.querySelector('[data-room-ui] .pills');
275
+ var ui = document.querySelector('[data-room-ui]');
276
+ if (!band || !ui) return;
277
+ pill = el('button', 'pill cpill'); pill.type = 'button'; pill.setAttribute('data-chat-toggle', ''); pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-controls', 'chat-sheet'); pill.setAttribute('aria-label', 'Room chat');
278
+ pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
279
+ band.appendChild(pill);
280
+ sheet = el('div', 'sheet csheet'); sheet.id = 'chat-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.hidden = true;
281
+ ui.appendChild(sheet);
282
+ // One sheet at a time: the room's and the server's close this one, and this one closes theirs.
283
+ ['[data-share-toggle]', '[data-server-toggle]'].forEach(function (q) { var b = document.querySelector(q); if (b) b.addEventListener('click', function () { openChat(false, true); }); });
284
+ } else {
285
+ var top = document.querySelector('[data-top]'); var playb = document.querySelector('[data-playb]');
286
+ if (!top) return;
287
+ pill = el('button', 'cwatch'); pill.type = 'button'; pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-label', 'Room chat');
288
+ pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
289
+ top.insertBefore(pill, playb || null);
290
+ sheet = el('div', 'cpanel csheet'); sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.setAttribute('data-keep-focus', ''); sheet.hidden = true;
291
+ (document.querySelector('.stage') || document.body).appendChild(sheet);
292
+ }
293
+ pill.addEventListener('click', function (e) { e.stopPropagation(); openChat(!st.open); });
294
+ var head = el('div', 'chead'); head.appendChild(el('b', '', 'Room chat')); where = el('span'); head.appendChild(where);
295
+ var x = el('button', '', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Close chat'); x.onclick = function () { openChat(false); }; head.appendChild(x);
296
+ feed = el('ol', 'cfeed'); feed.setAttribute('aria-live', 'polite'); feed.setAttribute('data-chat-feed', '');
297
+ feed.addEventListener('scroll', function () { if (atBottom()) newBtn.hidden = true; }, { passive: true });
298
+ newBtn = el('button', 'cnew', 'New messages ↓'); newBtn.type = 'button'; newBtn.hidden = true; newBtn.onclick = function () { feed.scrollTop = feed.scrollHeight; newBtn.hidden = true; };
299
+ reactsEl = el('div', 'creacts'); reactsEl.setAttribute('data-chat-reacts', '');
300
+ linesEl = el('div', 'clines'); linesEl.setAttribute('data-chat-lines', '');
301
+ form = el('form', 'csay'); form.setAttribute('data-chat-say', '');
302
+ input = el('input'); input.type = 'text'; input.setAttribute('enterkeyhint', 'send'); input.setAttribute('autocomplete', 'off'); input.setAttribute('aria-label', 'Say something to the room'); input.placeholder = 'Say something…';
303
+ var go = el('button', '', 'Say'); go.type = 'submit';
304
+ form.append(input, go);
305
+ form.addEventListener('submit', function (e) { e.preventDefault(); sendText(); });
306
+ input.addEventListener('keydown', function (e) { if (e.key === 'Escape') { e.preventDefault(); openChat(false); } e.stopPropagation(); });
307
+ note = el('p', 'cnote'); note.hidden = true; note.setAttribute('data-chat-note', '');
308
+ var opts = el('div', 'copts');
309
+ var l1 = el('label'); l1.appendChild(el('span', '', 'Show my messages over my character')); bubbleBox = el('input'); bubbleBox.type = 'checkbox'; bubbleBox.checked = st.bubble; bubbleBox.setAttribute('data-chat-bubble', ''); l1.appendChild(bubbleBox);
310
+ var l2 = el('label'); l2.appendChild(el('span', '', 'Show chat on this screen')); showBox = el('input'); showBox.type = 'checkbox'; showBox.checked = st.show; showBox.setAttribute('data-chat-show', ''); l2.appendChild(showBox);
311
+ bubbleBox.addEventListener('change', function () { st.bubble = bubbleBox.checked; try { localStorage.setItem('homie-chat-bubble', st.bubble ? '1' : '0'); } catch (e) {} tellFrame(); });
312
+ showBox.addEventListener('change', function () { st.show = showBox.checked; try { localStorage.setItem('homie-chat-show', st.show ? '1' : '0'); } catch (e) {} tellFrame(); if (!st.show) { parts = []; if (tickEl) tickEl.textContent = ''; } });
313
+ opts.append(l1, l2);
314
+ if (surface !== 'play') l1.hidden = true;
315
+ sheet.append(head, feed, newBtn, reactsEl, linesEl, form, note, opts);
316
+ document.addEventListener('pointerdown', function (e) { if (st.open && !sheet.contains(e.target) && !pill.contains(e.target)) openChat(false); }, true);
317
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && st.open) openChat(false); });
318
+ renderFeed();
319
+ paintRules();
320
+ }
321
+ function openChat(on, quiet) {
322
+ if (!sheet) return;
323
+ st.open = Boolean(on);
324
+ sheet.hidden = !st.open;
325
+ pill.setAttribute('aria-expanded', st.open ? 'true' : 'false');
326
+ if (st.open) {
327
+ st.unread = 0; paintBadge();
328
+ if (surface === 'play') {
329
+ ['[data-share-sheet]', '[data-server-sheet]'].forEach(function (q) { var s = document.querySelector(q); if (s) s.hidden = true; });
330
+ ['[data-share-toggle]', '[data-server-toggle]'].forEach(function (q) { var b = document.querySelector(q); if (b) b.setAttribute('aria-expanded', 'false'); });
331
+ pill.classList.remove('dim');
332
+ }
333
+ if (tickEl) tickEl.textContent = '';
334
+ feed.scrollTop = feed.scrollHeight;
335
+ } else if (!quiet) {
336
+ var frame = document.querySelector('iframe.game');
337
+ if (surface === 'play' && frame) { try { frame.focus(); } catch (e) {} }
338
+ }
339
+ }
340
+ function paintBadge() { if (!badge) return; badge.hidden = !st.unread; badge.textContent = st.unread > 9 ? '9+' : String(st.unread); }
341
+ function tellFrame() {
342
+ var frame = document.querySelector('iframe.game');
343
+ try { if (frame && frame.contentWindow) frame.contentWindow.postMessage({ t: 'homie-chat', show: st.show, bubble: st.bubble }, '*'); } catch (e) {}
344
+ }
345
+
346
+ /* ------------------------------------------------------------ the rules: what this browser may send here */
347
+ function rank(mode) { return { off: 0, emoji: 1, lines: 2, text: 3 }[mode] || 0; }
348
+ function mayIf(who) { return cfg.owner || who === 'anyone' || (who === 'signed-in' && cfg.acct) || (who === 'members' && cfg.member); }
349
+ function iAmWatcher() { return surface === 'watch' || mySeat() === null; }
350
+ function paintRules() {
351
+ var r = st.rules;
352
+ if (!sheet || !r) { if (pill) pill.hidden = Boolean(r && r.mode === 'off'); return; }
353
+ pill.hidden = r.mode === 'off';
354
+ if (r.mode === 'off' && st.open) openChat(false);
355
+ var watchersOut = iAmWatcher() && !r.watchers && !cfg.owner;
356
+ var reacts = rank(r.mode) >= 1 && mayIf(r.react) && !watchersOut;
357
+ var lines = rank(r.mode) >= 2 && mayIf(r.react) && !watchersOut;
358
+ var typing = rank(r.mode) >= 3 && mayIf(r.who) && !watchersOut;
359
+ reactsEl.textContent = '';
360
+ (r.emoji || cfg.reactions).forEach(function (x) {
361
+ var b = el('button', '', x.e); b.type = 'button'; b.setAttribute('data-react', x.k); b.setAttribute('aria-label', 'React ' + x.k); b.title = x.k;
362
+ b.disabled = !reacts;
363
+ b.addEventListener('click', function () { sendReact(x, b); });
364
+ reactsEl.appendChild(b);
365
+ });
366
+ reactsEl.hidden = rank(r.mode) < 1;
367
+ linesEl.textContent = '';
368
+ (rank(r.mode) >= 2 ? r.lines || [] : []).forEach(function (l) {
369
+ var b = el('button', '', l.text); b.type = 'button'; b.setAttribute('data-say', l.id); b.disabled = !lines;
370
+ b.addEventListener('click', function () { sendLine(l); });
371
+ linesEl.appendChild(b);
372
+ });
373
+ linesEl.hidden = !(rank(r.mode) >= 2 && (r.lines || []).length);
374
+ form.hidden = !typing;
375
+ input.maxLength = r.max || 140;
376
+ var n = '';
377
+ var link = null;
378
+ if (watchersOut) n = 'Watchers read the chat here; only players send.';
379
+ else if (r.mode === 'emoji') n = r.capped === 'kids' ? 'This room keeps chat to emoji (a room for kids).' : 'This room keeps chat to emoji.';
380
+ else if (r.mode === 'lines') n = r.capped === 'kids' ? 'This room keeps chat to emoji and quick lines (a room for kids).' : 'This room keeps chat to emoji and quick lines.';
381
+ else if (!typing && r.who === 'signed-in') { n = 'Sign in to type here. Emoji ' + (rank(r.mode) >= 2 ? 'and quick lines are' : 'are') + ' open to everyone. '; link = 'Sign in'; }
382
+ else if (!typing && r.who === 'members') n = 'Only members of this server type here.';
383
+ if (!reacts && !watchersOut && r.react === 'signed-in' && !cfg.acct) { n = 'Sign in to chat in this room. '; link = 'Sign in'; }
384
+ note.textContent = n;
385
+ if (link) { var a = el('a', '', link); a.href = cfg.account + '?next=' + encodeURIComponent(location.pathname + location.search); a.target = surface === 'play' ? '_blank' : '_self'; note.appendChild(a); }
386
+ note.hidden = !n;
387
+ bubbleBox.parentNode.hidden = surface !== 'play' || !r.bubbles || mySeat() === null;
388
+ }
389
+ function paintWhere() {
390
+ if (!where) return;
391
+ var f = st.now; var c = f && f.counts ? f.counts : null;
392
+ var bits = [];
393
+ if (st.room) { var m = /^pub-(\d+)$/.exec(st.room); bits.push(m ? 'Room ' + m[1] : st.room); }
394
+ if (c) bits.push(c.players + (c.players === 1 ? ' playing' : ' playing') + (c.watchers ? ' · ' + c.watchers + ' watching' : ''));
395
+ where.textContent = bits.join(' · ');
396
+ }
397
+
398
+ /* ------------------------------------------------------------ sending */
399
+ function send(msg) {
400
+ var ws = st.ws;
401
+ if (!ws || ws.readyState !== 1) { flash('Not connected yet; try again in a moment.'); return false; }
402
+ try { ws.send(JSON.stringify(msg)); return true; } catch (e) { return false; }
403
+ }
404
+ function nextN() { st.seq += 1; return 'c' + st.seq + Math.random().toString(36).slice(2, 6); }
405
+ function sendReact(x, b) {
406
+ var n = nextN();
407
+ var msg = { t: 'react', kind: x.k, n: n };
408
+ if (!st.bubble) msg.bubble = false;
409
+ if (!send(msg)) return;
410
+ st.pending[n] = { react: true, at: now() };
411
+ st.sent[n] = now();
412
+ // Yours floats at once; the room's copy of it does not float twice.
413
+ burst(x.e, null);
414
+ if (b) { b.classList.add('pop'); setTimeout(function () { b.classList.remove('pop'); }, 160); }
415
+ }
416
+ function sendLine(l) {
417
+ var n = nextN();
418
+ var msg = { t: 'say', say: l.id, n: n };
419
+ if (!st.bubble) msg.bubble = false;
420
+ if (!send(msg)) return;
421
+ st.sent[n] = now();
422
+ addPending(n, l.text);
423
+ }
424
+ function sendText() {
425
+ var text = String(input.value || '').replace(/\s+/g, ' ').trim();
426
+ if (!text) return;
427
+ var n = nextN();
428
+ var msg = { t: 'say', text: text.slice(0, (st.rules && st.rules.max) || 280), n: n };
429
+ if (!st.bubble) msg.bubble = false;
430
+ if (!send(msg)) return;
431
+ st.sent[n] = now();
432
+ input.value = '';
433
+ addPending(n, text);
434
+ }
435
+ function addPending(n, text) {
436
+ var me = { id: null, n: n, t: 'line', name: myName(), text: text, seat: mySeat(), colour: mySeat(), pending: true, mine: true };
437
+ st.pending[n] = me;
438
+ st.lines.push(me);
439
+ renderFeed(true);
440
+ }
441
+ function myName() { var f = st.now; var s = mySeat(); var c = f && f.clients ? f.clients.filter(function (x) { return x.seat === s && s !== null; })[0] : null; return c ? c.name : 'You'; }
442
+
443
+ /* ------------------------------------------------------------ receiving */
444
+ function receive(m) {
445
+ if (!m || typeof m !== 'object') return false;
446
+ if (m.t === 'line' || m.t === 'react') { onLine(m, false); return true; }
447
+ if (m.t === 'lines' && Array.isArray(m.lines)) { st.lines = m.lines.slice(-80).map(function (x) { return Object.assign({}, x); }); renderFeed(); return true; }
448
+ if (m.t === 'unline' && Array.isArray(m.ids)) {
449
+ st.lines = st.lines.filter(function (x) { return m.ids.indexOf(x.id) < 0; });
450
+ if (tvEl) m.ids.forEach(function (id) { var r = tvEl.querySelector('[data-line="' + String(id).replace(/[^A-Za-z0-9_-]/g, '') + '"]'); if (r) r.remove(); });
451
+ renderFeed(); return true;
452
+ }
453
+ if (m.t === 'held' || m.t === 'slow') {
454
+ var p = m.n && st.pending[m.n];
455
+ if (p && !p.react) { p.pending = false; p.held = m.message || 'Not sent.'; if (input && !input.value && p.text && m.why !== 'words' && m.why !== 'harm' && m.why !== 'ai' && m.why !== 'contact') input.value = p.text; renderFeed(); setTimeout(function () { st.lines = st.lines.filter(function (x) { return x !== p; }); renderFeed(); }, 7000); }
456
+ else flash(m.message || 'Not sent.');
457
+ if (m.n) delete st.pending[m.n];
458
+ return true;
459
+ }
460
+ return false;
461
+ }
462
+ function onLine(m, quiet) {
463
+ var mine = Boolean(m.n && st.pending[m.n]) || (typeof m.seat === 'number' && m.seat === mySeat() && surface === 'play');
464
+ var p = m.n ? st.pending[m.n] : null;
465
+ if (p) delete st.pending[m.n];
466
+ var line = Object.assign({}, m, { mine: mine });
467
+ if (p && !p.react) { var i = st.lines.indexOf(p); if (i >= 0) st.lines[i] = line; else st.lines.push(line); }
468
+ else st.lines.push(line);
469
+ if (st.lines.length > 80) st.lines = st.lines.slice(-80);
470
+ st.log.push({ id: m.id, t: m.t, n: m.n || null, at: now(), mine: mine });
471
+ if (st.log.length > 200) st.log.shift();
472
+ if (m.t === 'react' && !(p && p.react)) burst(m.glyph, m.id);
473
+ else if (m.t === 'react' && p && p.react) st.got[m.id] = now();
474
+ if (m.t === 'line') st.got[m.id] = now();
475
+ if (surface === 'tv') { tvLine(m); return; }
476
+ if (!quiet && !mine && m.t === 'line' && m.by !== 'studio') {
477
+ if (!st.open) { st.unread += 1; paintBadge(); if (pill) pill.classList.remove('dim'); }
478
+ tick(m);
479
+ }
480
+ renderFeed(true);
481
+ }
482
+
483
+ /* ------------------------------------------------------------ the feed */
484
+ function atBottom() { return !feed || feed.scrollHeight - feed.scrollTop - feed.clientHeight < 24; }
485
+ function renderFeed(append) {
486
+ if (!feed) return;
487
+ var stick = atBottom();
488
+ feed.textContent = '';
489
+ if (!st.lines.length) { var e = el('li', 'cempty', 'Nothing said yet. Say hi, or send an emoji.'); feed.appendChild(e); return; }
490
+ st.lines.forEach(function (m) {
491
+ var li = el('li', (m.t === 'react' ? 'creact' : '') + (m.by === 'studio' ? ' cstudio' : '') + (m.pending ? ' cpending' : '') + (m.held ? ' cheld' : '') + (m.mine ? ' cmine' : ''));
492
+ if (m.id) li.setAttribute('data-line', m.id);
493
+ if (m.by === 'studio') li.appendChild(el('span', 'ctag', 'STUDIO'));
494
+ else if (m.owner) li.appendChild(el('span', 'ctag', 'OWNER'));
495
+ var n = el('span', 'cn', m.by === 'studio' ? 'Studio' : m.mine && surface === 'play' ? 'You' : m.name); if (m.by !== 'studio') n.style.color = colourOf(m); li.appendChild(n);
496
+ if (m.t === 'react') li.appendChild(el('span', 'cg', m.glyph || '')); else li.appendChild(el('span', 'ct', m.text || ''));
497
+ if (m.held) li.appendChild(el('span', 'cwhy', m.held));
498
+ if (m.reported) li.appendChild(el('span', 'cwhy', 'Reported. Thanks.'));
499
+ if (m.id && !m.mine && m.by !== 'studio' && !m.pending) li.addEventListener('click', function () { st.pick = st.pick === m.id ? null : m.id; st.armed = null; st.report = null; renderFeed(); });
500
+ if (m.id && st.pick === m.id) li.appendChild(actions(m));
501
+ feed.appendChild(li);
502
+ });
503
+ if (stick || !append) feed.scrollTop = feed.scrollHeight; else if (append) newBtn.hidden = false;
504
+ }
505
+ function actions(m) {
506
+ var box = el('div');
507
+ if (m.id && st.report === m.id) {
508
+ var rep = el('div', 'creport'); rep.appendChild(el('p', '', 'Tell the studio what is wrong with this message:'));
509
+ (cfg.reasons || []).forEach(function (r) { var b = el('button', '', r.text); b.type = 'button'; b.onclick = function (e) { e.stopPropagation(); report(m, r.k); }; rep.appendChild(b); });
510
+ box.appendChild(rep);
511
+ return box;
512
+ }
513
+ var acts = el('div', 'cacts');
514
+ if (!m.reported) { var r = el('button', 'warn', 'Report'); r.type = 'button'; r.onclick = function (e) { e.stopPropagation(); st.report = m.id; renderFeed(); }; acts.appendChild(r); }
515
+ // The studio's owner, in their own game: Remove, Mute and Kick, from the owner's own script (only their page has it).
516
+ if (cfg.owner && typeof st.ownerActs === 'function') st.ownerActs(m, acts);
517
+ box.appendChild(acts);
518
+ return box;
519
+ }
520
+ function report(m, reason) {
521
+ var b = null; try { b = localStorage.getItem('homie-b'); } catch (e) {}
522
+ fetch('/' + cfg.game + '/api/chat/report', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room: st.room, id: m.id, reason: reason, b: b }) })
523
+ .then(function (r) { return r.json().catch(function () { return { ok: false }; }); })
524
+ .then(function (j) { st.report = null; st.pick = null; if (j.ok) m.reported = true; flash(j.message || (j.ok ? 'Thanks for telling the studio.' : 'That did not go through.')); renderFeed(); })
525
+ .catch(function () { flash('That did not go through.'); });
526
+ }
527
+ var flashEl = null;
528
+ function flash(text) {
529
+ var t = document.querySelector('[data-toast]');
530
+ if (t) { t.textContent = text; t.hidden = false; clearTimeout(flash.t); flash.t = setTimeout(function () { t.hidden = true; }, 2600); return; }
531
+ if (!flashEl) { if (!document.body) return; flashEl = el('div', 'ctick at-top-center'); document.body.appendChild(flashEl); }
532
+ var row = el('div', '', text); flashEl.appendChild(row);
533
+ setTimeout(function () { row.remove(); }, 2600);
534
+ }
535
+
536
+ /* ------------------------------------------------------------ what the page tells it */
537
+ st.socket = function (ws, room) { st.ws = ws; if (room) st.room = room; build(); paintWhere(); };
538
+ st.receive = receive;
539
+ st.facts = function (f) {
540
+ if (!f || typeof f !== 'object') return;
541
+ st.now = f;
542
+ var r = f.policy && f.policy.chat ? f.policy.chat : null;
543
+ var sig = r ? JSON.stringify(r) : '';
544
+ if (sig !== st.rulesSig) { st.rulesSig = sig; st.rules = r; build(); paintRules(); }
545
+ paintWhere();
546
+ };
547
+ st.seat = function () { paintRules(); };
548
+ st.burst = burst;
549
+ st.flash = function (t) { flash(t); };
550
+ st.render = function () { renderFeed(); };
551
+ // A big screen has nothing to type with: its corner and the float only.
552
+ if (surface === 'tv') floatLayer();
553
+ // Settle the game's frame on this browser's two switches as soon as it loads.
554
+ var frame0 = document.querySelector('iframe.game');
555
+ if (frame0) frame0.addEventListener('load', tellFrame);
556
+ }());`;