@homie-rocks/studio 0.28.0 → 0.30.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,623 @@
1
+ /**
2
+ * THE LOUNGE'S PAGE (@homie-rocks/studio 0.29.0, chat/LOUNGE.md): /lounge/ in the studio's own look (worker/site.mjs
3
+ * layout). One column of talk on a phone, with "What's on" a tap away; the talk and a sidebar (the next play night, live
4
+ * rooms, show what you made, how it works) on a computer. The page's script is /_homie/lounge.js (the site's CSP allows
5
+ * no inline script): the room's own watch socket (NETPLAY.md section 19), the float, a passkey sign-in in place, cards,
6
+ * reports, a person's own Delete, and the keepers' Remove, Mute, Kick and slow mode.
7
+ *
8
+ * Names, lines and cards are always text (textContent) and links are built from checked https addresses, never markup.
9
+ */
10
+ import { REACTIONS } from './chat.mjs';
11
+ import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
12
+ import { PASSKEY_JS } from './account-page.mjs';
13
+ import { esc, icon, layout } from './site.mjs';
14
+ import { breadcrumbs } from './schema.mjs';
15
+ import { STUDIO_VERSION_TAG } from './version.mjs';
16
+
17
+ const jsonScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
18
+
19
+ const CSS = `
20
+ body[data-page="lounge"] main{display:block}
21
+ body[data-page="lounge"] .foot{display:none}
22
+ .lg{max-width:1260px;margin:0 auto;padding:clamp(10px,2vw,22px) max(var(--gutter),16px) 0;display:grid;grid-template-columns:minmax(0,1fr);gap:22px}
23
+ .lg-tabs{display:none}
24
+ .lg-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap;padding:6px 2px 14px;border-bottom:1px solid var(--line)}
25
+ .lg-head h1{margin:0;font:800 clamp(30px,4.4vw,52px)/1 var(--display);letter-spacing:-.025em}
26
+ .lg-head p{margin:8px 0 0;max-width:62ch;color:var(--soft);font-size:16px}
27
+ .lg-here{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:999px;background:var(--panel);border:1px solid var(--line);font:600 13px/1 var(--text);color:var(--soft);white-space:nowrap}
28
+ .lg-here i{width:8px;height:8px;border-radius:50%;background:#3ddc84;box-shadow:0 0 10px #3ddc84}
29
+ .lg-here.off i{background:var(--dim);box-shadow:none}
30
+ .lg-chat{display:flex;flex-direction:column;min-width:0;min-height:calc(100dvh - var(--top-h) - 24px)}
31
+ .lg-feed{list-style:none;margin:0;padding:10px 0 34px;flex:1;display:flex;flex-direction:column;gap:2px}
32
+ .lg-more{align-self:center;margin:6px 0 10px;min-height:40px;padding:0 16px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--soft);font:600 14px/1 var(--text);cursor:pointer}
33
+ .lg-day{align-self:center;margin:14px 0 6px;padding:4px 12px;border-radius:999px;background:var(--panel);font:600 12px/1.4 var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--dim)}
34
+ .lg-empty{margin:auto;padding:36px 10px;text-align:center;color:var(--dim);max-width:44ch}
35
+ .lg-empty b{display:block;margin-bottom:6px;color:var(--fg);font:800 22px/1.2 var(--display)}
36
+ .ln{position:relative;display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:2px 12px;padding:8px 10px;border-radius:14px}
37
+ .ln:hover,.ln:focus-within{background:color-mix(in srgb,var(--fg) 4%,transparent)}
38
+ .ln.me{background:color-mix(in srgb,var(--hot) 7%,transparent)}
39
+ .ln.cont{padding-top:1px}
40
+ .ln.cont .av,.ln.cont .who{display:none}
41
+ .ln.cont .body{grid-column:2}
42
+ .av{grid-row:span 2;width:38px;height:38px;border-radius:12px;display:grid;place-items:center;font:800 16px/1 var(--display);color:#0b0b0f;background:var(--hot)}
43
+ .who{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;min-width:0}
44
+ .who b{font:700 15px/1.3 var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:24ch}
45
+ .who time{font:500 12px/1 var(--mono);color:var(--dim)}
46
+ .tag{padding:2px 7px;border-radius:999px;font:700 10.5px/1.5 var(--mono);letter-spacing:.06em;text-transform:uppercase;background:color-mix(in srgb,var(--fg) 10%,transparent);color:var(--soft)}
47
+ .tag.owner{background:color-mix(in srgb,var(--hot) 26%,transparent);color:var(--fg)}
48
+ .tag.mod{background:color-mix(in srgb,#3ddc84 22%,transparent);color:var(--fg)}
49
+ .tag.hub{background:transparent;border:1px solid var(--line)}
50
+ .body{grid-column:2;min-width:0;font-size:16px;line-height:1.5;overflow-wrap:anywhere;color:var(--fg)}
51
+ .body.react{font-size:15px;color:var(--dim)}
52
+ .body.react span{font-size:22px;vertical-align:-3px;margin-left:4px}
53
+ .body.quick{font-style:italic;color:var(--soft)}
54
+ .ln.studio .body{font-weight:600}
55
+ .ln.studio .av{background:var(--fg);color:var(--bg)}
56
+ .ln-x{grid-row:1;grid-column:3;align-self:start;width:34px;height:34px;border-radius:10px;border:0;background:transparent;color:var(--dim);cursor:pointer;opacity:0;font:700 18px/1 var(--text)}
57
+ .ln:hover .ln-x,.ln:focus-within .ln-x,.ln-x[aria-expanded="true"]{opacity:1}
58
+ @media (hover:none){.ln-x{opacity:.6}}
59
+ .ln-menu{position:absolute;right:8px;top:40px;z-index:6;display:flex;flex-direction:column;min-width:190px;padding:6px;border-radius:14px;background:var(--bg);border:1px solid var(--line);box-shadow:0 18px 50px rgba(0,0,0,.4)}
60
+ .ln-menu button{min-height:42px;padding:0 12px;border:0;border-radius:10px;background:transparent;color:var(--fg);font:600 15px/1 var(--text);text-align:left;cursor:pointer}
61
+ .ln-menu button:hover,.ln-menu button:focus-visible{background:var(--panel)}
62
+ .ln-menu button.bad{color:#ff8a80}
63
+ .ln-menu p{margin:6px 10px;font-size:13px;color:var(--dim)}
64
+ .mcard{display:grid;grid-template-columns:minmax(0,1fr);margin-top:8px;max-width:520px;border-radius:var(--r);overflow:hidden;background:var(--panel);border:1px solid var(--line);text-decoration:none;color:inherit}
65
+ .mcard .pic{aspect-ratio:16/9;background:linear-gradient(135deg,color-mix(in srgb,var(--hot) 40%,var(--bg)),var(--panel));background-size:cover;background-position:center}
66
+ .mcard .pic img{width:100%;height:100%;object-fit:cover}
67
+ .mcard .txt{padding:12px 14px 14px}
68
+ .mcard .kick{margin:0;font:700 11px/1.4 var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--glow-ink)}
69
+ .mcard h3{margin:4px 0 2px;font:800 20px/1.2 var(--display)}
70
+ .mcard p{margin:0;color:var(--soft);font-size:14.5px}
71
+ .mcard .go{display:inline-flex;align-items:center;gap:8px;margin-top:10px;min-height:38px;padding:0 14px;border-radius:999px;background:var(--hot);color:var(--hot-ink);font:700 14px/1 var(--text)}
72
+ .lg-compose{position:sticky;bottom:0;z-index:5;margin:0 -6px;padding:10px 6px calc(env(safe-area-inset-bottom,0px) + 12px);background:var(--bg)}
73
+ .lg-compose::before{content:"";position:absolute;left:0;right:0;top:-22px;height:22px;background:linear-gradient(to top,var(--bg),color-mix(in srgb,var(--bg) 0%,transparent));pointer-events:none}
74
+ .lg-reacts{display:flex;gap:8px;flex-wrap:wrap}
75
+ .lg-reacts button{width:46px;height:46px;border-radius:50%;border:1px solid var(--line);background:var(--panel);font-size:22px;line-height:1;cursor:pointer;transition:transform .12s}
76
+ .lg-reacts button:active,.lg-reacts button.pop{transform:scale(1.18)}
77
+ .lg-lines{display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;margin-top:10px;padding-bottom:2px}
78
+ .lg-lines::-webkit-scrollbar{display:none}
79
+ .lg-lines button{flex:none;min-height:40px;padding:0 14px;border-radius:999px;border:1px solid var(--line);background:transparent;color:var(--soft);font:600 14px/1 var(--text);cursor:pointer;white-space:nowrap}
80
+ .lg-lines button:hover{color:var(--fg);border-color:color-mix(in srgb,var(--fg) 30%,transparent)}
81
+ .lg-type{display:flex;gap:10px;align-items:center;margin-top:10px}
82
+ .lg-type input{flex:1;min-width:0;min-height:50px;padding:0 16px;border-radius:16px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font:500 16px/1 var(--text)}
83
+ .lg-type input:focus{outline:2px solid var(--hot);outline-offset:1px}
84
+ .lg-type button,.lg-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:50px;padding:0 18px;border-radius:16px;border:0;background:var(--hot);color:var(--hot-ink);font:700 15px/1 var(--text);cursor:pointer;text-decoration:none;white-space:nowrap}
85
+ .lg-btn.lg-ghost{background:transparent;color:var(--fg);border:1px solid color-mix(in srgb,var(--fg) 26%,transparent)}
86
+ .lg-btn.lg-sm{min-height:40px;padding:0 14px;border-radius:12px;font-size:14px}
87
+ .lg-sign{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:10px;padding:12px;border-radius:16px;background:var(--panel);border:1px solid var(--line)}
88
+ .lg-sign p{flex:1 1 220px;margin:0;color:var(--soft);font-size:14.5px}
89
+ .lg-note{min-height:1.4em;margin:8px 2px 0;font:600 13.5px/1.4 var(--text);color:var(--dim)}
90
+ .lg-note.bad{color:#ff8a80}
91
+ .lg-note.good{color:#8fe3a8}
92
+ .lg-ban{margin:10px 0 0;padding:12px 14px;border-radius:14px;background:color-mix(in srgb,#ff8a80 14%,var(--panel));font-weight:600}
93
+ .lg-float{position:fixed;inset:0;pointer-events:none;z-index:4}
94
+ .lg-side{display:flex;flex-direction:column;gap:16px;padding-bottom:24px}
95
+ .sc{padding:16px 16px 18px;border-radius:var(--r);background:var(--panel);border:1px solid var(--line)}
96
+ .sc h2{margin:0 0 4px;font:800 18px/1.25 var(--display)}
97
+ .sc .k{margin:0 0 8px;font:700 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--glow-ink)}
98
+ .sc p{margin:6px 0 0;color:var(--soft);font-size:14.5px}
99
+ .night .when{margin-top:6px;font:700 15px/1.4 var(--text)}
100
+ .night .count{display:inline-block;margin-top:10px;padding:6px 12px;border-radius:999px;background:color-mix(in srgb,var(--hot) 20%,transparent);font:700 13px/1.2 var(--mono);letter-spacing:.04em}
101
+ .night .count.on{background:#3ddc84;color:#06210f}
102
+ .night .acts{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
103
+ .night ul{list-style:none;margin:14px 0 0;padding:12px 0 0;border-top:1px solid var(--line)}
104
+ .night li{display:flex;justify-content:space-between;gap:10px;padding:5px 0;font-size:14px;color:var(--soft)}
105
+ .rooms-l{list-style:none;margin:10px 0 0;padding:0;display:flex;flex-direction:column;gap:10px}
106
+ .rooms-l li{display:grid;grid-template-columns:56px minmax(0,1fr);gap:4px 12px;align-items:center}
107
+ .rooms-l .th{grid-row:span 2;width:56px;height:56px;border-radius:12px;background:linear-gradient(135deg,color-mix(in srgb,var(--hot) 45%,var(--bg)),var(--bg));background-size:cover;background-position:center}
108
+ .rooms-l b{font:700 14.5px/1.3 var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
109
+ .rooms-l span{font-size:13px;color:var(--dim)}
110
+ .rooms-l li.empty{display:block;font-size:14px;color:var(--dim)}
111
+ .rooms-l .ra{grid-column:2;display:flex;gap:8px;margin-top:4px}
112
+ .rooms-l .ra a{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:999px;font:700 13px/1 var(--text);text-decoration:none;border:1px solid var(--line);color:var(--fg)}
113
+ .rooms-l .ra a.j{background:var(--hot);color:var(--hot-ink);border-color:transparent}
114
+ .how ul{margin:8px 0 0;padding-left:18px;color:var(--soft);font-size:14px}
115
+ .how li{margin:5px 0}
116
+ .keep{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
117
+ .keep select{min-height:40px;padding:0 10px;border-radius:12px;border:1px solid var(--line);background:var(--bg);color:var(--fg);font:600 14px/1 var(--text)}
118
+ dialog.lg-dlg{width:min(520px,calc(100vw - 24px));padding:0;border:1px solid var(--line);border-radius:22px;background:var(--bg);color:var(--fg);box-shadow:0 30px 90px rgba(0,0,0,.55)}
119
+ dialog.lg-dlg::backdrop{background:rgba(0,0,0,.55);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px)}
120
+ .lg-dlg form{padding:22px}
121
+ .lg-dlg h2{margin:0;font:800 24px/1.2 var(--display)}
122
+ .lg-dlg p{margin:8px 0 0;color:var(--soft);font-size:14.5px}
123
+ .lg-dlg label{display:block;margin-top:16px;font:600 13px/1.4 var(--text);color:var(--dim)}
124
+ .lg-dlg input,.lg-dlg textarea{box-sizing:border-box;width:100%;margin-top:6px;padding:12px 14px;border-radius:14px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font:500 16px/1.4 var(--text)}
125
+ .lg-dlg textarea{min-height:76px;resize:vertical}
126
+ .lg-dlg .row{display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end;margin-top:18px}
127
+ .lg-dlg .reasons{display:flex;flex-direction:column;gap:8px;margin-top:14px}
128
+ .lg-dlg .reasons button{min-height:46px;padding:0 14px;border-radius:12px;border:1px solid var(--line);background:var(--panel);color:var(--fg);font:600 15px/1 var(--text);text-align:left;cursor:pointer}
129
+ @media (min-width:960px){
130
+ .lg{grid-template-columns:minmax(0,1fr) 360px;gap:28px}
131
+ .lg-side{position:sticky;top:calc(var(--top-h) + 14px);align-self:start;max-height:calc(100dvh - var(--top-h) - 28px);overflow:auto;padding-top:6px;scrollbar-width:thin}
132
+ }
133
+ @media (max-width:959px){
134
+ .lg{padding:0 12px;gap:0}
135
+ .lg-tabs{display:flex;position:sticky;top:var(--lg-top,var(--top-h));z-index:6;gap:6px;margin:0 -12px;padding:8px 12px;background:color-mix(in srgb,var(--bg) 88%,transparent);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--line)}
136
+ .lg-tabs button{flex:1;min-height:44px;border-radius:12px;border:1px solid transparent;background:transparent;color:var(--soft);font:700 15px/1 var(--text);cursor:pointer}
137
+ .lg-tabs button[aria-selected="true"]{background:var(--panel);color:var(--fg);border-color:var(--line)}
138
+ .lg-tabs button b{display:inline-block;min-width:20px;margin-left:6px;padding:2px 6px;border-radius:999px;background:var(--hot);color:var(--hot-ink);font:700 11px/1.3 var(--mono)}
139
+ .lg[data-tab="chat"] .lg-side{display:none}
140
+ .lg[data-tab="on"] .lg-chat{display:none}
141
+ .lg-side{padding-top:14px}
142
+ .lg-head{padding-top:12px}
143
+ .lg-head h1{font-size:30px}
144
+ .lg-head p{font-size:15px}
145
+ .lg-chat{min-height:calc(100dvh - var(--top-h) - 62px)}
146
+ .lg-reacts{flex-wrap:nowrap;justify-content:space-between}
147
+ .lg-reacts button{width:44px;height:44px}
148
+ .ln{padding:8px 4px;grid-template-columns:34px minmax(0,1fr) auto;gap:2px 10px}
149
+ .av{width:34px;height:34px;border-radius:10px;font-size:14px}
150
+ }
151
+ @media (prefers-reduced-motion:reduce){.lg-reacts button{transition:none}}
152
+ `;
153
+
154
+ /** The page. `who` is the signed-in account (or nobody), `rules` the Lounge's public chat rules, `nights` its play nights. */
155
+ export function loungePage(cat, { origin = '', who = {}, rules = {}, nights = [] } = {}) {
156
+ const L = cat.studio?.lounge ?? {};
157
+ const studio = cat.studio?.name ?? 'Studio';
158
+ const boot = {
159
+ v: 1, studio, name: L.name ?? 'The Lounge', socket: '/lounge/__watch', account: '/account/',
160
+ me: { signedIn: Boolean(who.acct), name: who.name ?? null, owner: Boolean(who.owner), mod: Boolean(who.mod), player: who.player ?? null },
161
+ rules, nights, reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })), kids: Boolean(L.kids), featured: L.featured ?? 'directory',
162
+ };
163
+ const next = nights[0] ?? null;
164
+ const keeps = rules.history > 0 ? `kept for ${rules.history} ${rules.history === 1 ? 'day' : 'days'}` : 'gone after a few minutes';
165
+ const main = `
166
+ <div class="lg" data-tab="chat" data-lounge>
167
+ <div class="lg-tabs" role="tablist" aria-label="${esc(boot.name)}">
168
+ <button type="button" role="tab" aria-selected="true" data-tab-to="chat">Chat</button>
169
+ <button type="button" role="tab" aria-selected="false" data-tab-to="on">What's on${nights.length ? `<b>${esc(nights.length)}</b>` : ''}</button>
170
+ </div>
171
+ <section class="lg-chat" aria-labelledby="lg-title">
172
+ <header class="lg-head">
173
+ <div><h1 id="lg-title">${esc(boot.name)}</h1><p>${esc(L.blurb ?? '')}</p></div>
174
+ <span class="lg-here off" data-here><i></i><span>Joining…</span></span>
175
+ </header>
176
+ <ol class="lg-feed" data-feed aria-live="polite" aria-label="Messages"><li class="lg-empty"><b>Getting the Lounge…</b>One moment.</li></ol>
177
+ <div class="lg-compose" data-compose>
178
+ <div class="lg-reacts" data-reacts aria-label="Reactions"></div>
179
+ <div class="lg-lines" data-lines aria-label="Quick lines"></div>
180
+ <div data-typing></div>
181
+ <p class="lg-note" data-note role="status"></p>
182
+ </div>
183
+ </section>
184
+ <aside class="lg-side" aria-label="What's on">
185
+ <section class="sc night" data-night>
186
+ <p class="k">${icon('calendar')} Play night</p>
187
+ ${next ? `<h2>${esc(next.title)}</h2><p class="when" data-when="${esc(next.at)}">${esc(new Date(next.at).toUTCString().replace(/:00 GMT$/, ' UTC'))}</p>` : `<h2>No play night set yet</h2><p>When the studio sets one, it shows here with your own time.</p>`}
188
+ </section>
189
+ <section class="sc" data-live><p class="k">${icon('eye')} Live now</p><h2>Rooms you can join</h2><ul class="rooms-l" data-rooms><li><span>Reading the rooms…</span></li></ul></section>
190
+ <section class="sc" data-show-card><p class="k">${icon('spark')} Show what you made</p><h2>Made a game with Homie?</h2><p>Post its link: it shows as a card with its picture, and people can play it from here.</p><div class="keep"><button type="button" class="lg-btn lg-sm" data-show>${icon('plus')} Show it</button></div></section>
191
+ <section class="sc how"><p class="k">${icon('lounge')} How it works</p>
192
+ <ul>
193
+ <li>Reactions and quick lines are for everyone.${rules.mode === 'text' ? ` Typing is for ${rules.who === 'anyone' ? 'anyone' : 'people signed in with a passkey (no password, no email)'}.` : ''}</li>
194
+ <li>Every typed message is checked before anyone sees it.</li>
195
+ <li>What's said is <b data-keeps>${esc(keeps)}</b>. Take your own messages down any time; report anything that isn't okay.</li>
196
+ <li>Nobody here will ever ask your age, where you live or for photos. If they do, report it.</li>
197
+ </ul>
198
+ <div class="keep" data-keep hidden></div>
199
+ </section>
200
+ </aside>
201
+ </div>
202
+ <canvas class="lg-float" data-float aria-hidden="true"></canvas>
203
+ <dialog class="lg-dlg" data-show-dlg><form method="dialog" data-show-form>
204
+ <h2>Show what you made</h2><p>Paste the link to your game (its page or its Play). It must be a Homie studio's game.</p>
205
+ <label for="lg-url">Link</label><input id="lg-url" name="url" type="url" inputmode="url" required placeholder="https://your-studio.example/your-game/" autocomplete="off">
206
+ <label for="lg-say">Say something about it (optional)</label><textarea id="lg-say" name="note" maxlength="200" placeholder="What it is, what's new, what you want people to try"></textarea>
207
+ <p class="lg-note" data-show-note role="status"></p>
208
+ <div class="row"><button type="button" class="lg-btn lg-ghost lg-sm" data-close>Cancel</button><button type="submit" class="lg-btn lg-sm" value="post">Post it</button></div>
209
+ </form></dialog>
210
+ <dialog class="lg-dlg" data-report-dlg><form method="dialog"><h2>Report this message</h2><p>The studio sees this one message and the reason. They never see who reported it.</p><div class="reasons" data-reasons></div><div class="row"><button type="button" class="lg-btn lg-ghost lg-sm" data-close>Cancel</button></div></form></dialog>
211
+ <script type="application/json" id="lounge-boot">${jsonScript(boot)}</script>
212
+ <script src="/_homie/lounge.js?v=${STUDIO_VERSION_TAG}" defer></script>`;
213
+ return layout(cat, {
214
+ title: `${boot.name} · ${studio}`, description: L.blurb ?? '', origin, path: '/lounge/', active: 'lounge', page: 'lounge',
215
+ head: `<style>${CSS}</style>`, main, ld: [breadcrumbs(origin, [['Home', '/'], [boot.name, '/lounge/']])],
216
+ extraHeaders: { 'cache-control': 'private, no-store' },
217
+ });
218
+ }
219
+
220
+ /** The page's script: the passkey half of the account page, then the Lounge. Plain ES2017, no framework. */
221
+ export const LOUNGE_JS = `${PASSKEY_JS}\n${String.raw`(function () {
222
+ 'use strict';
223
+ var bootEl = document.getElementById('lounge-boot');
224
+ if (!bootEl) return;
225
+ var B = JSON.parse(bootEl.textContent || '{}');
226
+ var root = document.querySelector('[data-lounge]');
227
+ var feed = document.querySelector('[data-feed]');
228
+ var note = document.querySelector('[data-note]');
229
+ var hereEl = document.querySelector('[data-here]');
230
+ var reactsEl = document.querySelector('[data-reacts]');
231
+ var linesEl = document.querySelector('[data-lines]');
232
+ var typingEl = document.querySelector('[data-typing]');
233
+ var canvas = document.querySelector('[data-float]');
234
+ var me = B.me || {};
235
+ var rules = B.rules || {};
236
+ var lines = [];
237
+ var mine = {};
238
+ // What the box is drawn from, as one string: the page already drew the rules it was served with, so the room's
239
+ // first word (a second or two later on a slow connection) redraws nothing unless the rules changed in between.
240
+ function keyOf(r) { return JSON.stringify([r.mode, r.who, r.react, r.slow, r.max, r.links, r.swears, r.ai, r.history, r.kids, r.watchers, r.hub, r.bubbles, r.emoji, r.lines]); }
241
+ var ws = null, stopped = false, retry = 0, seq = 0, held = null, more = rules.history > 0, rulesKey = keyOf(rules);
242
+ var reduced = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
243
+
244
+ 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; }
245
+ function add(p) { for (var i = 1; i < arguments.length; i++) if (arguments[i]) p.appendChild(arguments[i]); return p; }
246
+ function hue(s) { var h = 2166136261; s = String(s || ''); for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h % 360; }
247
+ function rnd() { var a = new Uint8Array(18); crypto.getRandomValues(a); return btoa(String.fromCharCode.apply(null, a)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
248
+ var bkey = null;
249
+ try { bkey = localStorage.getItem('homie-b'); if (!/^[A-Za-z0-9_-]{16,43}$/.test(bkey || '')) { bkey = rnd(); localStorage.setItem('homie-b', bkey); } } catch (e) { bkey = rnd(); }
250
+ function say(text, kind) { note.textContent = text || ''; note.className = 'lg-note' + (kind ? ' ' + kind : ''); if (text) { clearTimeout(say.t); say.t = setTimeout(function () { if (note.textContent === text) note.textContent = ''; }, 6000); } }
251
+ function post(path, body) {
252
+ return fetch(path, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
253
+ .then(function (r) { return r.json().catch(function () { return { ok: false, message: 'The Lounge answered ' + r.status + '.' }; }); });
254
+ }
255
+ function https(u) { return typeof u === 'string' && /^https?:\/\/[^\s]{4,400}$/.test(u) && (u.indexOf('https://') === 0 || /^http:\/\/(localhost|127\.0\.0\.1)[:/]/.test(u)) ? u : null; }
256
+
257
+ /* ---------------------------------------------------------------- tabs (a phone) */
258
+ Array.prototype.forEach.call(document.querySelectorAll('[data-tab-to]'), function (b) {
259
+ b.addEventListener('click', function () {
260
+ root.setAttribute('data-tab', b.getAttribute('data-tab-to'));
261
+ Array.prototype.forEach.call(document.querySelectorAll('[data-tab-to]'), function (o) { o.setAttribute('aria-selected', o === b ? 'true' : 'false'); });
262
+ window.scrollTo(0, b.getAttribute('data-tab-to') === 'chat' ? document.body.scrollHeight : 0);
263
+ });
264
+ });
265
+
266
+ /* ---------------------------------------------------------------- the float (the room's own: six a second, eighteen at once) */
267
+ var ctx = canvas && canvas.getContext ? canvas.getContext('2d') : null;
268
+ var parts = [], spent = [], raf = 0;
269
+ function paint(t) {
270
+ raf = 0;
271
+ if (!ctx) return;
272
+ var dpr = Math.min(2, window.devicePixelRatio || 1);
273
+ var w = innerWidth, h = innerHeight;
274
+ 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); }
275
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
276
+ ctx.clearRect(0, 0, w, h);
277
+ var box = feed.getBoundingClientRect();
278
+ var left = Math.max(0, box.left), right = Math.min(w, box.right);
279
+ var size = Math.max(26, Math.min(44, h * 0.05));
280
+ ctx.font = size + 'px "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif';
281
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
282
+ parts = parts.filter(function (p) { return t - p.at < 2400; });
283
+ for (var i = 0; i < parts.length; i++) {
284
+ var p = parts[i], age = (t - p.at) / 2400;
285
+ if (age < 0) continue;
286
+ ctx.globalAlpha = Math.min(1, age * 8) * (1 - age);
287
+ ctx.fillText(p.glyph, left + (right - left) * p.x + Math.sin(age * 5) * p.sway * 26, h * (0.86 - age * 0.55));
288
+ }
289
+ ctx.globalAlpha = 1;
290
+ if (parts.length) raf = requestAnimationFrame(paint);
291
+ }
292
+ function burst(glyph) {
293
+ if (!glyph || reduced || !ctx) return;
294
+ var t = performance.now();
295
+ spent = spent.filter(function (s) { return s > t - 1000; });
296
+ if (spent.length >= 6 || parts.length >= 18) return;
297
+ spent.push(t);
298
+ parts.push({ glyph: glyph, at: t, x: 0.12 + Math.random() * 0.76, sway: Math.random() * 2 - 1 });
299
+ if (!raf) raf = requestAnimationFrame(paint);
300
+ }
301
+
302
+ /* ---------------------------------------------------------------- the feed */
303
+ var timeFmt = new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' });
304
+ var dayFmt = new Intl.DateTimeFormat(undefined, { weekday: 'long', day: 'numeric', month: 'long' });
305
+ function dayOf(at) { var d = new Date(at); return d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate(); }
306
+ function dayWord(at) {
307
+ var now = new Date(); var d = new Date(at);
308
+ if (dayOf(at) === dayOf(now.getTime())) return 'Today';
309
+ if (dayOf(at) === dayOf(now.getTime() - 86400000)) return 'Yesterday';
310
+ return dayFmt.format(d);
311
+ }
312
+ function isMine(m) { return Boolean(m.mine || (m.n && mine[m.n])); }
313
+ function keeper() { return Boolean(me.owner || me.mod); }
314
+ var open = null;
315
+ function closeMenu() { if (open) { open.menu.remove(); open.btn.setAttribute('aria-expanded', 'false'); open = null; } }
316
+ document.addEventListener('click', function (e) { if (open && !open.menu.contains(e.target) && e.target !== open.btn) closeMenu(); });
317
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
318
+ function menuFor(m, li, btn) {
319
+ if (open && open.btn === btn) { closeMenu(); return; }
320
+ closeMenu();
321
+ var menu = el('div', 'ln-menu'); menu.setAttribute('role', 'menu');
322
+ var item = function (label, cls, fn) { var b = el('button', cls || '', label); b.type = 'button'; b.setAttribute('role', 'menuitem'); b.onclick = function () { closeMenu(); fn(); }; menu.appendChild(b); };
323
+ var own = isMine(m);
324
+ if (own) item('Delete my message', 'bad', function () { if (me.signedIn) post('/lounge/api/delete', { id: m.id }).then(function (r) { if (!r || !r.ok) say((r && r.message) || 'That did not work.', 'bad'); }); else send({ t: 'unsay', id: m.id }); });
325
+ if (!own && m.by !== 'studio') item('Report', '', function () { report(m); });
326
+ if (keeper() && m.by !== 'studio' && !own) {
327
+ item('Remove from the Lounge', '', function () { mod({ op: 'remove', line: m.id }); });
328
+ item('Mute for 10 min', '', function () { mod({ op: 'mute', line: m.id, minutes: 10 }); });
329
+ item(me.owner ? 'Kick for a day' : 'Kick for an hour', 'bad', function () { if (confirm('Kick ' + m.name + ' out of the Lounge for ' + (me.owner ? 'a day' : 'an hour') + '? Their messages come down too.')) mod({ op: 'kick', line: m.id, minutes: me.owner ? 1440 : 60 }); });
330
+ } else if (keeper() && m.by === 'studio') item('Remove from the Lounge', '', function () { mod({ op: 'remove', line: m.id }); });
331
+ if (!menu.children.length) menu.appendChild(el('p', '', 'Nothing to do with this one.'));
332
+ li.appendChild(menu);
333
+ btn.setAttribute('aria-expanded', 'true');
334
+ open = { menu: menu, btn: btn };
335
+ var first = menu.querySelector('button'); if (first) first.focus();
336
+ }
337
+ function cardEl(c) {
338
+ var url = https(c.url);
339
+ var a = el(url ? 'a' : 'div', 'mcard');
340
+ if (url) { a.href = url; a.rel = 'nofollow ugc noopener'; a.target = '_blank'; }
341
+ var pic = el('div', 'pic');
342
+ var img = https(c.image);
343
+ if (img) { var i = el('img'); i.src = img; i.alt = ''; i.decoding = 'async'; i.referrerPolicy = 'no-referrer'; i.onerror = function () { i.remove(); }; pic.appendChild(i); }
344
+ var txt = el('div', 'txt');
345
+ add(txt, el('p', 'kick', c.studio ? 'Made by ' + c.studio : 'Made with Homie'), el('h3', '', c.title || 'A game'), c.pitch ? el('p', '', c.pitch) : null, url ? el('span', 'go', 'Play it') : null);
346
+ return add(a, pic, txt);
347
+ }
348
+ function lineEl(m, prev) {
349
+ var li = el('li', 'ln' + (m.by === 'studio' ? ' studio' : '') + (isMine(m) ? ' me' : ''));
350
+ li.setAttribute('data-id', m.id);
351
+ var cont = prev && prev.t === 'line' && m.t === 'line' && prev.name === m.name && prev.by === m.by && !m.card && !prev.card && m.at - prev.at < 5 * 60000 && dayOf(prev.at) === dayOf(m.at);
352
+ if (cont) li.classList.add('cont');
353
+ var who = m.by === 'studio' ? B.studio : m.name || 'Someone';
354
+ var av = el('span', 'av', String(who).trim().charAt(0).toUpperCase() || '·');
355
+ if (m.by !== 'studio') av.style.background = 'oklch(.78 .13 ' + hue(who) + ')';
356
+ av.setAttribute('aria-hidden', 'true');
357
+ var head = el('div', 'who');
358
+ var name = el('b', '', who);
359
+ if (m.by !== 'studio') name.style.color = 'oklch(.84 .12 ' + hue(who) + ')';
360
+ var time = el('time', '', timeFmt.format(new Date(m.at))); time.dateTime = new Date(m.at).toISOString(); time.title = new Date(m.at).toLocaleString();
361
+ add(head, name, m.owner && m.by !== 'studio' ? el('span', 'tag owner', 'Owner') : null, m.mod ? el('span', 'tag mod', 'Mod') : null, m.by === 'hub' ? el('span', 'tag hub', 'on homie.rocks') : null, isMine(m) ? el('span', 'tag', 'You') : null, time);
362
+ var body;
363
+ if (m.t === 'react') { body = el('div', 'body react', 'reacted'); body.appendChild(el('span', '', m.glyph || '')); }
364
+ else if (m.card) { body = el('div', 'body'); if (m.text && !/^Made: /.test(m.text)) body.appendChild(el('div', '', m.text)); body.appendChild(cardEl(m.card)); }
365
+ else body = el('div', 'body' + (m.say ? ' quick' : ''), m.text || '');
366
+ var x = el('button', 'ln-x', '⋯'); x.type = 'button'; x.setAttribute('aria-label', 'More for this message'); x.setAttribute('aria-haspopup', 'menu'); x.setAttribute('aria-expanded', 'false');
367
+ x.onclick = function (e) { e.stopPropagation(); menuFor(m, li, x); };
368
+ add(li, av, head, body, m.t === 'react' ? null : x);
369
+ return li;
370
+ }
371
+ function render(keepBottom) {
372
+ var stick = keepBottom || (window.innerHeight + window.scrollY >= document.body.scrollHeight - 140);
373
+ closeMenu();
374
+ feed.textContent = '';
375
+ if (more) { var b = el('button', 'lg-more', 'Earlier messages'); b.type = 'button'; b.onclick = earlier; feed.appendChild(b); }
376
+ if (!lines.length) {
377
+ var e = el('li', 'lg-empty'); add(e, el('b', '', 'Quiet in here.'), document.createTextNode(rules.mode === 'off' ? 'The Lounge is closed for now.' : 'Say hi, or send a reaction: it floats up everyone\'s screen.'));
378
+ feed.appendChild(e);
379
+ }
380
+ var prev = null, lastDay = null;
381
+ for (var i = 0; i < lines.length; i++) {
382
+ var m = lines[i];
383
+ var d = dayOf(m.at);
384
+ if (d !== lastDay) { feed.appendChild(el('li', 'lg-day', dayWord(m.at))); lastDay = d; prev = null; }
385
+ feed.appendChild(lineEl(m, prev));
386
+ prev = m;
387
+ }
388
+ if (stick) toBottom();
389
+ }
390
+ function toBottom() {
391
+ var go = function () { window.scrollTo(0, document.documentElement.scrollHeight); };
392
+ requestAnimationFrame(go); setTimeout(go, 160);
393
+ }
394
+ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 160; }
395
+ function earlier() {
396
+ var before = lines.length ? lines[0].at : Date.now();
397
+ fetch('/lounge/api/history?before=' + encodeURIComponent(before), { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (j) {
398
+ var got = (j && j.lines) || [];
399
+ var have = {}; lines.forEach(function (l) { have[l.id] = 1; });
400
+ lines = got.filter(function (l) { return !have[l.id]; }).concat(lines);
401
+ more = Boolean(j && j.more);
402
+ var h = document.body.scrollHeight;
403
+ render(false);
404
+ window.scrollTo(0, window.scrollY + (document.body.scrollHeight - h));
405
+ }).catch(function () { say('The earlier messages did not load.', 'bad'); });
406
+ }
407
+
408
+ /* ---------------------------------------------------------------- what may be sent, and how */
409
+ function allowed(kind) {
410
+ var rank = { off: 0, emoji: 1, lines: 2, text: 3 }[rules.mode] || 0;
411
+ var need = kind === 'text' ? rules.who : rules.react;
412
+ var mode = kind === 'react' ? rank >= 1 : kind === 'line' ? rank >= 2 : rank >= 3;
413
+ if (!mode) return false;
414
+ if (me.owner) return true;
415
+ if (need === 'signed-in' || need === 'members') return Boolean(me.signedIn);
416
+ return true;
417
+ }
418
+ function paintCompose() {
419
+ reactsEl.textContent = ''; linesEl.textContent = ''; typingEl.textContent = '';
420
+ if (held) { typingEl.appendChild(el('p', 'lg-ban', held)); return; }
421
+ (rules.emoji || B.reactions || []).forEach(function (r) {
422
+ var b = el('button', '', r.e); b.type = 'button'; b.setAttribute('aria-label', 'React ' + r.k); b.disabled = !allowed('react');
423
+ b.onclick = function () { if (send({ t: 'react', kind: r.k })) { burst(r.e); b.classList.add('pop'); setTimeout(function () { b.classList.remove('pop'); }, 160); } };
424
+ reactsEl.appendChild(b);
425
+ });
426
+ reactsEl.hidden = !allowed('react');
427
+ if (allowed('line')) (rules.lines || []).forEach(function (l) { var b = el('button', '', l.text); b.type = 'button'; b.onclick = function () { send({ t: 'say', say: l.id }); }; linesEl.appendChild(b); });
428
+ linesEl.hidden = !linesEl.children.length;
429
+ var rank = { off: 0, emoji: 1, lines: 2, text: 3 }[rules.mode] || 0;
430
+ if (rank >= 3 && allowed('text')) {
431
+ var form = el('form', 'lg-type'); form.setAttribute('autocomplete', 'off');
432
+ var input = el('input'); input.type = 'text'; input.maxLength = rules.max || 280; input.placeholder = 'Say something to the Lounge'; input.setAttribute('aria-label', 'Message'); input.enterKeyHint = 'send';
433
+ var go = el('button', '', 'Send'); go.type = 'submit';
434
+ form.onsubmit = function (e) { e.preventDefault(); var t = input.value.trim(); if (!t) return; if (send({ t: 'say', text: t })) { input.value = ''; } };
435
+ add(typingEl, add(form, input, go));
436
+ } else if (rank >= 3 && !me.signedIn) {
437
+ var box = el('div', 'lg-sign');
438
+ var p = el('p', '', 'Sign in with a passkey to type and to show what you made. No password, no email: your phone or computer unlocks it.');
439
+ var si = el('button', 'lg-btn lg-sm', 'Sign in'); si.type = 'button';
440
+ var su = el('button', 'lg-btn lg-ghost lg-sm', 'Make a passkey'); su.type = 'button';
441
+ si.onclick = function () { passkey('in'); }; su.onclick = function () { passkey('up'); };
442
+ add(typingEl, add(box, p, si, su));
443
+ } else if (rules.mode === 'off') typingEl.appendChild(el('p', 'lg-sign', 'The Lounge is closed for now.'));
444
+ }
445
+ function passkey(kind) {
446
+ if (!window.homiePasskey || !homiePasskey.supported()) { location.href = B.account + '?next=/lounge/'; return; }
447
+ say(kind === 'in' ? 'Waiting for your passkey…' : 'Making your passkey…');
448
+ var p = kind === 'in' ? homiePasskey.signIn() : homiePasskey.signUp(prompt('The name people see in the Lounge (you can change it later):', '') || undefined);
449
+ p.then(function () { say('Signed in.', 'good'); location.reload(); }, function (e) { say(homiePasskey.said ? homiePasskey.said(e) : (e && e.message) || 'That did not work.', 'bad'); });
450
+ }
451
+ var waiting = null;
452
+ function send(m) {
453
+ seq += 1;
454
+ var n = 'l' + seq + Math.random().toString(36).slice(2, 6);
455
+ var frame = JSON.stringify(Object.assign({}, m, { n: n }));
456
+ mine[n] = 1;
457
+ // Said a moment before the Lounge is connected (a page just opened, a phone waking up): it goes as soon as it is.
458
+ if (!ws || ws.readyState !== 1) { if (stopped) return false; waiting = frame; say('Connecting to the Lounge…'); return true; }
459
+ try { ws.send(frame); return true; } catch (e) { return false; }
460
+ }
461
+ function mod(body) {
462
+ post('/lounge/api/mod', body).then(function (r) { say((r && r.message) || (r && r.ok ? 'Done.' : 'That did not work.'), r && r.ok ? 'good' : 'bad'); });
463
+ }
464
+
465
+ /* ---------------------------------------------------------------- report */
466
+ var repDlg = document.querySelector('[data-report-dlg]');
467
+ function report(m) {
468
+ var box = repDlg.querySelector('[data-reasons]'); box.textContent = '';
469
+ (B.reasons || []).forEach(function (r) {
470
+ var b = el('button', '', r.text); b.type = 'button';
471
+ b.onclick = function () { repDlg.close(); post('/lounge/api/report', { id: m.id, reason: r.k, b: bkey }).then(function (j) { say((j && j.message) || 'Sent.', j && j.ok ? 'good' : 'bad'); }); };
472
+ box.appendChild(b);
473
+ });
474
+ if (repDlg.showModal) repDlg.showModal(); else repDlg.setAttribute('open', '');
475
+ }
476
+ Array.prototype.forEach.call(document.querySelectorAll('[data-close]'), function (b) { b.onclick = function () { var d = b.closest('dialog'); if (d) d.close(); }; });
477
+
478
+ /* ---------------------------------------------------------------- show what you made */
479
+ var showDlg = document.querySelector('[data-show-dlg]');
480
+ var showNote = document.querySelector('[data-show-note]');
481
+ var showBtn = document.querySelector('[data-show]');
482
+ if (showBtn) showBtn.onclick = function () {
483
+ if (!me.signedIn) { root.setAttribute('data-tab', 'chat'); say('Sign in with a passkey first: then show what you made.'); paintCompose(); window.scrollTo(0, document.body.scrollHeight); return; }
484
+ showNote.textContent = ''; if (showDlg.showModal) showDlg.showModal(); else showDlg.setAttribute('open', '');
485
+ setTimeout(function () { var u = showDlg.querySelector('input'); if (u) u.focus(); }, 0);
486
+ };
487
+ var showForm = document.querySelector('[data-show-form]');
488
+ if (showForm) showForm.addEventListener('submit', function (e) {
489
+ e.preventDefault();
490
+ var url = showForm.elements.url.value.trim(), text = showForm.elements.note.value.trim();
491
+ showNote.className = 'lg-note'; showNote.textContent = 'Reading your game…';
492
+ post('/lounge/api/show', { url: url, note: text, b: bkey }).then(function (r) {
493
+ if (r && r.ok) { showDlg.close(); showForm.reset(); root.setAttribute('data-tab', 'chat'); say('Your card is in the Lounge.', 'good'); }
494
+ else { showNote.className = 'lg-note bad'; showNote.textContent = (r && r.message) || 'That did not post.'; }
495
+ });
496
+ });
497
+
498
+ /* ---------------------------------------------------------------- the socket */
499
+ function onFrame(m) {
500
+ if (m.t === 'net') {
501
+ var pol = m.policy && m.policy.chat;
502
+ // Only a real change of rules redraws the box (the room says its facts whenever somebody comes or goes), and
503
+ // what a person was typing stays, with the cursor in it.
504
+ var key = pol ? keyOf(pol) : null;
505
+ if (pol && key !== rulesKey) {
506
+ rulesKey = key; rules = pol; more = more || rules.history > 0;
507
+ var k = document.querySelector('[data-keeps]'); if (k) k.textContent = rules.history > 0 ? 'kept for ' + rules.history + (rules.history === 1 ? ' day' : ' days') : 'gone after a few minutes';
508
+ var was = typingEl.querySelector('input'); var draft = was ? was.value : ''; var focused = was && document.activeElement === was;
509
+ paintCompose(); paintKeep();
510
+ var now2 = typingEl.querySelector('input'); if (now2 && draft) now2.value = draft; if (now2 && focused) now2.focus();
511
+ }
512
+ var c = m.counts || {}; here(c.shells || ((c.watchers || 0) + (c.players || 0)));
513
+ return;
514
+ }
515
+ if (m.t === 'lines' && Array.isArray(m.lines)) {
516
+ var have = {}; lines.forEach(function (l) { have[l.id] = l; });
517
+ m.lines.forEach(function (l) { if (have[l.id]) return; lines.push(l); });
518
+ lines.sort(function (a, b) { return a.at - b.at; });
519
+ more = rules.history > 0 && lines.length >= 30;
520
+ render(true); return;
521
+ }
522
+ if (m.t === 'line' || m.t === 'react') {
523
+ var own = Boolean(m.n && mine[m.n]);
524
+ if (own) m.mine = true;
525
+ if (m.t === 'react' && !own) burst(m.glyph);
526
+ if (lines.some(function (l) { return l.id === m.id; })) return;
527
+ lines.push(m);
528
+ if (lines.length > 400) lines = lines.slice(-400);
529
+ render(own);
530
+ return;
531
+ }
532
+ if (m.t === 'unline' && Array.isArray(m.ids)) { var gone = {}; m.ids.forEach(function (i) { gone[i] = 1; }); lines = lines.filter(function (l) { return !gone[l.id]; }); render(false); return; }
533
+ if (m.t === 'held' || m.t === 'slow') { say(m.message || 'Not sent.', 'bad'); return; }
534
+ if (m.t === 'muted') { say('The Lounge\'s keepers muted you for a while: your messages reach nobody for now.', 'bad'); return; }
535
+ if (m.t === 'kicked') { held = m.message || 'The Lounge\'s keepers asked you to take a break.'; paintCompose(); stop(); return; }
536
+ if (m.t === 'closed') { held = m.message || 'The Lounge is closed for now.'; paintCompose(); stop(); }
537
+ }
538
+ function connect() {
539
+ if (stopped) return;
540
+ var url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + B.socket + '?b=' + encodeURIComponent(bkey);
541
+ try { ws = new WebSocket(url); } catch (e) { return; }
542
+ ws.addEventListener('message', function (ev) { try { onFrame(JSON.parse(String(ev.data))); } catch (e) { /* not a frame */ } });
543
+ ws.addEventListener('open', function () { retry = 0; say(''); if (waiting) { try { ws.send(waiting); } catch (e) { /* next time */ } waiting = null; } });
544
+ ws.addEventListener('close', function () { if (!stopped) { retry += 1; hereEl.classList.add('off'); hereEl.lastChild.textContent = 'Reconnecting…'; setTimeout(connect, Math.min(15000, 1000 * Math.pow(2, Math.min(retry, 4)))); } });
545
+ }
546
+ function stop() { stopped = true; try { if (ws) ws.close(); } catch (e) { /* gone */ } }
547
+ addEventListener('pagehide', function () { stop(); });
548
+ addEventListener('pageshow', function (e) { if (e.persisted && stopped && !held) { stopped = false; connect(); } });
549
+
550
+ /* ---------------------------------------------------------------- what's on: the play night, live rooms, the keepers' pace */
551
+ var whenFmt = new Intl.DateTimeFormat(undefined, { weekday: 'long', day: 'numeric', month: 'long', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
552
+ function left(ms) { var m = Math.round(ms / 60000); if (m < 60) return m + ' min'; var h = Math.floor(m / 60); if (h < 48) return h + ' h ' + (m % 60) + ' min'; return Math.floor(h / 24) + ' days'; }
553
+ function ics(n) {
554
+ var stamp = function (ms) { return new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); };
555
+ var start = Date.parse(n.at), end = start + n.minutes * 60000;
556
+ var esc = function (s) { return String(s || '').replace(/[\\;,]/g, function (c) { return '\\' + c; }).replace(/\n/g, ' '); };
557
+ var text = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Homie//Lounge//EN', 'BEGIN:VEVENT', 'UID:' + n.id + '@' + location.host, 'DTSTAMP:' + stamp(Date.now()), 'DTSTART:' + stamp(start), 'DTEND:' + stamp(end),
558
+ 'SUMMARY:' + esc(n.title), 'DESCRIPTION:' + esc((n.note ? n.note + ' ' : '') + 'In ' + B.name + ' at ' + location.origin + '/lounge/'), 'URL:' + location.origin + '/lounge/', 'END:VEVENT', 'END:VCALENDAR'].join('\r\n');
559
+ var a = el('a'); a.href = URL.createObjectURL(new Blob([text], { type: 'text/calendar' })); a.download = (n.title || 'play-night').replace(/[^A-Za-z0-9]+/g, '-').toLowerCase() + '.ics';
560
+ document.body.appendChild(a); a.click(); setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 500);
561
+ }
562
+ var nights = B.nights || [];
563
+ function paintNight() {
564
+ var box = document.querySelector('[data-night]');
565
+ if (!box) return;
566
+ var k = box.querySelector('.k') || el('p', 'k', 'Play night');
567
+ box.textContent = ''; box.appendChild(k);
568
+ if (!nights.length) { add(box, el('h2', '', 'No play night set yet'), el('p', '', 'When the studio sets one, it shows here in your own time.')); return; }
569
+ var n = nights[0], start = Date.parse(n.at), end = start + n.minutes * 60000, now = Date.now();
570
+ add(box, el('h2', '', n.title), el('p', 'when', whenFmt.format(new Date(start))));
571
+ if (n.note) box.appendChild(el('p', '', n.note));
572
+ var c = el('span', 'count' + (now >= start && now < end ? ' on' : ''), now >= start && now < end ? 'On now' : 'Starts in ' + left(start - now));
573
+ box.appendChild(c);
574
+ var acts = el('div', 'acts');
575
+ var cal = el('button', 'lg-btn lg-ghost lg-sm', 'Add to calendar'); cal.type = 'button'; cal.onclick = function () { ics(n); };
576
+ if (n.game && https(n.game.play)) { var g = el('a', 'lg-btn lg-sm', n.game.name ? 'Play ' + n.game.name : 'Open the game'); g.href = n.game.play; g.rel = 'noopener'; acts.appendChild(g); }
577
+ acts.appendChild(cal);
578
+ box.appendChild(acts);
579
+ if (nights.length > 1) {
580
+ var ul = el('ul');
581
+ nights.slice(1, 5).forEach(function (o) { add(ul, add(el('li'), el('span', '', o.title), el('span', '', whenFmt.format(new Date(Date.parse(o.at)))))); });
582
+ box.appendChild(ul);
583
+ }
584
+ }
585
+ function paintRooms(rooms) {
586
+ var ul = document.querySelector('[data-rooms]');
587
+ if (!ul) return;
588
+ ul.textContent = '';
589
+ if (!rooms.length) { ul.appendChild(el('li', 'empty', 'Nobody is playing right now. Start a room from any game, and say so here.')); return; }
590
+ rooms.forEach(function (r) {
591
+ var li = el('li');
592
+ var th = el('span', 'th'); if (https(r.picture)) th.style.backgroundImage = 'url("' + r.picture.replace(/"/g, '') + '")';
593
+ var acts = el('div', 'ra');
594
+ if (https(r.watch)) { var w = el('a', '', 'Watch'); w.href = r.watch; w.rel = 'nofollow ugc noopener'; acts.appendChild(w); }
595
+ if (https(r.join)) { var j = el('a', 'j', 'Join'); j.href = r.join; j.rel = 'nofollow ugc noopener'; acts.appendChild(j); }
596
+ add(li, th, el('b', '', r.title), el('span', '', (r.studio ? r.studio + ' · ' : '') + r.players + (r.players === 1 ? ' playing' : ' playing') + (r.max ? ' of ' + r.max : '')), acts);
597
+ ul.appendChild(li);
598
+ });
599
+ }
600
+ function paintKeep() {
601
+ var box = document.querySelector('[data-keep]');
602
+ if (!box || !keeper()) return;
603
+ box.hidden = false; box.textContent = '';
604
+ var sel = el('select'); sel.setAttribute('aria-label', 'Slow mode');
605
+ [[0, 'Slow mode off'], [3, 'One message every 3 s'], [10, 'Every 10 s'], [30, 'Every 30 s'], [120, 'Every 2 min']].forEach(function (o) { var op = el('option', '', o[1]); op.value = String(o[0]); if (Number(rules.slow) === o[0]) op.selected = true; sel.appendChild(op); });
606
+ sel.onchange = function () { mod({ op: 'slow', seconds: Number(sel.value) }); };
607
+ add(box, el('span', 'tag ' + (me.owner ? 'owner' : 'mod'), me.owner ? 'You keep this Lounge' : 'You\'re a moderator'), sel);
608
+ }
609
+ function here(n) { hereEl.classList.toggle('off', !n); hereEl.lastChild.textContent = n > 1 ? n + ' here now' : n === 1 ? 'Just you here' : 'Nobody else here'; }
610
+ function now() {
611
+ fetch('/lounge/api/now', { credentials: 'same-origin', cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (j) {
612
+ if (!j || !j.ok) return;
613
+ nights = j.nights || nights; paintNight(); paintRooms(j.rooms || []);
614
+ if (j.lounge && ws && ws.readyState === 1) here(j.lounge.here);
615
+ }).catch(function () { /* next time */ });
616
+ }
617
+
618
+ function measureTop() { var t = document.querySelector('.top'); if (t) document.documentElement.style.setProperty('--lg-top', Math.round(t.getBoundingClientRect().height) + 'px'); }
619
+ measureTop(); addEventListener('resize', measureTop);
620
+ paintCompose(); paintNight(); paintKeep(); render(true); connect(); now();
621
+ setInterval(function () { if (document.visibilityState !== 'hidden') { now(); } }, 30000);
622
+ setInterval(paintNight, 30000);
623
+ }());`}`;