eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1<meta charset="utf-8" />
2<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
3<meta name="theme-color" content="#5b6cff" />
4<meta name="app-version" content="__APP_VERSION__" />
5<!-- Whether ordinary members may invite people (admin setting). Gates the invite
6 UI only; the server enforces it regardless. "1" = on, "0" = off. -->
7<meta name="user-invites" content="__USER_INVITES__" />
8<!-- Per-instance feature gates (admin settings, default on). Same contract as
9 user-invites: these only hide entry points; the server enforces each gate. -->
10<meta name="enable-ai" content="__ENABLE_AI__" />
11<meta name="enable-bots" content="__ENABLE_BOTS__" />
12<meta name="enable-apps" content="__ENABLE_APPS__" />
13<meta name="enable-member-apps" content="__ENABLE_MEMBER_APPS__" />
14<!-- Space-install policy (admin setting, default off): "1" = only admins may
15 add apps for everyone in the space. Client-enforced — see docs/space-apps.md. -->
16<meta name="space-installs-admins" content="__SPACE_INSTALLS_ADMINS__" />
17<title>__GROUP_NAME__</title>
18<!-- Native wrapper detection: the Mac app's user agent carries "lchatMac". Tag the
19 document so CSS can adopt native conventions (e.g. drop web hand cursors). -->
20<script nonce="__CSP_NONCE__">
21 if (navigator.userAgent.includes("lchatMac"))
22 document.documentElement.classList.add("native");
23</script>
24<!-- The home server that hosts passkey backup enrolment (server-configured via
25 LCHAT_HOME_ORIGIN). backup.js reads it to open the enrol popup — the origin
26 is public, so exposing it here is fine. -->
27<script nonce="__CSP_NONCE__">
28 window.__homeOrigin = "__HOME_ORIGIN__";
29 // The provisioner-pushed plan flag ("free"/"paid"/"demo"; empty when
30 // self-hosted). shell.js reveals the menu's Upgrade entry (free) or the
31 // demo countdown band (demo) from it — display only, nothing here enforces
32 // billing or the demo TTL.
33 window.__entitlement = "__ENTITLEMENT__";
34 // The public-web fetch service this instance prefers for unfurls (docs/fetch.md;
35 // server-configured via LCHAT_FETCH_URL, empty = unfurl via this instance).
36 window.__fetchURL = "__FETCH_URL__";
37</script>
38<!-- Pre-paint bounce: a signed-out browser visit belongs on home's sign-in
39 page. This must be client-side — the session lives in localStorage, which
40 the server (deliberately) cannot see, so an HTTP redirect would bounce
41 signed-in members too. Running it here, before first paint, makes it feel
42 like one: nothing renders before the hop. shell.js's showNoInvite keeps
43 the same check as a backstop (corrupt session, storage denied). Stays put:
44 native webviews (__lchatEmbed, injected at documentStart), the installed
45 Home-Screen app (paste-to-join is its only way to consume an invite
46 link), ?stay=1, home's ?restore=1 handoff, restore returns from home, and
47 unsubstituted dev pages. -->
48<script nonce="__CSP_NONCE__">
49 (function () {
50 try {
51 if (window.__lchatEmbed) return;
52 if (matchMedia("(display-mode: standalone)").matches || navigator.standalone === true) return;
53 if (localStorage.getItem("lchat_session")) return;
54 if (location.hash.indexOf("lchat_restore=") !== -1) return;
55 var params = new URLSearchParams(location.search);
56 if (params.has("stay") || params.get("restore") === "1") return;
57 var home = window.__homeOrigin || "";
58 if (home.indexOf("http") === 0) location.replace(home);
59 } catch (e) {} /* storage denied etc.: route() decides after boot */
60 })();
61</script>
62<link rel="stylesheet" href="/static/style.css" />
63<link rel="manifest" href="/manifest.webmanifest" />
64<link rel="icon" href="/static/icon-192.png" />
65<link rel="apple-touch-icon" href="/static/icon-180.png" />
66<meta name="apple-mobile-web-app-capable" content="yes" />
67<meta name="apple-mobile-web-app-title" content="__GROUP_NAME__" />
68
69<!-- Apply the saved theme + appearance before first paint (no flash of the
70 default). The canonical list + defaults live in web/static/ui.js; this small
71 copy must stay in sync. The choice is a local preference only — it is never
72 sent to the server. -->
73<script nonce="__CSP_NONCE__">
74 (function () {
75 try {
76 var THEMES = ["litroom", "lagoon", "sunny", "plum", "linen", "classic"];
77 var PANEL = {
78 litroom: ["#fff8f0", "#241a1e"], lagoon: ["#f3fbfa", "#0c2a29"],
79 sunny: ["#fffaf0", "#241d12"], plum: ["#fdf8fd", "#221829"],
80 linen: ["#f5f1ea", "#211f1b"], classic: ["#ffffff", "#1d2026"],
81 };
82 var t = localStorage.getItem("lchat_theme");
83 if (THEMES.indexOf(t) < 0) t = "classic";
84 var a = localStorage.getItem("lchat_appearance");
85 var root = document.documentElement;
86 root.dataset.theme = t;
87 root.style.colorScheme = a === "light" ? "light" : a === "dark" ? "dark" : "light dark";
88 var dark = a === "dark" || (a !== "light" && matchMedia("(prefers-color-scheme: dark)").matches);
89 var m = document.querySelector('meta[name="theme-color"]');
90 if (m) m.setAttribute("content", PANEL[t][dark ? 1 : 0]);
91 } catch (e) {}
92 })();
93</script>
94
95<div id="app"></div>
96<!-- Full-screen cover shown until the app's first meaningful paint (theme bg is
97 already set pre-paint above), so a refresh doesn't flash an empty shell and
98 then jump as messages load + scroll. shell.js removes it once ready. -->
99<div id="app-loading" class="app-loading">
100 <!-- The boot scene (bootscene.js): smoke and dust in a haze-filled room, lit
101 from below by an UNSEEN 11-segment meter — two strips of light from the
102 bottom corners converging behind the server's avatar. The cover
103 assembles in stages as a boot takes longer (spinner → brand → quote →
104 smoke); at ready it slams to eleven and bows out. The children stay
105 hidden (opacity 0) until bootscene.js poses them, so the embed and
106 signed-out paths, which never run the scene, show only the plain bg. -->
107 <canvas class="boot-canvas" id="boot-canvas" aria-hidden="true"></canvas>
108 <div class="boot-brand" id="boot-brand">
109 <div class="boot-avatar" id="boot-avatar-tile" aria-hidden="true">
110 <span class="boot-avatar-letter"></span>
111 <img id="boot-avatar-img" alt="" hidden />
112 </div>
113 <div class="boot-name">__GROUP_NAME__</div>
114 </div>
115 <div class="boot-spin" id="boot-spin" role="progressbar" aria-label="Loading"></div>
116 <div class="boot-quote" id="boot-quote">
117 <div class="boot-quote-text" id="boot-quote-text"></div>
118 <div class="boot-quote-attr" id="boot-quote-attr"></div>
119 </div>
120 <a class="boot-goesto" id="boot-goesto" href="https://elevenmessenger.com" target="_blank" rel="noopener">This group chat goes to <b>Eleven</b></a>
121</div>
122<div id="toast" class="toast"></div>
123
124<!-- The multi-thread shell: a thread list (master) + an open thread (detail).
125 On mobile the two panes stack and we toggle .show-thread to navigate. -->
126<template id="app-tpl">
127 <div class="tt" id="tt">
128 <div class="conn-status hidden" id="conn-status" role="status" aria-live="polite">Reconnecting…</div>
129 <!-- Tamper-evidence failure banner (docs/tamper-evidence.md): a full-width
130 top band shown ONLY when this space fails an integrity check. Inline
131 (occupies layout, never overlays); contents built in shell.js. -->
132 <div class="integrity-banner hidden" id="integrity-banner" role="alert"></div>
133 <!-- Demo countdown band (docs/demo-spaces.md): shown only on a demo space.
134 Inline (occupies layout, never overlays); contents built in shell.js
135 from the HOME server's live status — the boot-time entitlement can go
136 stale the moment someone claims the space. -->
137 <div class="demo-banner hidden" id="demo-banner"></div>
138 <div class="tt-body">
139 <aside class="sidebar">
140 <header class="side-head">
141 <button class="avatar-btn" id="me-btn" aria-label="Edit profile" title="Edit profile">
142 <span class="avatar" id="me-avatar"></span>
143 </button>
144 <!-- The heading is the space's name, so the top of the sidebar says WHERE
145 you are — kept in step with document.title (sealed branding updates both). -->
146 <div class="title" id="side-space-name">__GROUP_NAME__</div>
147 <div class="head-actions">
148 <div class="menu-wrap">
149 <button class="icon-btn" id="menu-btn" aria-label="Menu" aria-haspopup="true">⋮</button>
150 <div class="menu hidden" id="menu" role="menu">
151 <button class="menu-item" id="menu-about" role="menuitem">About this space</button>
152 <button class="menu-item" id="menu-apps" role="menuitem">Apps</button>
153 <button class="menu-item" id="menu-notif" role="menuitem">Notifications</button>
154 <button class="menu-item" id="menu-theme" role="menuitem">Theme</button>
155 <button class="menu-item" id="menu-add-device" role="menuitem">Add a device</button>
156 <!-- Mac desktop browsers only (shell.js unhides): the one
157 platform with a scheme receiver today; iOS follows. -->
158 <button class="menu-item" id="menu-open-app" role="menuitem" hidden>Open in the Eleven app</button>
159 <button class="menu-item" id="menu-rename" role="menuitem">Edit profile</button>
160 <a class="menu-item" id="menu-upgrade" role="menuitem" hidden target="_blank" rel="noopener">Upgrade</a>
161 <button class="menu-item danger" id="menu-leave" role="menuitem">Leave</button>
162 </div>
163 </div>
164 <button class="icon-btn" id="new-chat" aria-label="New chat" title="New chat">
165 <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
166 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
167 <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
168 <path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
169 </svg>
170 </button>
171 </div>
172 </header>
173 <div class="chat-search hidden" id="chat-search">
174 <!-- Combobox semantics for the message-results dropdown: focus stays
175 here while aria-activedescendant tracks the highlighted row
176 (searchui.js). aria-controls targets the lazily-built listbox. -->
177 <input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off"
178 role="combobox" aria-autocomplete="list" aria-haspopup="listbox" aria-expanded="false"
179 aria-controls="msg-results-list" />
180 <!-- List filters: online-only + People/Bots/Apps categories (shell.js). -->
181 <div class="menu-wrap">
182 <button type="button" id="filter-btn" class="icon-btn filter-btn" aria-label="Filter chats" title="Filter chats"
183 aria-haspopup="true" aria-expanded="false">
184 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
185 stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
186 <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
187 </svg>
188 </button>
189 <div class="menu filter-menu hidden" id="filter-menu">
190 <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-online" /><span>Online only</span></label>
191 <div class="filter-sep" role="separator"></div>
192 <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-people" checked /><span>People</span></label>
193 <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-bots" checked /><span>Bots</span></label>
194 <label class="filter-row"><input type="checkbox" class="filter-check" id="flt-apps" checked /><span>Apps</span></label>
195 </div>
196 </div>
197 </div>
198 <!-- Consolidated search results (#586, searchui.js): filled while the
199 search box holds a query — matching conversations first (a row opens
200 the chat), then message hits (a row jumps to the message). A dropdown
201 under the search bar, floating over the thread list like the filter
202 menu beside it (the never-float rule covers toasts/nudges, not a
203 user-invoked, transient surface); the zero-height anchor keeps it
204 out of the sidebar column's flow. -->
205 <div class="msg-results-anchor">
206 <div class="msg-results hidden" id="msg-results" role="region" aria-label="Search results"></div>
207 </div>
208 <!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout
209 space in the sidebar — never a floating toast. -->
210 <div class="backup-banner hidden" id="backup-banner"></div>
211 <!-- A subtle, dismissible "a new app is available — Reload / Later" offer
212 (shell.js offerAppReload). Inline, never a forced reload. -->
213 <div class="update-banner hidden" id="app-update-banner"></div>
214 <div class="thread-list" id="thread-list"></div>
215 </aside>
216 <section class="thread-pane">
217 <div class="empty" id="thread-empty">
218 <div class="empty-art">💬</div>
219 <p>Pick a chat on the left, or start a new one.</p>
220 </div>
221 <div class="thread-view hidden" id="thread-view">
222 <header class="thread-head">
223 <button class="icon-btn back-btn" id="back-btn" aria-label="Back to chats">‹</button>
224 <span class="avatar thread-head-avatar" id="thread-avatar"></span>
225 <div class="thread-head-title">
226 <div class="thread-title" id="thread-title"></div>
227 <div class="thread-sub" id="thread-sub"></div>
228 </div>
229 <div class="menu-wrap hidden" id="views-menu-wrap">
230 <button class="icon-btn views-btn" id="views-menu-btn" aria-haspopup="true"><span id="views-menu-label">Open</span> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
231 <div class="menu hidden" id="views-menu" role="menu"></div>
232 </div>
233 <div class="menu-wrap" id="thread-menu-wrap">
234 <button class="icon-btn" id="thread-menu-btn" aria-label="Chat options" aria-haspopup="true">⋮</button>
235 <div class="menu hidden" id="thread-menu" role="menu">
236 <button class="menu-item" id="thread-add" role="menuitem">Add people</button>
237 <button class="menu-item" id="thread-edit" role="menuitem">Edit group</button>
238 <button class="menu-item" id="thread-members" role="menuitem">View members</button>
239 <button class="menu-item" id="thread-media" role="menuitem">Media, links and docs</button>
240 <!-- "Apps", not "Add apps": the sheet manages two scopes now (this
241 chat + the whole space) and shows in DMs too. -->
242 <button class="menu-item" id="thread-apps" role="menuitem">Apps</button>
243 <button class="menu-item" id="thread-pin" role="menuitem">Pin chat</button>
244 <button class="menu-item" id="thread-mute" role="menuitem">Mute chat</button>
245 <button class="menu-item" id="thread-close" role="menuitem">Close chat</button>
246 <button class="menu-item danger" id="thread-block" role="menuitem">Block</button>
247 <button class="menu-item danger" id="thread-remove" role="menuitem">Remove</button>
248 <button class="menu-item danger" id="thread-end" role="menuitem">End conversation</button>
249 <button class="menu-item danger" id="thread-leave" role="menuitem">Leave group</button>
250 </div>
251 </div>
252 </header>
253 <!-- Shared-app panel surfaces (e.g. a poll) mount here, above the feed.
254 Collapses to nothing when no app has state (see .app-surface). -->
255 <div class="app-surface" id="app-surface"></div>
256 <!-- App view surfaces (shown only in .app-mode). A personal app-thread is
257 two Views of one thread: the Chat pane (a DM with its bot) and the
258 Interface pane (its interactive UI) — side by side on desktop, one at a
259 time on mobile (see the split CSS). A normal chat thread uses only the
260 chat pane; #interface-pane stays empty (and hidden). -->
261 <div class="app-split" id="app-split">
262 <div class="app-pane" id="app-pane"></div>
263 <div class="app-split-divider" id="app-split-divider" role="separator" aria-orientation="vertical" aria-label="Resize chat and interface panes" aria-valuemin="20" aria-valuemax="80" tabindex="0"></div>
264 <div class="app-pane interface-pane" id="interface-pane"></div>
265 </div>
266 <div class="drop-overlay hidden" id="drop-overlay">Drop a photo or file to send</div>
267 </div>
268 </section>
269 </div>
270 </div>
271</template>
272
273<template id="noinvite-tpl">
274 <!-- The signed-out landing: a single-CTA screen — the server's branding and
275 one button, Sign in. New members arrive on an invite LINK (which opens
276 /join/… directly), so the paste form is folded behind "Have an invite
277 link?" — still reachable, because the iOS Home-Screen app has no other
278 way to consume a link (the A2HS onboarding says to paste it here). -->
279 <div class="center">
280 <div class="card landing">
281 <!-- Instance branding (plaintext by design — see CLAUDE.md): the photo is
282 revealed by showNoInvite once /api/group confirms one is set. -->
283 <div class="join-brand">
284 <img id="join-brand-photo" class="join-brand-photo hidden" alt="" />
285 <div class="join-brand-name">__GROUP_NAME__</div>
286 </div>
287 <div class="row" id="restore-row" style="display:none">
288 <button type="button" id="restore-btn" style="width:100%">Sign in</button>
289 </div>
290 <div id="join-fold">
291 <p id="join-intro" class="hidden">Paste your secret invite link to join.</p>
292 <form id="paste-join" autocomplete="off" class="hidden">
293 <input id="link" placeholder="Paste your invite link" autocomplete="off" />
294 <input id="name" placeholder="Screen name (e.g. Sophie)" maxlength="32" autocomplete="off" class="hidden" style="margin-top: 10px" />
295 <div class="row"><button type="submit">Join the chat</button></div>
296 </form>
297 <p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p>
298 <div class="row" id="invite-reveal-row">
299 <button type="button" id="invite-reveal" class="signin-link">Have an invite link?</button>
300 </div>
301 </div>
302 </div>
303 </div>
304</template>
305
306<template id="a2hs-tpl">
307 <div class="center">
308 <div class="card onboard">
309 <div class="onboard-icon">📲</div>
310 <h1>Add __GROUP_NAME__ to your Home Screen</h1>
311 <p>On iPhone &amp; iPad, add this to your Home Screen so it opens like an app — and can send you notifications.</p>
312 <ol class="steps">
313 <li>Tap the <strong>Share</strong> button (the square with an up arrow) in Safari's toolbar.</li>
314 <li>Scroll down and choose <strong>Add to Home Screen</strong>.</li>
315 <li>Open <strong>__GROUP_NAME__</strong> from your Home Screen, then paste your invite link to join.</li>
316 </ol>
317 <div class="row"><button id="a2hs-continue" class="secondary">Continue in browser</button></div>
318 </div>
319 </div>
320</template>
321
322<template id="push-tpl">
323 <div class="center">
324 <div class="card onboard">
325 <div class="onboard-icon">🔔</div>
326 <h1>Turn on notifications</h1>
327 <p>Get an alert when there's a new message — even when the chat is closed.</p>
328 <div class="row"><button id="push-enable">Enable notifications</button></div>
329 <div class="row"><button id="push-skip" class="secondary">Not now</button></div>
330 <p id="push-note" class="banner warn hidden" style="margin-top:12px"></p>
331 </div>
332 </div>
333</template>
334
335<!-- bootscene.js is loaded DIRECTLY (not just via shell.js's import graph) so
336 the cover's chrome — avatar, name, spinner, quote, footer — appears the
337 moment this one small file lands; the smoke and lights fade in later,
338 when the shell has booted. Module caching makes this the same instance
339 shell.js imports. -->
340<script type="module" src="/static/bootscene.js"></script>
341<script type="module" src="/static/shell.js"></script>
342