eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The new-chat surface — the roster picker (DMs + groups), membership view,
2// Discover, and the apps catalogue, plus thread creation itself. Extracted
3// verbatim from shell.js; behaviours ride the verbs seam, state shellstate.
4import { verbs } from "/static/shellverbs.js";
5import { ICON_WRENCH } from "/static/icons.js";
6import { session, roster, myUserId, threads, threadKeys, threadApp, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId, ENABLE_BOTS, ENABLE_APPS, ENABLE_MEMBER_APPS, PLACES_UI, instanceKey, ws, tiers, tierKeys, tierKeyB64 } from "/static/shellstate.js";
7import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js";
8import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js";
9import { appGlyphInto, personalApps, groupCapableApps, appByName, installGroupApp } from "/static/apps.js";
10import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
11
12// ---- new chat (roster picker) -------------------------------------------
13
14// The app pickers' view of the registry: with member-made apps off, App Studio
15// (the make-your-own entry point) hides — deploys would 403 anyway. Member apps
16// already deployed stay listed: like the wider apps gate, off only stops new
17// use, it never breaks what members already rely on.
18export function pickerApps() {
19 return personalApps().filter((a) => ENABLE_MEMBER_APPS || a.name !== "appstudio");
20}
21
22// The existing DM thread with a person, if any. There's exactly one DM per pair
23// (the server enforces it too), so starting a chat with someone you already DM
24// should just reopen it — never spawn a second.
25export function existingDM(userId) {
26 return threads.find((t) => t.kind === "dm" && (t.members || []).includes(userId));
27}
28
29// Press Enter in a sheet field to fire its primary action (create / open the top
30// match). Ignores Shift+Enter and IME composition.
31function submitOnEnter(input, action) {
32 input.addEventListener("keydown", (e) => {
33 if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
34 e.preventDefault();
35 action();
36 }
37 });
38}
39
40// startMode ("group" | "invite") opens the sheet straight in that view — the ⌘K
41// palette's "New group" / "Invite someone" land there directly. Anything else
42// (including the click event the + button passes) starts at "people".
43export function showNewChat(startMode) {
44 // Only joined connections can be picked for a new DM/group. Keyless entries are
45 // pending invitees who haven't joined yet — they already have their own chat.
46 const others = roster.filter((e) => e.user_id !== myUserId && e.public_key);
47 const backdrop = verbs.modal();
48 const card = backdrop.querySelector(".sheet");
49
50 // Run a createThread promise, then close the sheet and open the new thread.
51 // A failure with one of OUR reasons (a friendly-flagged error — see
52 // friendlyError) renders it inline in the sheet, occupying layout space
53 // (#786) — the button's generic "try again" hid genuinely different
54 // problems ("still setting up" vs a network blip) behind one label.
55 const go = async (promise, btn) => {
56 btn.disabled = true;
57 const stale = card.querySelector(".sheet-error");
58 if (stale) stale.remove();
59 try {
60 const id = await promise;
61 backdrop.remove();
62 await verbs.loadThreads();
63 verbs.renderThreadList();
64 verbs.openThread(id);
65 } catch (e) {
66 console.error(e);
67 btn.disabled = false;
68 if (e && e.friendly && e.message) {
69 const note = document.createElement("p");
70 note.className = "sheet-error";
71 note.setAttribute("role", "alert");
72 note.textContent = e.message;
73 btn.closest(".sheet-foot").before(note);
74 btn.textContent = "Try again";
75 } else {
76 btn.textContent = "Couldn't start — try again";
77 }
78 }
79 };
80
81 // Apps (To-do, Reminders, …) share the ONE scrollable list with people and
82 // helpers, as their own "Apps" group. They aren't roster members you DM,
83 // though: tapping one opens (or reopens) its personal app-thread via
84 // resolveAppThread, never createThread. matchingApps filters the registry's
85 // scope:"personal" set by the same query as the people search.
86 //
87 // An app may gate on a connected service (`requiresService`): DNSimple's app
88 // only "installs" once you've linked DNSimple. We learn the linked set from
89 // /api/connected; until it arrives (or if the fetch fails) gated apps stay
90 // hidden — no flash of a not-yet-usable app — then we re-render.
91 let connectedServices = null; // null = unknown yet; a Set once loaded
92 fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
93 .then((r) => (r.ok ? r.json() : { services: [] }))
94 .then((j) => {
95 connectedServices = new Set(j.services || []);
96 render(currentMode);
97 })
98 .catch(() => (connectedServices = new Set()));
99 // The apps gate empties this (both the picker's Apps section and the
100 // no-connections fallback list go through here); existing app THREADS still
101 // open from the sidebar — only the entry point for new ones hides.
102 const matchingApps = (q) =>
103 !ENABLE_APPS
104 ? []
105 : pickerApps().filter(
106 (a) =>
107 (a.title || a.name).toLowerCase().includes(q) &&
108 (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))),
109 );
110 // The Admin app is system-scope (deliberately absent from personalApps), but
111 // admins get its row in the Apps section all the same — the discoverable way
112 // in, beyond the /admin command. Empty for everyone else. Deliberately exempt
113 // from the apps gate: Admin is how an admin switches apps back on. Webhooks
114 // (also system-scope, also admin-only — docs/webhooks.md) rides the same row
115 // logic.
116 const adminApps = verbs.isAdmin()
117 ? [
118 { name: "admin", title: "Admin", glyph: "🛠️", logo: ICON_WRENCH },
119 { name: "webhooks", title: "Webhooks", glyph: "📥" },
120 ]
121 : [];
122 const adminMatching = (q) => adminApps.filter((a) => a.title.toLowerCase().includes(q));
123 const addAppRow = (listEl, app) => {
124 const b = document.createElement("button");
125 b.className = "list-row";
126 const g = document.createElement("span");
127 g.className = "avatar app-avatar";
128 appGlyphInto(g, app);
129 const nm = document.createElement("span");
130 nm.textContent = app.title || app.name;
131 b.append(g, nm);
132 b.onclick = async () => {
133 backdrop.remove();
134 // A personal copy that already exists opens directly — the common,
135 // repeat case stays one tap. With the full places UI (PLACES_UI), an
136 // app you've never opened lands in the Apps sheet FOCUSED on it
137 // instead: its birth is a place decision (just for you / a chat /
138 // everyone), never a silent create — only directory-listed apps have a
139 // sheet entry to land on; host-mounted rows (Admin) keep the direct
140 // open. With the places UI hidden (the default — § The pullback),
141 // tapping an app just sets it up in its one obvious place: the direct
142 // personal create this row always used to do.
143 const existing = Object.keys(threadApp).find((id) => threadApp[id] === app.name);
144 if (existing) return verbs.openThread(existing);
145 if (!PLACES_UI || app.listed !== true) {
146 const id = await verbs.resolveAppThread(app.name);
147 if (id) verbs.openThread(id);
148 return;
149 }
150 verbs.manageGroupApps({ space: true, focus: app.name });
151 };
152 listEl.appendChild(b);
153 };
154
155 // "people": tap a name → DM. "group": multi-select + name → group.
156 let currentMode = "people"; // tracked so the async connected-fetch can re-render in place
157 const render = (mode) => {
158 currentMode = mode;
159 card.innerHTML = "";
160 const head = document.createElement("div");
161 head.className = "sheet-head";
162 const h = document.createElement("h2");
163 h.textContent = mode === "group" ? "New group" : mode === "invite" ? "Invite someone" : "New chat";
164 head.appendChild(h);
165 // Secondary actions for the default "people" view: switch to a group, and — when
166 // the admin allows it — invite someone new to the server.
167 if (mode === "people") {
168 // Offered whenever creating could succeed: people to pick, OR the
169 // instance key that lets an open group start with just its creator
170 // (the others-only gate predates open groups; native never had it).
171 if (others.length || instanceKey) {
172 const toggle = document.createElement("button");
173 toggle.className = "linkish";
174 toggle.textContent = "New group";
175 toggle.onclick = () => render("group");
176 head.appendChild(toggle);
177 }
178const disc = document.createElement("button");
179 disc.className = "linkish";
180 disc.textContent = "Discover";
181 disc.onclick = () => {
182 backdrop.remove();
183 showDiscover();
184 };
185 head.appendChild(disc);
186 if (verbs.inviteAllowed()) {
187 const inv = document.createElement("button");
188 inv.className = "linkish";
189 inv.textContent = "Invite someone";
190 inv.onclick = () => render("invite");
191 head.appendChild(inv);
192 }
193 }
194 card.appendChild(head);
195
196 // Invite a brand-new person: name them, mint a link, open the pending DM.
197 if (mode === "invite") {
198 const note = document.createElement("p");
199 note.className = "sub";
200 note.style.padding = "0 4px 6px";
201 note.textContent =
202 "They'll get a private chat with you and a link to join. Anyone with the link can read that chat, so share it directly with them.";
203 card.appendChild(note);
204 const who = document.createElement("input");
205 who.placeholder = "Their name (e.g. Grandma)";
206 who.maxLength = 60;
207 who.className = "sheet-input";
208 card.appendChild(who);
209 // Cohort invites, right where people actually invite. The select lives
210 // in the DOM from the start (keeping its place between the name field
211 // and the buttons) but populates from a FRESH fetch — the module `tiers`
212 // cache can lag a level just created in the Admin app, or another
213 // member's change this session. Hidden until it has an option.
214 const inviteTierSel = document.createElement("select");
215 inviteTierSel.className = "sheet-input";
216 inviteTierSel.setAttribute("aria-label", "Access level for this invite");
217 inviteTierSel.style.display = "none";
218 card.appendChild(inviteTierSel);
219 verbs.loadTiers().then(() => {
220 const meEntry = roster.find((e) => e.user_id === myUserId);
221 const isAdmin = !!(meEntry && meEntry.role === "admin");
222 // The flat-group rule applied to levels: you can bring someone into a
223 // level you're IN (you hold its key already); admins mint for any
224 // level. The server enforces the same line.
225 const usableLevels = tiers.filter((t) => !t.key_lost && (isAdmin || t.mine));
226 if (!usableLevels.length) return;
227 const none = document.createElement("option");
228 none.value = "";
229 none.textContent = "No access level";
230 inviteTierSel.appendChild(none);
231 for (const t of usableLevels) {
232 const o = document.createElement("option");
233 o.value = t.id;
234 o.textContent = `Add them to ${t.name}`;
235 inviteTierSel.appendChild(o);
236 }
237 inviteTierSel.style.display = "";
238 });
239 const foot = document.createElement("div");
240 foot.className = "sheet-foot";
241 const cancel = document.createElement("button");
242 cancel.className = "secondary";
243 cancel.textContent = "Cancel";
244 cancel.onclick = () => render("people");
245 const create = document.createElement("button");
246 create.textContent = "Create invite";
247 create.disabled = true;
248 who.addEventListener("input", () => (create.disabled = !who.value.trim()));
249 create.onclick = () =>
250 go(verbs.inviteSomeone(who.value.trim(), (inviteTierSel && inviteTierSel.value) || ""), create);
251 submitOnEnter(who, () => create.click());
252 foot.appendChild(cancel);
253 foot.appendChild(create);
254 card.appendChild(foot);
255 who.focus();
256 return;
257 }
258
259 // Nobody else here yet: the people picker has nothing to offer, so show
260 // the invite empty state — EXCEPT in group mode with the instance key,
261 // where an open group can start with just its creator; fall through to
262 // the real group view (name, Open toggle, create).
263 if (!others.length && !(mode === "group" && instanceKey)) {
264 const p = document.createElement("p");
265 p.className = "sub";
266 if (verbs.inviteAllowed()) {
267 p.textContent = "No connections yet — invite someone to start chatting.";
268 card.appendChild(p);
269 const btn = document.createElement("button");
270 btn.textContent = "Invite someone";
271 btn.onclick = () => render("invite");
272 card.appendChild(btn);
273 } else {
274 p.textContent = "No one else to chat with yet. Ask the admin to invite people.";
275 card.appendChild(p);
276 }
277 const apps = matchingApps("");
278 if (apps.length || adminApps.length) {
279 const list = document.createElement("div");
280 list.className = "pick-list";
281 list.appendChild(pickerSection("Apps"));
282 for (const app of apps) addAppRow(list, app);
283 for (const app of adminApps) addAppRow(list, app);
284 card.appendChild(list);
285 }
286 return;
287 }
288
289 let nameInput, openCb, tierSel, defaultCb;
290 if (mode === "group") {
291 nameInput = document.createElement("input");
292 nameInput.placeholder = "Group name (optional)";
293 nameInput.maxLength = 60;
294 nameInput.className = "sheet-input";
295 card.appendChild(nameInput);
296 // Open community group: any member can find it in Discover and join
297 // without an invite. Needs the instance key (it carries the group key to
298 // future joiners) — until that arrives the choice simply isn't offered,
299 // matching how photos wait for the same key.
300 if (instanceKey) {
301 const row = document.createElement("label");
302 row.className = "opt-row";
303 openCb = document.createElement("input");
304 openCb.type = "checkbox";
305 if (startMode === "open-group") openCb.checked = true;
306 const txt = document.createElement("span");
307 txt.textContent = "Open group";
308 const desc = document.createElement("span");
309 desc.className = "opt-desc";
310 desc.textContent = "Anyone in this space can find it under Discover and join.";
311 txt.appendChild(desc);
312 row.appendChild(openCb);
313 row.appendChild(txt);
314 card.appendChild(row);
315 // Gate the group to an access level — offered only for levels whose
316 // key this device holds (the open_key is wrapped under it).
317 const myLevels = tiers.filter((t) => t.mine && tierKeyB64[t.id]);
318 if (myLevels.length) {
319 tierSel = document.createElement("select");
320 tierSel.className = "sheet-input";
321 tierSel.setAttribute("aria-label", "Who can find and join");
322 const all = document.createElement("option");
323 all.value = "";
324 all.textContent = "Everyone in this space";
325 tierSel.appendChild(all);
326 for (const t of myLevels) {
327 const o = document.createElement("option");
328 o.value = t.id;
329 o.textContent = `Only ${t.name}`;
330 tierSel.appendChild(o);
331 }
332 tierSel.style.display = openCb.checked ? "" : "none";
333 openCb.addEventListener("change", () => {
334 tierSel.style.display = openCb.checked ? "" : "none";
335 });
336 card.appendChild(tierSel);
337 }
338 // Admins can make an open group one that new members auto-join
339 // (a #general). Admin-only, and only while open + ungated (a gated
340 // group can't be default — the server refuses; a newcomer holds no
341 // level yet). Mirrors the Edit-group toggle.
342 const meEntry = roster.find((e) => e.user_id === myUserId);
343 if (meEntry && meEntry.role === "admin") {
344 const drow = document.createElement("label");
345 drow.className = "opt-row";
346 defaultCb = document.createElement("input");
347 defaultCb.type = "checkbox";
348 const dtxt = document.createElement("span");
349 dtxt.textContent = "New members join automatically";
350 const ddesc = document.createElement("span");
351 ddesc.className = "opt-desc";
352 ddesc.textContent = "Everyone who joins the space starts in this group.";
353 dtxt.appendChild(ddesc);
354 drow.appendChild(defaultCb);
355 drow.appendChild(dtxt);
356 card.appendChild(drow);
357 const syncDefault = () => {
358 const gated = tierSel && tierSel.value;
359 const on = openCb.checked && !gated;
360 drow.style.display = on ? "" : "none";
361 if (!on) defaultCb.checked = false;
362 };
363 syncDefault();
364 openCb.addEventListener("change", syncDefault);
365 if (tierSel) tierSel.addEventListener("change", syncDefault);
366 }
367 }
368 }
369
370 // Start-with-apps (group mode): pick shared apps at the group's birth —
371 // parity with native's NewGroupView, and the best teaching moment for
372 // "added here, shared here". The picks land as ordinary groupapps.add
373 // events in the fresh log, exactly like ⋮ → Apps afterwards.
374 const birthApps = new Set();
375 if (mode === "group" && ENABLE_APPS && groupCapableApps().length) {
376 const alist = document.createElement("div");
377 alist.className = "pick-list";
378 alist.appendChild(pickerSection("Start with apps"));
379 for (const app of groupCapableApps()) {
380 const row = document.createElement("label");
381 row.className = "list-row";
382 const cb = document.createElement("input");
383 cb.type = "checkbox";
384 cb.onchange = () => (cb.checked ? birthApps.add(app.name) : birthApps.delete(app.name));
385 const body = document.createElement("span");
386 body.style.cssText = "flex:1;min-width:0;display:flex;align-items:center;gap:8px;";
387 const g = document.createElement("span");
388 g.className = "avatar app-avatar";
389 g.style.cssText = "width:26px;height:26px;font-size:14px;";
390 appGlyphInto(g, app);
391 const nm = document.createElement("span");
392 nm.textContent = app.title || app.name;
393 body.append(g, nm);
394 row.append(cb, body);
395 alist.appendChild(row);
396 }
397 card.appendChild(alist);
398 }
399
400 const filter = document.createElement("input");
401 filter.placeholder = "Search";
402 filter.className = "sheet-input";
403 // A search box over nobody (the solo open-group path) is just confusing.
404 if (!others.length) filter.style.display = "none";
405 card.appendChild(filter);
406
407 const list = document.createElement("div");
408 list.className = "pick-list";
409 card.appendChild(list);
410
411 const selected = new Set();
412 const create = document.createElement("button");
413 // An open group may start with just its creator (others join via
414 // Discover); a closed group still needs at least one other member.
415 const updateCreate = () => {
416 create.disabled = selected.size === 0 && !(openCb && openCb.checked);
417 };
418 if (openCb) openCb.onchange = updateCreate;
419
420 const renderList = () => {
421 const q = filter.value.trim().toLowerCase();
422 list.innerHTML = "";
423 const addRow = (e) => {
424 if (mode === "group") {
425 const row = document.createElement("label");
426 row.className = "list-row";
427 const cb = document.createElement("input");
428 cb.type = "checkbox";
429 cb.checked = selected.has(e.user_id);
430 cb.onchange = () => {
431 cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
432 updateCreate();
433 };
434 row.appendChild(cb);
435 row.appendChild(pickerName(e));
436 list.appendChild(row);
437 } else {
438 const b = document.createElement("button");
439 b.className = "list-row";
440 b.appendChild(pickerName(e));
441 b.onclick = () => {
442 const dm = existingDM(e.user_id);
443 if (dm) {
444 backdrop.remove();
445 verbs.openThread(dm.id); // already chatting — just reopen it
446 return;
447 }
448 go(createThread([e.user_id], { group: false }), b);
449 };
450 list.appendChild(b);
451 }
452 };
453 // Three groups in the one scrollable list: people, then bots/AI under
454 // "Helpers", then personal apps under "Apps" (people picker only — a group's
455 // member list can't include an app). People get a heading only when another
456 // group sits alongside them (a lone people list needs no label); helpers and
457 // apps always get theirs, since each is deliberately "not a person".
458 const people = others.filter((e) => !e.is_agent && rosterMatches(e, q));
459 // Bots off empties the Helpers section — existing bot DMs stay in the
460 // sidebar; only starting new ones hides.
461 const helpers = ENABLE_BOTS ? others.filter((e) => e.is_agent && rosterMatches(e, q)) : [];
462 const apps = mode === "people" ? matchingApps(q) : [];
463 const adminHits = mode === "people" ? adminMatching(q) : [];
464 if (!people.length && !helpers.length && !apps.length && !adminHits.length) {
465 const p = document.createElement("p");
466 p.className = "sub";
467 p.style.padding = "12px 28px";
468 p.textContent = "No matches.";
469 list.appendChild(p);
470 return;
471 }
472 if (people.length && (helpers.length || apps.length || adminHits.length)) list.appendChild(pickerSection("People"));
473 for (const e of people) addRow(e);
474 if (helpers.length) list.appendChild(pickerSection("Helpers"));
475 for (const e of helpers) addRow(e);
476 if (apps.length || adminHits.length) list.appendChild(pickerSection("Apps"));
477 for (const app of apps) addAppRow(list, app);
478 for (const app of adminHits) addAppRow(list, app);
479 };
480 filter.addEventListener("input", renderList);
481 renderList();
482
483 // Enter submits: in a group, create it; in the picker, open the top match.
484 submitOnEnter(filter, () => {
485 if (mode === "group") return create.click();
486 const first = list.querySelector("button.list-row");
487 if (first) first.click();
488 });
489 if (mode === "group") submitOnEnter(nameInput, () => create.click());
490
491 if (mode === "group") {
492 const foot = document.createElement("div");
493 foot.className = "sheet-foot";
494 const cancel = document.createElement("button");
495 cancel.className = "secondary";
496 cancel.textContent = "Cancel";
497 cancel.onclick = () => render("people");
498 create.textContent = "Create group";
499 updateCreate(); // pre-ticked Open (Discover's empty state) starts enabled
500 create.onclick = () =>
501 go(
502 (async () => {
503 const tid = await createThread([...selected], {
504 group: true,
505 name: nameInput.value.trim(),
506 open: !!(openCb && openCb.checked),
507 openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "",
508 default: !!(defaultCb && defaultCb.checked),
509 });
510 // Birth apps ride the fresh log; best-effort in order — a failed
511 // add is recoverable from ⋮ → Apps, never a failed group.
512 for (const name of birthApps) {
513 const app = appByName(name);
514 if (app) await installGroupApp(tid, app).catch(() => {});
515 }
516 return tid;
517 })(),
518 create,
519 );
520 foot.appendChild(cancel);
521 foot.appendChild(create);
522 card.appendChild(foot);
523 }
524 };
525 // Honour a requested start view only when it's usable, else fall back.
526 let start = "people";
527 if (startMode === "group" && others.length) start = "group";
528 // "open-group" (Discover's empty state) works even with nobody else joined
529 // yet — an open group may start with just its creator.
530 if (startMode === "open-group") start = "group";
531 if (startMode === "invite" && verbs.inviteAllowed()) start = "invite";
532 render(start);
533}
534
535// Discover (⌘K → "Discover groups", or the new-chat sheet): the community
536// directory of open groups — join any of them on the spot. Each row's name
537// decrypts via its open_key + the instance key, so the whole directory is
538// readable by any member and by nobody else (the server sees only blobs).
539// Membership: the member's paid-access-levels surface. Their current
540// subscription (with the dated "renews / ends on" line — the reassuring
541// heart), plus any levels they can subscribe to. Reachable from Edit profile
542// and ⌘K when the space sells any level or the member already subscribes.
543// Anti-dark-pattern: cancel is a plain, equal-weight action and always states
544// the exact keep-until date; price + interval are shown before any commitment.
545export async function showMembership() {
546 const backdrop = verbs.modal();
547 const card = backdrop.querySelector(".sheet");
548 const head = document.createElement("div");
549 head.className = "sheet-head";
550 const h = document.createElement("h2");
551 h.textContent = "Membership";
552 head.appendChild(h);
553 card.appendChild(head);
554
555 const status = statusLineEl();
556 card.appendChild(status);
557
558 const paint = async () => {
559 await verbs.loadTiers();
560 // Clear everything below the header + status line.
561 while (status.nextSibling) status.nextSibling.remove();
562
563 const sellable = tiers.filter((t) => t.price_cents > 0);
564 const subscribed = tiers.filter((t) => t.status); // active | past_due | canceled
565 if (!sellable.length && !subscribed.length) {
566 const p = document.createElement("p");
567 p.className = "membership-note";
568 p.textContent = "This space doesn't offer paid access levels.";
569 card.appendChild(p);
570 return;
571 }
572
573 const note = document.createElement("p");
574 note.className = "membership-note";
575 note.textContent = "Subscriptions are handled securely by Stripe. You can change or cancel anytime.";
576 card.appendChild(note);
577
578 const busy = (btn, fn) => async () => {
579 btn.disabled = true;
580 const prev = btn.textContent;
581 btn.textContent = "…";
582 const res = await fn();
583 if (res && res.url) {
584 location.href = res.url; // hosted checkout / billing portal
585 return;
586 }
587 btn.disabled = false;
588 btn.textContent = prev;
589 if (res) {
590 flash(status, "Done.");
591 await paint();
592 } else {
593 flash(status, "Something went wrong — try again.");
594 }
595 };
596
597 // ---- Your subscription (current state) --------------------------------
598 for (const t of subscribed) {
599 const box = document.createElement("div");
600 box.className = "plan-card current";
601 const hd = document.createElement("div");
602 hd.className = "plan-head";
603 const nm = document.createElement("span");
604 nm.className = "plan-name";
605 nm.textContent = t.name;
606 hd.appendChild(nm);
607 const pr = verbs.levelPrice(t);
608 if (pr) {
609 const price = document.createElement("span");
610 price.className = "plan-price";
611 price.textContent = pr.text;
612 const per = document.createElement("span");
613 per.className = "per";
614 per.textContent = pr.once ? " · once" : " / " + pr.per;
615 price.appendChild(per);
616 hd.appendChild(price);
617 }
618 box.appendChild(hd);
619
620 // The dated status line — icon (shape) + words, never colour alone.
621 const st = document.createElement("div");
622 st.className = "plan-state";
623 const dot = document.createElement("span");
624 dot.className = "dot";
625 st.appendChild(dot);
626 const txt = document.createElement("span");
627 if (t.lifetime && t.status === "active") {
628 txt.textContent = "Yours for keeps — paid once.";
629 } else if (t.status === "active") {
630 txt.textContent = t.paid_until ? `Renews on ${verbs.billingDate(t.paid_until)}` : "Active";
631 } else if (t.status === "past_due") {
632 st.classList.add("warn");
633 dot.textContent = "";
634 const warn = document.createElement("span");
635 warn.setAttribute("aria-hidden", "true");
636 warn.textContent = "⚠ ";
637 st.replaceChild(warn, dot);
638 txt.textContent = `Payment didn't go through — access ends ${verbs.billingDate(t.grace_deadline)} unless it's fixed.`;
639 } else {
640 // canceled / ending
641 txt.textContent = `Your access ends on ${verbs.billingDate(t.grace_deadline || t.paid_until)}.`;
642 }
643 st.appendChild(txt);
644 box.appendChild(st);
645
646 const acts = document.createElement("div");
647 acts.className = "plan-actions";
648 if (t.lifetime) {
649 // A one-time purchase has no billing to manage: no cancel, no
650 // resume, no portal — the box is just the good news above.
651 } else if (t.status === "active") {
652 // Change level (to another sellable level), Cancel, Manage billing.
653 const others = sellable.filter((x) => x.id !== t.id);
654 if (others.length) {
655 const chg = pillBtn("Change level", true);
656 chg.onclick = () => showChangeLevel(t, others, paint, status);
657 acts.appendChild(chg);
658 }
659 const cancel = pillBtn("Cancel", true);
660 cancel.onclick = () => confirmCancel(t, paint, status);
661 acts.appendChild(cancel);
662 } else if (t.status === "past_due") {
663 const fix = pillBtn("Update payment", false);
664 fix.onclick = busy(fix, () => verbs.levelPortal(t.id));
665 acts.appendChild(fix);
666 } else {
667 // ending — offer to resume (un-cancels the same subscription, no new charge).
668 const resume = pillBtn("Resume", false);
669 resume.onclick = busy(resume, () => verbs.resumeLevelSub(t.id));
670 acts.appendChild(resume);
671 }
672 const manage = pillBtn("Manage billing", true);
673 manage.onclick = busy(manage, () => verbs.levelPortal(t.id));
674 acts.appendChild(manage);
675 box.appendChild(acts);
676 card.appendChild(box);
677 }
678
679 // ---- Levels you can subscribe to --------------------------------------
680 const canSubscribe = sellable.filter((t) => !t.status);
681 if (canSubscribe.length) {
682 if (subscribed.length) {
683 const sec = document.createElement("div");
684 sec.className = "membership-sec";
685 sec.textContent = "Other access levels";
686 card.appendChild(sec);
687 }
688 for (const t of canSubscribe) {
689 const box = document.createElement("div");
690 box.className = "plan-card";
691 const hd = document.createElement("div");
692 hd.className = "plan-head";
693 const nm = document.createElement("span");
694 nm.className = "plan-name";
695 nm.textContent = t.name;
696 hd.appendChild(nm);
697 const pr = verbs.levelPrice(t);
698 const price = document.createElement("span");
699 price.className = "plan-price";
700 price.textContent = pr.text;
701 const per = document.createElement("span");
702 per.className = "per";
703 per.textContent = pr.once ? " · once" : " / " + pr.per;
704 price.appendChild(per);
705 hd.appendChild(price);
706 box.appendChild(hd);
707 const acts = document.createElement("div");
708 acts.className = "plan-actions";
709 const sub = pillBtn("Subscribe", false);
710 sub.onclick = busy(sub, () => verbs.subscribeToLevel(t.id));
711 acts.appendChild(sub);
712 box.appendChild(acts);
713 card.appendChild(box);
714 }
715 }
716 };
717 await paint();
718}
719
720// A quiet pill button; loud=accent (the one prominent action), else the quiet
721// variant. Reuses the .pill-btn vocabulary from Discover.
722function pillBtn(label, quiet) {
723 const b = document.createElement("button");
724 b.className = "pill-btn" + (quiet ? " quiet" : "");
725 b.textContent = label;
726 return b;
727}
728
729// A small inline status line (reused across the membership flows).
730function statusLineEl() {
731 const p = document.createElement("div");
732 p.className = "admin-status";
733 p.setAttribute("role", "status");
734 p.style.cssText = "min-height:18px;font-size:13px;color:var(--muted)";
735 return p;
736}
737
738// Show a transient message in an inline status element (never a floating toast).
739export function flash(el, msg) {
740 if (!el) return;
741 el.textContent = msg;
742 setTimeout(() => {
743 if (el.textContent === msg) el.textContent = "";
744 }, 2500);
745}
746
747// Cancel confirmation as a DATED PROMISE, not a threat: it names the exact day
748// access continues to, so cancelling never feels like losing everything now.
749function confirmCancel(t, repaint, status) {
750 const until = verbs.billingDate(t.paid_until);
751 const msg = until
752 ? `You'll keep ${t.name} until ${until}. Cancel your subscription?`
753 : `Cancel your ${t.name} subscription?`;
754 if (!confirm(msg)) return;
755 flash(status, "Cancelling…");
756 verbs.cancelLevelSub(t.id).then((ok) => {
757 flash(status, ok ? "Cancelled — you keep access until it ends." : "Couldn't cancel — try again.");
758 repaint();
759 });
760}
761
762// Change level: pick another sellable level; switches the subscription in place
763// (Stripe prorates the difference).
764function showChangeLevel(from, others, repaint, status) {
765 const backdrop = verbs.modal();
766 const card = backdrop.querySelector(".sheet");
767 const h = document.createElement("h2");
768 h.textContent = "Change level";
769 card.appendChild(h);
770 const note = document.createElement("p");
771 note.className = "membership-note";
772 note.textContent = `You're on ${from.name}. Switching adjusts your next bill for the difference.`;
773 card.appendChild(note);
774 for (const t of others) {
775 const row = document.createElement("button");
776 row.className = "list-row";
777 const nm = document.createElement("span");
778 nm.style.flex = "1";
779 nm.textContent = t.name;
780 const pr = verbs.levelPrice(t);
781 const price = document.createElement("span");
782 price.className = "plan-price";
783 price.textContent = pr.once ? `${pr.text} · once` : `${pr.text} / ${pr.per}`;
784 row.append(nm, price);
785 row.onclick = () => {
786 if (!confirm(`Switch to ${t.name} (${pr.text} / ${pr.per})?`)) return;
787 backdrop.remove();
788 flash(status, "Switching…");
789 verbs.changeLevelSub(from.id, t.id).then((ok) => {
790 flash(status, ok ? "Switched." : "Couldn't switch — try again.");
791 repaint();
792 });
793 };
794 card.appendChild(row);
795 }
796 const foot = document.createElement("div");
797 foot.className = "sheet-foot";
798 const cancel = document.createElement("button");
799 cancel.className = "secondary";
800 cancel.textContent = "Back";
801 cancel.onclick = () => backdrop.remove();
802 foot.appendChild(cancel);
803 card.appendChild(foot);
804}
805
806export async function showDiscover() {
807 const backdrop = verbs.modal();
808 const card = backdrop.querySelector(".sheet");
809 const head = document.createElement("div");
810 head.className = "sheet-head";
811 const h = document.createElement("h2");
812 h.textContent = "Discover groups";
813 head.appendChild(h);
814 card.appendChild(head);
815 const note = document.createElement("p");
816 note.className = "sub";
817 note.style.padding = "0 4px 6px";
818 note.textContent = "Groups anyone in this space can join — no invite needed.";
819 card.appendChild(note);
820
821 let rows = [];
822 try {
823 const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json();
824 rows = r.threads || [];
825 } catch (e) {
826 console.error(e);
827 note.textContent = "Couldn't load open groups — try again.";
828 return;
829 }
830 if (!rows.length) {
831 // An empty directory is an invitation: hand over the one action that
832 // fills it (the button lands in the new-group sheet with Open pre-ticked).
833 note.textContent = "No open groups here yet. Start one and it shows up for everyone in this space.";
834 const start = document.createElement("button");
835 start.textContent = "Start an open group";
836 start.onclick = () => {
837 backdrop.remove();
838 showNewChat("open-group");
839 };
840 card.appendChild(start);
841 return;
842 }
843 if (!instanceKey) {
844 // Same key-pending state as photos: the instance key hasn't reached this
845 // device yet, so the directory can't be decrypted (or joined usefully).
846 note.textContent = "Waiting for your key to arrive — try again in a moment.";
847 return;
848 }
849
850 const list = document.createElement("div");
851 list.className = "pick-list";
852 card.appendChild(list);
853 await verbs.loadTiers(); // gate keys + level names for the rows below
854 for (const row of rows) {
855 // Row states: joined always wins (membership IS access — Open must work
856 // even if we've since left the gate level); then locked to another level
857 // (an honest lock — the name STAYS sealed, that's the feature); then
858 // ours-but-the-key-hasn't-arrived.
859 const joined = !!row.joined;
860 const locked = !joined && row.open_tier && !row.eligible;
861 const keyPending = !joined && !locked && row.open_tier && !tierKeys[row.open_tier];
862 let name = row.tier_name ? `A group for ${row.tier_name}` : "Open group";
863 if (!locked && !keyPending) {
864 const src = row.open_tier ? tierKeys[row.open_tier] : instanceKey;
865 if (src) {
866 try {
867 const key = await importKey((await decrypt(src, row.open_key)).k);
868 if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name;
869 } catch (e) {
870 console.error("discover row", row.id, e);
871 if (!joined) continue; // undecryptable AND unjoinable — don't offer it
872 }
873 }
874 }
875 const item = document.createElement("div");
876 item.className = "list-row static";
877 item.appendChild(avatarEl(name));
878 const info = document.createElement("div");
879 info.className = "discover-main";
880 const title = document.createElement("div");
881 title.textContent = name;
882 const sub = document.createElement("span");
883 sub.className = "helper-desc";
884 const count = row.member_count === 1 ? "1 member" : `${row.member_count} members`;
885 if (joined && row.open_tier && !row.eligible) {
886 sub.textContent = `${count} · you're in, but no longer in ${row.tier_name || "its level"}`;
887 } else if (locked) {
888 sub.textContent = row.tier_name ? `${count} · for ${row.tier_name} only` : `${count} · limited access`;
889 } else if (keyPending) {
890 sub.textContent = `${count} · your key is on its way`;
891 } else {
892 sub.textContent = row.default_join ? `${count} · everyone starts here` : count;
893 }
894 info.appendChild(title);
895 info.appendChild(sub);
896 item.appendChild(info);
897 const btn = document.createElement("button");
898 btn.className = "pill-btn";
899 if (!joined && (locked || keyPending)) {
900 // No dead Join buttons: the state is the row's text (never color
901 // alone); a lock is information, not an error.
902 btn.classList.add("quiet");
903 btn.disabled = true;
904 btn.textContent = locked ? "Locked" : "Waiting";
905 item.appendChild(btn);
906 list.appendChild(item);
907 continue;
908 }
909 if (row.joined) {
910 // Already yours — the quiet variant keeps Join the sheet's one loud act.
911 btn.classList.add("quiet");
912 btn.textContent = "Open";
913 btn.onclick = () => {
914 backdrop.remove();
915 verbs.openThread(row.id);
916 };
917 } else {
918 btn.textContent = "Join";
919 btn.onclick = async () => {
920 btn.disabled = true;
921 try {
922 const r = await fetch(`/api/threads/${encodeURIComponent(row.id)}/join`, {
923 method: "POST",
924 headers: { "Content-Type": "application/json" },
925 body: JSON.stringify({ token: session.token }),
926 });
927 if (!r.ok) throw new Error("join failed");
928 await verbs.loadThreads();
929 verbs.renderThreadList();
930 // Tell the room — the same action-line shape announceJoin sends,
931 // but in the joiner's own voice ("Paul joined", not "… added Paul").
932 // The join field nudges other clients to refresh their member list.
933 const key = threadKeys[row.id];
934 if (key && ws && ws.readyState === WebSocket.OPEN) {
935 const me = session.name || "New member";
936 const payload = { name: me, text: `${me} joined`, join: myUserId ? [myUserId] : [], ts: Date.now(), action: true };
937 if (myUserId) payload.uid = myUserId;
938 ws.send(JSON.stringify({ type: "msg", thread_id: row.id, blob: await encrypt(key, payload) }));
939 }
940 // The sheet stays up until we're actually in: a failure above lands
941 // in the catch with the retry button still visible (re-running the
942 // join is safe — the server treats it as idempotent).
943 backdrop.remove();
944 verbs.openThread(row.id);
945 } catch (e) {
946 console.error(e);
947 btn.disabled = false;
948 btn.textContent = "Couldn't join — try again";
949 }
950 };
951 }
952 item.appendChild(btn);
953 list.appendChild(item);
954 }
955 if (!list.childElementCount) {
956 // Every row failed to decrypt (e.g. open_keys minted against a different
957 // instance key) — say so rather than presenting a silently empty sheet.
958 const p = document.createElement("p");
959 p.className = "sub";
960 p.style.padding = "0 4px 6px";
961 p.textContent = "Couldn't read the group directory on this device — try again in a moment.";
962 card.appendChild(p);
963 }
964}
965
966// Apps directory (⌘K → "Open apps"): the personal apps as a plain pick list —
967// tap one to open (or create on first use) its app-thread. Same rows the
968// New-chat sheet shows under "Apps", including the connected-service gate: a
969// `requiresService` app only lists once its service is linked, and stays hidden
970// until /api/connected answers (no flash of a not-yet-usable app).
971export function showApps() {
972 const backdrop = verbs.modal();
973 const card = backdrop.querySelector(".sheet");
974 const head = document.createElement("div");
975 head.className = "sheet-head";
976 const h = document.createElement("h2");
977 h.textContent = "Apps";
978 head.appendChild(h);
979 card.appendChild(head);
980 const list = document.createElement("div");
981 list.className = "pick-list";
982 card.appendChild(list);
983 let connectedServices = null; // null = unknown yet; a Set once loaded
984 const paint = () => {
985 list.innerHTML = "";
986 for (const app of pickerApps()) {
987 if (app.requiresService && !(connectedServices && connectedServices.has(app.requiresService)))
988 continue;
989 const b = document.createElement("button");
990 b.className = "list-row";
991 const g = document.createElement("span");
992 g.className = "avatar app-avatar";
993 appGlyphInto(g, app);
994 const nm = document.createElement("span");
995 nm.textContent = app.title || app.name;
996 b.append(g, nm);
997 b.onclick = async () => {
998 backdrop.remove();
999 const id = await verbs.resolveAppThread(app.name);
1000 if (id) verbs.openThread(id);
1001 };
1002 list.appendChild(b);
1003 }
1004 };
1005 fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
1006 .then((r) => (r.ok ? r.json() : { services: [] }))
1007 .then((j) => {
1008 connectedServices = new Set(j.services || []);
1009 paint();
1010 })
1011 .catch(() => {});
1012 paint();
1013}
1014
1015// createThread generates a fresh key, wraps it to every member (me + picked),
1016// and posts the thread (DM, or group with an encrypted name). Returns the id.
1017export async function createThread(otherIds, opts = {}) {
1018 const group = !!opts.group;
1019 const { key, b64 } = await generateKey();
1020 const memberIds = [myUserId, ...otherIds];
1021 const members = [];
1022 for (const uid of memberIds) {
1023 const e = roster.find((x) => x.user_id === uid);
1024 members.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
1025 }
1026 const body = { token: session.token, kind: group ? "group" : "dm", members };
1027 // Only store a name when one was actually given — a nameless group falls back
1028 // to its member names for display (see displayName), rather than "Group".
1029 if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name });
1030 // An open community group also ships its key encrypted under the instance
1031 // key — or under an access level's key when gated (opts.openTier), so only
1032 // that level can discover and join. The server can open neither blob.
1033 // Picked members still get their normal wrapped copy above.
1034 if (group && opts.open) {
1035 const src = opts.openTier ? tierKeys[opts.openTier] : instanceKey;
1036 if (!src) throw new Error("no wrapping key yet");
1037 body.visibility = "open";
1038 body.open_key = await encrypt(src, { k: b64 });
1039 if (opts.openTier) body.open_tier = opts.openTier;
1040 }
1041 const r = await (
1042 await fetch("/api/threads", {
1043 method: "POST",
1044 headers: { "Content-Type": "application/json" },
1045 body: JSON.stringify(body),
1046 })
1047 ).json();
1048 // Surface the server's refusal reason ("dms closed" — the person's DM door,
1049 // issue #578) so callers can answer with honest copy, not a generic failure.
1050 if (!r.thread_id) throw new Error(r.error || "no thread id");
1051 // The server dedupes DMs: if one already exists it returns it with existing:true.
1052 // Keep that thread's real key — don't overwrite it with the throwaway key we
1053 // generated for the would-be new thread (which no member could decrypt).
1054 if (!r.existing) {
1055 threadKeys[r.thread_id] = key;
1056 threadKeyB64[r.thread_id] = b64;
1057 }
1058 // An open, ungated group can be marked default (new members auto-join) at
1059 // creation — a separate admin-gated call after the thread exists. Ungated
1060 // only: the server refuses default on a level-gated group.
1061 if (opts.default && opts.open && !opts.openTier && !r.existing) {
1062 await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/default`, {
1063 method: "POST",
1064 headers: { "Content-Type": "application/json" },
1065 body: JSON.stringify({ token: session.token, default: true }),
1066 }).catch(() => {});
1067 }
1068 return r.thread_id;
1069}
1070