eleven / ocho Public

web: extract the new-chat surface into newchat.js (#967)

The roster picker, membership view, Discover, apps catalogue, and
createThread (~975 lines) move verbatim through the verbs seam. The
five const-arrow billing verbs get a second registry assign after
their definitions (no hoisting); the hoisted invite/app-thread helpers
join the boot assign. Audit clean; bare-ref sweep clean (embed already
imported from shellstate).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1d4a2c1b10e75353ec61ff7cd2e3aeb74a389586 parent b4b1ba7
3 files changed, +993 −977
  • CLAUDE.md +2 −0
  • web/static/newchat.js +985 −0
  • web/static/shell.js +6 −977
diff --git a/CLAUDE.md b/CLAUDE.md
index ee4fedd..e358459 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -199,6 +199,8 @@ Frontend module layout:
- `rosterui.js` — the member-display vocabulary (labels, presence lines,
picker rows). `groupactions.js` — the thread kebab's whole surface (edit
group, members, profiles, mute/pin, leave/close), through the verbs seam.
+- `newchat.js` — the roster picker, membership view, Discover, apps
+ catalogue, and thread creation, through the verbs seam.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/newchat.js b/web/static/newchat.js
new file mode 100644
index 0000000..c834857
--- /dev/null
+++ b/web/static/newchat.js
@@ -0,0 +1,985 @@
+// The new-chat surface — the roster picker (DMs + groups), membership view,
+// Discover, and the apps catalogue, plus thread creation itself. Extracted
+// verbatim from shell.js; behaviours ride the verbs seam, state shellstate.
+import { verbs } from "/static/shellverbs.js";
+import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, threadKeyB64, hidden, dmClosed, embed, setActiveId } from "/static/shellstate.js";
+import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js";
+import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js";
+import { appGlyphInto, personalApps } from "/static/apps.js";
+import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
+
+// ---- new chat (roster picker) -------------------------------------------
+
+// The existing DM thread with a person, if any. There's exactly one DM per pair
+// (the server enforces it too), so starting a chat with someone you already DM
+// should just reopen it — never spawn a second.
+export function existingDM(userId) {
+ return threads.find((t) => t.kind === "dm" && (t.members || []).includes(userId));
+}
+
+// Press Enter in a sheet field to fire its primary action (create / open the top
+// match). Ignores Shift+Enter and IME composition.
+function submitOnEnter(input, action) {
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
+ e.preventDefault();
+ action();
+ }
+ });
+}
+
+// startMode ("group" | "invite") opens the sheet straight in that view — the ⌘K
+// palette's "New group" / "Invite someone" land there directly. Anything else
+// (including the click event the + button passes) starts at "people".
+export function showNewChat(startMode) {
+ // Only joined connections can be picked for a new DM/group. Keyless entries are
+ // pending invitees who haven't joined yet — they already have their own chat.
+ const others = roster.filter((e) => e.user_id !== myUserId && e.public_key);
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+
+ // Run a createThread promise, then close the sheet and open the new thread.
+ // A failure with one of OUR reasons (a friendly-flagged error — see
+ // friendlyError) renders it inline in the sheet, occupying layout space
+ // (#786) — the button's generic "try again" hid genuinely different
+ // problems ("still setting up" vs a network blip) behind one label.
+ const go = async (promise, btn) => {
+ btn.disabled = true;
+ const stale = card.querySelector(".sheet-error");
+ if (stale) stale.remove();
+ try {
+ const id = await promise;
+ backdrop.remove();
+ await verbs.loadThreads();
+ verbs.renderThreadList();
+ verbs.openThread(id);
+ } catch (e) {
+ console.error(e);
+ btn.disabled = false;
+ if (e && e.friendly && e.message) {
+ const note = document.createElement("p");
+ note.className = "sheet-error";
+ note.setAttribute("role", "alert");
+ note.textContent = e.message;
+ btn.closest(".sheet-foot").before(note);
+ btn.textContent = "Try again";
+ } else {
+ btn.textContent = "Couldn't start — try again";
+ }
+ }
+ };
+
+ // Apps (To-do, Reminders, …) share the ONE scrollable list with people and
+ // helpers, as their own "Apps" group. They aren't roster members you DM,
+ // though: tapping one opens (or reopens) its personal app-thread via
+ // resolveAppThread, never createThread. matchingApps filters the registry's
+ // scope:"personal" set by the same query as the people search.
+ //
+ // An app may gate on a connected service (`requiresService`): DNSimple's app
+ // only "installs" once you've linked DNSimple. We learn the linked set from
+ // /api/connected; until it arrives (or if the fetch fails) gated apps stay
+ // hidden — no flash of a not-yet-usable app — then we re-render.
+ let connectedServices = null; // null = unknown yet; a Set once loaded
+ fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
+ .then((r) => (r.ok ? r.json() : { services: [] }))
+ .then((j) => {
+ connectedServices = new Set(j.services || []);
+ render(currentMode);
+ })
+ .catch(() => (connectedServices = new Set()));
+ // The apps gate empties this (both the picker's Apps section and the
+ // no-connections fallback list go through here); existing app THREADS still
+ // open from the sidebar — only the entry point for new ones hides.
+ const matchingApps = (q) =>
+ !ENABLE_APPS
+ ? []
+ : personalApps().filter(
+ (a) =>
+ (a.title || a.name).toLowerCase().includes(q) &&
+ (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))),
+ );
+ // The Admin app is system-scope (deliberately absent from personalApps), but
+ // admins get its row in the Apps section all the same — the discoverable way
+ // in, beyond the /admin command. Null for everyone else. Deliberately exempt
+ // from the apps gate: Admin is how an admin switches apps back on.
+ const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️", logo: ICON_WRENCH } : null;
+ const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q);
+ const addAppRow = (listEl, app) => {
+ const b = document.createElement("button");
+ b.className = "list-row";
+ const g = document.createElement("span");
+ g.className = "avatar app-avatar";
+ appGlyphInto(g, app);
+ const nm = document.createElement("span");
+ nm.textContent = app.title || app.name;
+ b.append(g, nm);
+ b.onclick = async () => {
+ backdrop.remove();
+ const id = await verbs.resolveAppThread(app.name);
+ if (id) verbs.openThread(id);
+ };
+ listEl.appendChild(b);
+ };
+
+ // "people": tap a name → DM. "group": multi-select + name → group.
+ let currentMode = "people"; // tracked so the async connected-fetch can re-render in place
+ const render = (mode) => {
+ currentMode = mode;
+ card.innerHTML = "";
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = mode === "group" ? "New group" : mode === "invite" ? "Invite someone" : "New chat";
+ head.appendChild(h);
+ // Secondary actions for the default "people" view: switch to a group, and — when
+ // the admin allows it — invite someone new to the server.
+ if (mode === "people") {
+ if (others.length) {
+ const toggle = document.createElement("button");
+ toggle.className = "linkish";
+ toggle.textContent = "New group";
+ toggle.onclick = () => render("group");
+ head.appendChild(toggle);
+ }
+const disc = document.createElement("button");
+ disc.className = "linkish";
+ disc.textContent = "Discover";
+ disc.onclick = () => {
+ backdrop.remove();
+ showDiscover();
+ };
+ head.appendChild(disc);
+ if (verbs.inviteAllowed()) {
+ const inv = document.createElement("button");
+ inv.className = "linkish";
+ inv.textContent = "Invite someone";
+ inv.onclick = () => render("invite");
+ head.appendChild(inv);
+ }
+ }
+ card.appendChild(head);
+
+ // Invite a brand-new person: name them, mint a link, open the pending DM.
+ if (mode === "invite") {
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.style.padding = "0 4px 6px";
+ note.textContent =
+ "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.";
+ card.appendChild(note);
+ const who = document.createElement("input");
+ who.placeholder = "Their name (e.g. Grandma)";
+ who.maxLength = 60;
+ who.className = "sheet-input";
+ card.appendChild(who);
+ // Cohort invites, right where people actually invite. The select lives
+ // in the DOM from the start (keeping its place between the name field
+ // and the buttons) but populates from a FRESH fetch — the module `tiers`
+ // cache can lag a level just created in the Admin app, or another
+ // member's change this session. Hidden until it has an option.
+ const inviteTierSel = document.createElement("select");
+ inviteTierSel.className = "sheet-input";
+ inviteTierSel.setAttribute("aria-label", "Access level for this invite");
+ inviteTierSel.style.display = "none";
+ card.appendChild(inviteTierSel);
+ verbs.loadTiers().then(() => {
+ const meEntry = roster.find((e) => e.user_id === myUserId);
+ const isAdmin = !!(meEntry && meEntry.role === "admin");
+ // The flat-group rule applied to levels: you can bring someone into a
+ // level you're IN (you hold its key already); admins mint for any
+ // level. The server enforces the same line.
+ const usableLevels = tiers.filter((t) => !t.key_lost && (isAdmin || t.mine));
+ if (!usableLevels.length) return;
+ const none = document.createElement("option");
+ none.value = "";
+ none.textContent = "No access level";
+ inviteTierSel.appendChild(none);
+ for (const t of usableLevels) {
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = `Add them to ${t.name}`;
+ inviteTierSel.appendChild(o);
+ }
+ inviteTierSel.style.display = "";
+ });
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => render("people");
+ const create = document.createElement("button");
+ create.textContent = "Create invite";
+ create.disabled = true;
+ who.addEventListener("input", () => (create.disabled = !who.value.trim()));
+ create.onclick = () =>
+ go(verbs.inviteSomeone(who.value.trim(), (inviteTierSel && inviteTierSel.value) || ""), create);
+ submitOnEnter(who, () => create.click());
+ foot.appendChild(cancel);
+ foot.appendChild(create);
+ card.appendChild(foot);
+ who.focus();
+ return;
+ }
+
+ if (!others.length) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ if (verbs.inviteAllowed()) {
+ p.textContent = "No connections yet — invite someone to start chatting.";
+ card.appendChild(p);
+ const btn = document.createElement("button");
+ btn.textContent = "Invite someone";
+ btn.onclick = () => render("invite");
+ card.appendChild(btn);
+ } else {
+ p.textContent = "No one else to chat with yet. Ask the admin to invite people.";
+ card.appendChild(p);
+ }
+ const apps = matchingApps("");
+ if (apps.length || adminApp) {
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ list.appendChild(pickerSection("Apps"));
+ for (const app of apps) addAppRow(list, app);
+ if (adminApp) addAppRow(list, adminApp);
+ card.appendChild(list);
+ }
+ return;
+ }
+
+ let nameInput, openCb, tierSel, defaultCb;
+ if (mode === "group") {
+ nameInput = document.createElement("input");
+ nameInput.placeholder = "Group name (optional)";
+ nameInput.maxLength = 60;
+ nameInput.className = "sheet-input";
+ card.appendChild(nameInput);
+ // Open community group: any member can find it in Discover and join
+ // without an invite. Needs the instance key (it carries the group key to
+ // future joiners) — until that arrives the choice simply isn't offered,
+ // matching how photos wait for the same key.
+ if (instanceKey) {
+ const row = document.createElement("label");
+ row.className = "opt-row";
+ openCb = document.createElement("input");
+ openCb.type = "checkbox";
+ if (startMode === "open-group") openCb.checked = true;
+ const txt = document.createElement("span");
+ txt.textContent = "Open group";
+ const desc = document.createElement("span");
+ desc.className = "opt-desc";
+ desc.textContent = "Anyone in this space can find it under Discover and join.";
+ txt.appendChild(desc);
+ row.appendChild(openCb);
+ row.appendChild(txt);
+ card.appendChild(row);
+ // Gate the group to an access level — offered only for levels whose
+ // key this device holds (the open_key is wrapped under it).
+ const myLevels = tiers.filter((t) => t.mine && tierKeyB64[t.id]);
+ if (myLevels.length) {
+ tierSel = document.createElement("select");
+ tierSel.className = "sheet-input";
+ tierSel.setAttribute("aria-label", "Who can find and join");
+ const all = document.createElement("option");
+ all.value = "";
+ all.textContent = "Everyone in this space";
+ tierSel.appendChild(all);
+ for (const t of myLevels) {
+ const o = document.createElement("option");
+ o.value = t.id;
+ o.textContent = `Only ${t.name}`;
+ tierSel.appendChild(o);
+ }
+ tierSel.style.display = openCb.checked ? "" : "none";
+ openCb.addEventListener("change", () => {
+ tierSel.style.display = openCb.checked ? "" : "none";
+ });
+ card.appendChild(tierSel);
+ }
+ // Admins can make an open group one that new members auto-join
+ // (a #general). Admin-only, and only while open + ungated (a gated
+ // group can't be default — the server refuses; a newcomer holds no
+ // level yet). Mirrors the Edit-group toggle.
+ const meEntry = roster.find((e) => e.user_id === myUserId);
+ if (meEntry && meEntry.role === "admin") {
+ const drow = document.createElement("label");
+ drow.className = "opt-row";
+ defaultCb = document.createElement("input");
+ defaultCb.type = "checkbox";
+ const dtxt = document.createElement("span");
+ dtxt.textContent = "New members join automatically";
+ const ddesc = document.createElement("span");
+ ddesc.className = "opt-desc";
+ ddesc.textContent = "Everyone who joins the space starts in this group.";
+ dtxt.appendChild(ddesc);
+ drow.appendChild(defaultCb);
+ drow.appendChild(dtxt);
+ card.appendChild(drow);
+ const syncDefault = () => {
+ const gated = tierSel && tierSel.value;
+ const on = openCb.checked && !gated;
+ drow.style.display = on ? "" : "none";
+ if (!on) defaultCb.checked = false;
+ };
+ syncDefault();
+ openCb.addEventListener("change", syncDefault);
+ if (tierSel) tierSel.addEventListener("change", syncDefault);
+ }
+ }
+ }
+
+ const filter = document.createElement("input");
+ filter.placeholder = "Search";
+ filter.className = "sheet-input";
+ card.appendChild(filter);
+
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+
+ const selected = new Set();
+ const create = document.createElement("button");
+ // An open group may start with just its creator (others join via
+ // Discover); a closed group still needs at least one other member.
+ const updateCreate = () => {
+ create.disabled = selected.size === 0 && !(openCb && openCb.checked);
+ };
+ if (openCb) openCb.onchange = updateCreate;
+
+ const renderList = () => {
+ const q = filter.value.trim().toLowerCase();
+ list.innerHTML = "";
+ const addRow = (e) => {
+ if (mode === "group") {
+ const row = document.createElement("label");
+ row.className = "list-row";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = selected.has(e.user_id);
+ cb.onchange = () => {
+ cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
+ updateCreate();
+ };
+ row.appendChild(cb);
+ row.appendChild(pickerName(e));
+ list.appendChild(row);
+ } else {
+ const b = document.createElement("button");
+ b.className = "list-row";
+ b.appendChild(pickerName(e));
+ b.onclick = () => {
+ const dm = existingDM(e.user_id);
+ if (dm) {
+ backdrop.remove();
+ verbs.openThread(dm.id); // already chatting — just reopen it
+ return;
+ }
+ go(createThread([e.user_id], { group: false }), b);
+ };
+ list.appendChild(b);
+ }
+ };
+ // Three groups in the one scrollable list: people, then bots/AI under
+ // "Helpers", then personal apps under "Apps" (people picker only — a group's
+ // member list can't include an app). People get a heading only when another
+ // group sits alongside them (a lone people list needs no label); helpers and
+ // apps always get theirs, since each is deliberately "not a person".
+ const people = others.filter((e) => !e.is_agent && rosterMatches(e, q));
+ // Bots off empties the Helpers section — existing bot DMs stay in the
+ // sidebar; only starting new ones hides.
+ const helpers = ENABLE_BOTS ? others.filter((e) => e.is_agent && rosterMatches(e, q)) : [];
+ const apps = mode === "people" ? matchingApps(q) : [];
+ const showAdmin = mode === "people" && adminMatches(q);
+ if (!people.length && !helpers.length && !apps.length && !showAdmin) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.padding = "12px 28px";
+ p.textContent = "No matches.";
+ list.appendChild(p);
+ return;
+ }
+ if (people.length && (helpers.length || apps.length || showAdmin)) list.appendChild(pickerSection("People"));
+ for (const e of people) addRow(e);
+ if (helpers.length) list.appendChild(pickerSection("Helpers"));
+ for (const e of helpers) addRow(e);
+ if (apps.length || showAdmin) list.appendChild(pickerSection("Apps"));
+ for (const app of apps) addAppRow(list, app);
+ if (showAdmin) addAppRow(list, adminApp);
+ };
+ filter.addEventListener("input", renderList);
+ renderList();
+
+ // Enter submits: in a group, create it; in the picker, open the top match.
+ submitOnEnter(filter, () => {
+ if (mode === "group") return create.click();
+ const first = list.querySelector("button.list-row");
+ if (first) first.click();
+ });
+ if (mode === "group") submitOnEnter(nameInput, () => create.click());
+
+ if (mode === "group") {
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => render("people");
+ create.textContent = "Create group";
+ updateCreate(); // pre-ticked Open (Discover's empty state) starts enabled
+ create.onclick = () =>
+ go(
+ createThread([...selected], {
+ group: true,
+ name: nameInput.value.trim(),
+ open: !!(openCb && openCb.checked),
+ openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "",
+ default: !!(defaultCb && defaultCb.checked),
+ }),
+ create,
+ );
+ foot.appendChild(cancel);
+ foot.appendChild(create);
+ card.appendChild(foot);
+ }
+ };
+ // Honour a requested start view only when it's usable, else fall back.
+ let start = "people";
+ if (startMode === "group" && others.length) start = "group";
+ // "open-group" (Discover's empty state) works even with nobody else joined
+ // yet — an open group may start with just its creator.
+ if (startMode === "open-group") start = "group";
+ if (startMode === "invite" && verbs.inviteAllowed()) start = "invite";
+ render(start);
+}
+
+// Discover (⌘K → "Discover groups", or the new-chat sheet): the community
+// directory of open groups — join any of them on the spot. Each row's name
+// decrypts via its open_key + the instance key, so the whole directory is
+// readable by any member and by nobody else (the server sees only blobs).
+// Membership: the member's paid-access-levels surface. Their current
+// subscription (with the dated "renews / ends on" line — the reassuring
+// heart), plus any levels they can subscribe to. Reachable from Edit profile
+// and ⌘K when the space sells any level or the member already subscribes.
+// Anti-dark-pattern: cancel is a plain, equal-weight action and always states
+// the exact keep-until date; price + interval are shown before any commitment.
+export async function showMembership() {
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = "Membership";
+ head.appendChild(h);
+ card.appendChild(head);
+
+ const status = statusLineEl();
+ card.appendChild(status);
+
+ const paint = async () => {
+ await verbs.loadTiers();
+ // Clear everything below the header + status line.
+ while (status.nextSibling) status.nextSibling.remove();
+
+ const sellable = tiers.filter((t) => t.price_cents > 0);
+ const subscribed = tiers.filter((t) => t.status); // active | past_due | canceled
+ if (!sellable.length && !subscribed.length) {
+ const p = document.createElement("p");
+ p.className = "membership-note";
+ p.textContent = "This space doesn't offer paid access levels.";
+ card.appendChild(p);
+ return;
+ }
+
+ const note = document.createElement("p");
+ note.className = "membership-note";
+ note.textContent = "Subscriptions are handled securely by Stripe. You can change or cancel anytime.";
+ card.appendChild(note);
+
+ const busy = (btn, fn) => async () => {
+ btn.disabled = true;
+ const prev = btn.textContent;
+ btn.textContent = "…";
+ const res = await fn();
+ if (res && res.url) {
+ location.href = res.url; // hosted checkout / billing portal
+ return;
+ }
+ btn.disabled = false;
+ btn.textContent = prev;
+ if (res) {
+ flash(status, "Done.");
+ await paint();
+ } else {
+ flash(status, "Something went wrong — try again.");
+ }
+ };
+
+ // ---- Your subscription (current state) --------------------------------
+ for (const t of subscribed) {
+ const box = document.createElement("div");
+ box.className = "plan-card current";
+ const hd = document.createElement("div");
+ hd.className = "plan-head";
+ const nm = document.createElement("span");
+ nm.className = "plan-name";
+ nm.textContent = t.name;
+ hd.appendChild(nm);
+ const pr = verbs.levelPrice(t);
+ if (pr) {
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = pr.text;
+ const per = document.createElement("span");
+ per.className = "per";
+ per.textContent = " / " + pr.per;
+ price.appendChild(per);
+ hd.appendChild(price);
+ }
+ box.appendChild(hd);
+
+ // The dated status line — icon (shape) + words, never colour alone.
+ const st = document.createElement("div");
+ st.className = "plan-state";
+ const dot = document.createElement("span");
+ dot.className = "dot";
+ st.appendChild(dot);
+ const txt = document.createElement("span");
+ if (t.status === "active") {
+ txt.textContent = t.paid_until ? `Renews on ${verbs.billingDate(t.paid_until)}` : "Active";
+ } else if (t.status === "past_due") {
+ st.classList.add("warn");
+ dot.textContent = "";
+ const warn = document.createElement("span");
+ warn.setAttribute("aria-hidden", "true");
+ warn.textContent = "⚠ ";
+ st.replaceChild(warn, dot);
+ txt.textContent = `Payment didn't go through — access ends ${verbs.billingDate(t.grace_deadline)} unless it's fixed.`;
+ } else {
+ // canceled / ending
+ txt.textContent = `Your access ends on ${verbs.billingDate(t.grace_deadline || t.paid_until)}.`;
+ }
+ st.appendChild(txt);
+ box.appendChild(st);
+
+ const acts = document.createElement("div");
+ acts.className = "plan-actions";
+ if (t.status === "active") {
+ // Change level (to another sellable level), Cancel, Manage billing.
+ const others = sellable.filter((x) => x.id !== t.id);
+ if (others.length) {
+ const chg = pillBtn("Change level", true);
+ chg.onclick = () => showChangeLevel(t, others, paint, status);
+ acts.appendChild(chg);
+ }
+ const cancel = pillBtn("Cancel", true);
+ cancel.onclick = () => confirmCancel(t, paint, status);
+ acts.appendChild(cancel);
+ } else if (t.status === "past_due") {
+ const fix = pillBtn("Update payment", false);
+ fix.onclick = busy(fix, () => verbs.levelPortal(t.id));
+ acts.appendChild(fix);
+ } else {
+ // ending — offer to resume (un-cancels the same subscription, no new charge).
+ const resume = pillBtn("Resume", false);
+ resume.onclick = busy(resume, () => verbs.resumeLevelSub(t.id));
+ acts.appendChild(resume);
+ }
+ const manage = pillBtn("Manage billing", true);
+ manage.onclick = busy(manage, () => verbs.levelPortal(t.id));
+ acts.appendChild(manage);
+ box.appendChild(acts);
+ card.appendChild(box);
+ }
+
+ // ---- Levels you can subscribe to --------------------------------------
+ const canSubscribe = sellable.filter((t) => !t.status);
+ if (canSubscribe.length) {
+ if (subscribed.length) {
+ const sec = document.createElement("div");
+ sec.className = "membership-sec";
+ sec.textContent = "Other access levels";
+ card.appendChild(sec);
+ }
+ for (const t of canSubscribe) {
+ const box = document.createElement("div");
+ box.className = "plan-card";
+ const hd = document.createElement("div");
+ hd.className = "plan-head";
+ const nm = document.createElement("span");
+ nm.className = "plan-name";
+ nm.textContent = t.name;
+ hd.appendChild(nm);
+ const pr = verbs.levelPrice(t);
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = pr.text;
+ const per = document.createElement("span");
+ per.className = "per";
+ per.textContent = " / " + pr.per;
+ price.appendChild(per);
+ hd.appendChild(price);
+ box.appendChild(hd);
+ const acts = document.createElement("div");
+ acts.className = "plan-actions";
+ const sub = pillBtn("Subscribe", false);
+ sub.onclick = busy(sub, () => verbs.subscribeToLevel(t.id));
+ acts.appendChild(sub);
+ box.appendChild(acts);
+ card.appendChild(box);
+ }
+ }
+ };
+ await paint();
+}
+
+// A quiet pill button; loud=accent (the one prominent action), else the quiet
+// variant. Reuses the .pill-btn vocabulary from Discover.
+function pillBtn(label, quiet) {
+ const b = document.createElement("button");
+ b.className = "pill-btn" + (quiet ? " quiet" : "");
+ b.textContent = label;
+ return b;
+}
+
+// A small inline status line (reused across the membership flows).
+function statusLineEl() {
+ const p = document.createElement("div");
+ p.className = "admin-status";
+ p.setAttribute("role", "status");
+ p.style.cssText = "min-height:18px;font-size:13px;color:var(--muted)";
+ return p;
+}
+
+// Show a transient message in an inline status element (never a floating toast).
+export function flash(el, msg) {
+ if (!el) return;
+ el.textContent = msg;
+ setTimeout(() => {
+ if (el.textContent === msg) el.textContent = "";
+ }, 2500);
+}
+
+// Cancel confirmation as a DATED PROMISE, not a threat: it names the exact day
+// access continues to, so cancelling never feels like losing everything now.
+function confirmCancel(t, repaint, status) {
+ const until = verbs.billingDate(t.paid_until);
+ const msg = until
+ ? `You'll keep ${t.name} until ${until}. Cancel your subscription?`
+ : `Cancel your ${t.name} subscription?`;
+ if (!confirm(msg)) return;
+ flash(status, "Cancelling…");
+ verbs.cancelLevelSub(t.id).then((ok) => {
+ flash(status, ok ? "Cancelled — you keep access until it ends." : "Couldn't cancel — try again.");
+ repaint();
+ });
+}
+
+// Change level: pick another sellable level; switches the subscription in place
+// (Stripe prorates the difference).
+function showChangeLevel(from, others, repaint, status) {
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Change level";
+ card.appendChild(h);
+ const note = document.createElement("p");
+ note.className = "membership-note";
+ note.textContent = `You're on ${from.name}. Switching adjusts your next bill for the difference.`;
+ card.appendChild(note);
+ for (const t of others) {
+ const row = document.createElement("button");
+ row.className = "list-row";
+ const nm = document.createElement("span");
+ nm.style.flex = "1";
+ nm.textContent = t.name;
+ const pr = verbs.levelPrice(t);
+ const price = document.createElement("span");
+ price.className = "plan-price";
+ price.textContent = `${pr.text} / ${pr.per}`;
+ row.append(nm, price);
+ row.onclick = () => {
+ if (!confirm(`Switch to ${t.name} (${pr.text} / ${pr.per})?`)) return;
+ backdrop.remove();
+ flash(status, "Switching…");
+ verbs.changeLevelSub(from.id, t.id).then((ok) => {
+ flash(status, ok ? "Switched." : "Couldn't switch — try again.");
+ repaint();
+ });
+ };
+ card.appendChild(row);
+ }
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Back";
+ cancel.onclick = () => backdrop.remove();
+ foot.appendChild(cancel);
+ card.appendChild(foot);
+}
+
+export async function showDiscover() {
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = "Discover groups";
+ head.appendChild(h);
+ card.appendChild(head);
+ const note = document.createElement("p");
+ note.className = "sub";
+ note.style.padding = "0 4px 6px";
+ note.textContent = "Groups anyone in this space can join — no invite needed.";
+ card.appendChild(note);
+
+ let rows = [];
+ try {
+ const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json();
+ rows = r.threads || [];
+ } catch (e) {
+ console.error(e);
+ note.textContent = "Couldn't load open groups — try again.";
+ return;
+ }
+ if (!rows.length) {
+ // An empty directory is an invitation: hand over the one action that
+ // fills it (the button lands in the new-group sheet with Open pre-ticked).
+ note.textContent = "No open groups here yet. Start one and it shows up for everyone in this space.";
+ const start = document.createElement("button");
+ start.textContent = "Start an open group";
+ start.onclick = () => {
+ backdrop.remove();
+ showNewChat("open-group");
+ };
+ card.appendChild(start);
+ return;
+ }
+ if (!instanceKey) {
+ // Same key-pending state as photos: the instance key hasn't reached this
+ // device yet, so the directory can't be decrypted (or joined usefully).
+ note.textContent = "Waiting for your key to arrive — try again in a moment.";
+ return;
+ }
+
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+ await verbs.loadTiers(); // gate keys + level names for the rows below
+ for (const row of rows) {
+ // Row states: joined always wins (membership IS access — Open must work
+ // even if we've since left the gate level); then locked to another level
+ // (an honest lock — the name STAYS sealed, that's the feature); then
+ // ours-but-the-key-hasn't-arrived.
+ const joined = !!row.joined;
+ const locked = !joined && row.open_tier && !row.eligible;
+ const keyPending = !joined && !locked && row.open_tier && !tierKeys[row.open_tier];
+ let name = row.tier_name ? `A group for ${row.tier_name}` : "Open group";
+ if (!locked && !keyPending) {
+ const src = row.open_tier ? tierKeys[row.open_tier] : instanceKey;
+ if (src) {
+ try {
+ const key = await importKey((await decrypt(src, row.open_key)).k);
+ if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name;
+ } catch (e) {
+ console.error("discover row", row.id, e);
+ if (!joined) continue; // undecryptable AND unjoinable — don't offer it
+ }
+ }
+ }
+ const item = document.createElement("div");
+ item.className = "list-row static";
+ item.appendChild(avatarEl(name));
+ const info = document.createElement("div");
+ info.className = "discover-main";
+ const title = document.createElement("div");
+ title.textContent = name;
+ const sub = document.createElement("span");
+ sub.className = "helper-desc";
+ const count = row.member_count === 1 ? "1 member" : `${row.member_count} members`;
+ if (joined && row.open_tier && !row.eligible) {
+ sub.textContent = `${count} · you're in, but no longer in ${row.tier_name || "its level"}`;
+ } else if (locked) {
+ sub.textContent = row.tier_name ? `${count} · for ${row.tier_name} only` : `${count} · limited access`;
+ } else if (keyPending) {
+ sub.textContent = `${count} · your key is on its way`;
+ } else {
+ sub.textContent = row.default_join ? `${count} · everyone starts here` : count;
+ }
+ info.appendChild(title);
+ info.appendChild(sub);
+ item.appendChild(info);
+ const btn = document.createElement("button");
+ btn.className = "pill-btn";
+ if (!joined && (locked || keyPending)) {
+ // No dead Join buttons: the state is the row's text (never color
+ // alone); a lock is information, not an error.
+ btn.classList.add("quiet");
+ btn.disabled = true;
+ btn.textContent = locked ? "Locked" : "Waiting";
+ item.appendChild(btn);
+ list.appendChild(item);
+ continue;
+ }
+ if (row.joined) {
+ // Already yours — the quiet variant keeps Join the sheet's one loud act.
+ btn.classList.add("quiet");
+ btn.textContent = "Open";
+ btn.onclick = () => {
+ backdrop.remove();
+ verbs.openThread(row.id);
+ };
+ } else {
+ btn.textContent = "Join";
+ btn.onclick = async () => {
+ btn.disabled = true;
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(row.id)}/join`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ if (!r.ok) throw new Error("join failed");
+ await verbs.loadThreads();
+ verbs.renderThreadList();
+ // Tell the room — the same action-line shape announceJoin sends,
+ // but in the joiner's own voice ("Paul joined", not "… added Paul").
+ // The join field nudges other clients to refresh their member list.
+ const key = threadKeys[row.id];
+ if (key && ws && ws.readyState === WebSocket.OPEN) {
+ const me = session.name || "New member";
+ const payload = { name: me, text: `${me} joined`, join: myUserId ? [myUserId] : [], ts: Date.now(), action: true };
+ if (myUserId) payload.uid = myUserId;
+ ws.send(JSON.stringify({ type: "msg", thread_id: row.id, blob: await encrypt(key, payload) }));
+ }
+ // The sheet stays up until we're actually in: a failure above lands
+ // in the catch with the retry button still visible (re-running the
+ // join is safe — the server treats it as idempotent).
+ backdrop.remove();
+ verbs.openThread(row.id);
+ } catch (e) {
+ console.error(e);
+ btn.disabled = false;
+ btn.textContent = "Couldn't join — try again";
+ }
+ };
+ }
+ item.appendChild(btn);
+ list.appendChild(item);
+ }
+ if (!list.childElementCount) {
+ // Every row failed to decrypt (e.g. open_keys minted against a different
+ // instance key) — say so rather than presenting a silently empty sheet.
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.padding = "0 4px 6px";
+ p.textContent = "Couldn't read the group directory on this device — try again in a moment.";
+ card.appendChild(p);
+ }
+}
+
+// Apps directory (⌘K → "Open apps"): the personal apps as a plain pick list —
+// tap one to open (or create on first use) its app-thread. Same rows the
+// New-chat sheet shows under "Apps", including the connected-service gate: a
+// `requiresService` app only lists once its service is linked, and stays hidden
+// until /api/connected answers (no flash of a not-yet-usable app).
+export function showApps() {
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const head = document.createElement("div");
+ head.className = "sheet-head";
+ const h = document.createElement("h2");
+ h.textContent = "Apps";
+ head.appendChild(h);
+ card.appendChild(head);
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+ let connectedServices = null; // null = unknown yet; a Set once loaded
+ const paint = () => {
+ list.innerHTML = "";
+ for (const app of personalApps()) {
+ if (app.requiresService && !(connectedServices && connectedServices.has(app.requiresService)))
+ continue;
+ const b = document.createElement("button");
+ b.className = "list-row";
+ const g = document.createElement("span");
+ g.className = "avatar app-avatar";
+ appGlyphInto(g, app);
+ const nm = document.createElement("span");
+ nm.textContent = app.title || app.name;
+ b.append(g, nm);
+ b.onclick = async () => {
+ backdrop.remove();
+ const id = await verbs.resolveAppThread(app.name);
+ if (id) verbs.openThread(id);
+ };
+ list.appendChild(b);
+ }
+ };
+ fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
+ .then((r) => (r.ok ? r.json() : { services: [] }))
+ .then((j) => {
+ connectedServices = new Set(j.services || []);
+ paint();
+ })
+ .catch(() => {});
+ paint();
+}
+
+// createThread generates a fresh key, wraps it to every member (me + picked),
+// and posts the thread (DM, or group with an encrypted name). Returns the id.
+export async function createThread(otherIds, opts = {}) {
+ const group = !!opts.group;
+ const { key, b64 } = await generateKey();
+ const memberIds = [myUserId, ...otherIds];
+ const members = [];
+ for (const uid of memberIds) {
+ const e = roster.find((x) => x.user_id === uid);
+ members.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
+ }
+ const body = { token: session.token, kind: group ? "group" : "dm", members };
+ // Only store a name when one was actually given — a nameless group falls back
+ // to its member names for display (see displayName), rather than "Group".
+ if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name });
+ // An open community group also ships its key encrypted under the instance
+ // key — or under an access level's key when gated (opts.openTier), so only
+ // that level can discover and join. The server can open neither blob.
+ // Picked members still get their normal wrapped copy above.
+ if (group && opts.open) {
+ const src = opts.openTier ? tierKeys[opts.openTier] : instanceKey;
+ if (!src) throw new Error("no wrapping key yet");
+ body.visibility = "open";
+ body.open_key = await encrypt(src, { k: b64 });
+ if (opts.openTier) body.open_tier = opts.openTier;
+ }
+ const r = await (
+ await fetch("/api/threads", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ ).json();
+ // Surface the server's refusal reason ("dms closed" — the person's DM door,
+ // issue #578) so callers can answer with honest copy, not a generic failure.
+ if (!r.thread_id) throw new Error(r.error || "no thread id");
+ // The server dedupes DMs: if one already exists it returns it with existing:true.
+ // Keep that thread's real key — don't overwrite it with the throwaway key we
+ // generated for the would-be new thread (which no member could decrypt).
+ if (!r.existing) {
+ threadKeys[r.thread_id] = key;
+ threadKeyB64[r.thread_id] = b64;
+ }
+ // An open, ungated group can be marked default (new members auto-join) at
+ // creation — a separate admin-gated call after the thread exists. Ungated
+ // only: the server refuses default on a level-gated group.
+ if (opts.default && opts.open && !opts.openTier && !r.existing) {
+ await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/default`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, default: true }),
+ }).catch(() => {});
+ }
+ return r.thread_id;
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index f3f64c0..3fa20bc 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -137,6 +137,7 @@ import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendN
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
+import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread } from "/static/newchat.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
@@ -188,8 +189,9 @@ Object.assign(verbs, {
paintThreadHeader, modal, toast, displayName, threadAvatarFor,
announceJoin, announceLeave, announceRemove, sendChatText,
loadBlobBytes, chatAppFor, chatAppLoadOlder, stashAppPane,
- createThread, existingDM, resolveRename, showList, paintHeaderAvatar,
+ resolveRename, showList, paintHeaderAvatar,
loadTiers, setNickname, syncThreadKeysToSW,
+ inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed,
});
onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
@@ -1964,6 +1966,9 @@ const cancelLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}
const resumeLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/resume`, {});
const changeLevelSub = (fromId, toId) => billingPost(`/api/tiers/${encodeURIComponent(fromId)}/change`, { to_tier: toId });
const levelPortal = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/portal`, {});
+// The five const-arrow billing verbs can't join the boot-time assign above
+// (no hoisting); register them here, once they exist.
+Object.assign(verbs, { subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal });
// A calm, dated day like "5 February 2026" from unix seconds.
function billingDate(unixSecs) {
@@ -7133,982 +7138,6 @@ function connectWS() {
};
}
-// ---- new chat (roster picker) -------------------------------------------
-
-// The existing DM thread with a person, if any. There's exactly one DM per pair
-// (the server enforces it too), so starting a chat with someone you already DM
-// should just reopen it — never spawn a second.
-function existingDM(userId) {
- return threads.find((t) => t.kind === "dm" && (t.members || []).includes(userId));
-}
-
-// Press Enter in a sheet field to fire its primary action (create / open the top
-// match). Ignores Shift+Enter and IME composition.
-function submitOnEnter(input, action) {
- input.addEventListener("keydown", (e) => {
- if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
- e.preventDefault();
- action();
- }
- });
-}
-
-// startMode ("group" | "invite") opens the sheet straight in that view — the ⌘K
-// palette's "New group" / "Invite someone" land there directly. Anything else
-// (including the click event the + button passes) starts at "people".
-function showNewChat(startMode) {
- // Only joined connections can be picked for a new DM/group. Keyless entries are
- // pending invitees who haven't joined yet — they already have their own chat.
- const others = roster.filter((e) => e.user_id !== myUserId && e.public_key);
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
-
- // Run a createThread promise, then close the sheet and open the new thread.
- // A failure with one of OUR reasons (a friendly-flagged error — see
- // friendlyError) renders it inline in the sheet, occupying layout space
- // (#786) — the button's generic "try again" hid genuinely different
- // problems ("still setting up" vs a network blip) behind one label.
- const go = async (promise, btn) => {
- btn.disabled = true;
- const stale = card.querySelector(".sheet-error");
- if (stale) stale.remove();
- try {
- const id = await promise;
- backdrop.remove();
- await loadThreads();
- renderThreadList();
- openThread(id);
- } catch (e) {
- console.error(e);
- btn.disabled = false;
- if (e && e.friendly && e.message) {
- const note = document.createElement("p");
- note.className = "sheet-error";
- note.setAttribute("role", "alert");
- note.textContent = e.message;
- btn.closest(".sheet-foot").before(note);
- btn.textContent = "Try again";
- } else {
- btn.textContent = "Couldn't start — try again";
- }
- }
- };
-
- // Apps (To-do, Reminders, …) share the ONE scrollable list with people and
- // helpers, as their own "Apps" group. They aren't roster members you DM,
- // though: tapping one opens (or reopens) its personal app-thread via
- // resolveAppThread, never createThread. matchingApps filters the registry's
- // scope:"personal" set by the same query as the people search.
- //
- // An app may gate on a connected service (`requiresService`): DNSimple's app
- // only "installs" once you've linked DNSimple. We learn the linked set from
- // /api/connected; until it arrives (or if the fetch fails) gated apps stay
- // hidden — no flash of a not-yet-usable app — then we re-render.
- let connectedServices = null; // null = unknown yet; a Set once loaded
- fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
- .then((r) => (r.ok ? r.json() : { services: [] }))
- .then((j) => {
- connectedServices = new Set(j.services || []);
- render(currentMode);
- })
- .catch(() => (connectedServices = new Set()));
- // The apps gate empties this (both the picker's Apps section and the
- // no-connections fallback list go through here); existing app THREADS still
- // open from the sidebar — only the entry point for new ones hides.
- const matchingApps = (q) =>
- !ENABLE_APPS
- ? []
- : personalApps().filter(
- (a) =>
- (a.title || a.name).toLowerCase().includes(q) &&
- (!a.requiresService || (connectedServices && connectedServices.has(a.requiresService))),
- );
- // The Admin app is system-scope (deliberately absent from personalApps), but
- // admins get its row in the Apps section all the same — the discoverable way
- // in, beyond the /admin command. Null for everyone else. Deliberately exempt
- // from the apps gate: Admin is how an admin switches apps back on.
- const adminApp = isAdmin() ? { name: "admin", title: "Admin", glyph: "🛠️", logo: ICON_WRENCH } : null;
- const adminMatches = (q) => !!adminApp && adminApp.title.toLowerCase().includes(q);
- const addAppRow = (listEl, app) => {
- const b = document.createElement("button");
- b.className = "list-row";
- const g = document.createElement("span");
- g.className = "avatar app-avatar";
- appGlyphInto(g, app);
- const nm = document.createElement("span");
- nm.textContent = app.title || app.name;
- b.append(g, nm);
- b.onclick = async () => {
- backdrop.remove();
- const id = await resolveAppThread(app.name);
- if (id) openThread(id);
- };
- listEl.appendChild(b);
- };
-
- // "people": tap a name → DM. "group": multi-select + name → group.
- let currentMode = "people"; // tracked so the async connected-fetch can re-render in place
- const render = (mode) => {
- currentMode = mode;
- card.innerHTML = "";
- const head = document.createElement("div");
- head.className = "sheet-head";
- const h = document.createElement("h2");
- h.textContent = mode === "group" ? "New group" : mode === "invite" ? "Invite someone" : "New chat";
- head.appendChild(h);
- // Secondary actions for the default "people" view: switch to a group, and — when
- // the admin allows it — invite someone new to the server.
- if (mode === "people") {
- if (others.length) {
- const toggle = document.createElement("button");
- toggle.className = "linkish";
- toggle.textContent = "New group";
- toggle.onclick = () => render("group");
- head.appendChild(toggle);
- }
-const disc = document.createElement("button");
- disc.className = "linkish";
- disc.textContent = "Discover";
- disc.onclick = () => {
- backdrop.remove();
- showDiscover();
- };
- head.appendChild(disc);
- if (inviteAllowed()) {
- const inv = document.createElement("button");
- inv.className = "linkish";
- inv.textContent = "Invite someone";
- inv.onclick = () => render("invite");
- head.appendChild(inv);
- }
- }
- card.appendChild(head);
-
- // Invite a brand-new person: name them, mint a link, open the pending DM.
- if (mode === "invite") {
- const note = document.createElement("p");
- note.className = "sub";
- note.style.padding = "0 4px 6px";
- note.textContent =
- "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.";
- card.appendChild(note);
- const who = document.createElement("input");
- who.placeholder = "Their name (e.g. Grandma)";
- who.maxLength = 60;
- who.className = "sheet-input";
- card.appendChild(who);
- // Cohort invites, right where people actually invite. The select lives
- // in the DOM from the start (keeping its place between the name field
- // and the buttons) but populates from a FRESH fetch — the module `tiers`
- // cache can lag a level just created in the Admin app, or another
- // member's change this session. Hidden until it has an option.
- const inviteTierSel = document.createElement("select");
- inviteTierSel.className = "sheet-input";
- inviteTierSel.setAttribute("aria-label", "Access level for this invite");
- inviteTierSel.style.display = "none";
- card.appendChild(inviteTierSel);
- loadTiers().then(() => {
- const meEntry = roster.find((e) => e.user_id === myUserId);
- const isAdmin = !!(meEntry && meEntry.role === "admin");
- // The flat-group rule applied to levels: you can bring someone into a
- // level you're IN (you hold its key already); admins mint for any
- // level. The server enforces the same line.
- const usableLevels = tiers.filter((t) => !t.key_lost && (isAdmin || t.mine));
- if (!usableLevels.length) return;
- const none = document.createElement("option");
- none.value = "";
- none.textContent = "No access level";
- inviteTierSel.appendChild(none);
- for (const t of usableLevels) {
- const o = document.createElement("option");
- o.value = t.id;
- o.textContent = `Add them to ${t.name}`;
- inviteTierSel.appendChild(o);
- }
- inviteTierSel.style.display = "";
- });
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => render("people");
- const create = document.createElement("button");
- create.textContent = "Create invite";
- create.disabled = true;
- who.addEventListener("input", () => (create.disabled = !who.value.trim()));
- create.onclick = () =>
- go(inviteSomeone(who.value.trim(), (inviteTierSel && inviteTierSel.value) || ""), create);
- submitOnEnter(who, () => create.click());
- foot.appendChild(cancel);
- foot.appendChild(create);
- card.appendChild(foot);
- who.focus();
- return;
- }
-
- if (!others.length) {
- const p = document.createElement("p");
- p.className = "sub";
- if (inviteAllowed()) {
- p.textContent = "No connections yet — invite someone to start chatting.";
- card.appendChild(p);
- const btn = document.createElement("button");
- btn.textContent = "Invite someone";
- btn.onclick = () => render("invite");
- card.appendChild(btn);
- } else {
- p.textContent = "No one else to chat with yet. Ask the admin to invite people.";
- card.appendChild(p);
- }
- const apps = matchingApps("");
- if (apps.length || adminApp) {
- const list = document.createElement("div");
- list.className = "pick-list";
- list.appendChild(pickerSection("Apps"));
- for (const app of apps) addAppRow(list, app);
- if (adminApp) addAppRow(list, adminApp);
- card.appendChild(list);
- }
- return;
- }
-
- let nameInput, openCb, tierSel, defaultCb;
- if (mode === "group") {
- nameInput = document.createElement("input");
- nameInput.placeholder = "Group name (optional)";
- nameInput.maxLength = 60;
- nameInput.className = "sheet-input";
- card.appendChild(nameInput);
- // Open community group: any member can find it in Discover and join
- // without an invite. Needs the instance key (it carries the group key to
- // future joiners) — until that arrives the choice simply isn't offered,
- // matching how photos wait for the same key.
- if (instanceKey) {
- const row = document.createElement("label");
- row.className = "opt-row";
- openCb = document.createElement("input");
- openCb.type = "checkbox";
- if (startMode === "open-group") openCb.checked = true;
- const txt = document.createElement("span");
- txt.textContent = "Open group";
- const desc = document.createElement("span");
- desc.className = "opt-desc";
- desc.textContent = "Anyone in this space can find it under Discover and join.";
- txt.appendChild(desc);
- row.appendChild(openCb);
- row.appendChild(txt);
- card.appendChild(row);
- // Gate the group to an access level — offered only for levels whose
- // key this device holds (the open_key is wrapped under it).
- const myLevels = tiers.filter((t) => t.mine && tierKeyB64[t.id]);
- if (myLevels.length) {
- tierSel = document.createElement("select");
- tierSel.className = "sheet-input";
- tierSel.setAttribute("aria-label", "Who can find and join");
- const all = document.createElement("option");
- all.value = "";
- all.textContent = "Everyone in this space";
- tierSel.appendChild(all);
- for (const t of myLevels) {
- const o = document.createElement("option");
- o.value = t.id;
- o.textContent = `Only ${t.name}`;
- tierSel.appendChild(o);
- }
- tierSel.style.display = openCb.checked ? "" : "none";
- openCb.addEventListener("change", () => {
- tierSel.style.display = openCb.checked ? "" : "none";
- });
- card.appendChild(tierSel);
- }
- // Admins can make an open group one that new members auto-join
- // (a #general). Admin-only, and only while open + ungated (a gated
- // group can't be default — the server refuses; a newcomer holds no
- // level yet). Mirrors the Edit-group toggle.
- const meEntry = roster.find((e) => e.user_id === myUserId);
- if (meEntry && meEntry.role === "admin") {
- const drow = document.createElement("label");
- drow.className = "opt-row";
- defaultCb = document.createElement("input");
- defaultCb.type = "checkbox";
- const dtxt = document.createElement("span");
- dtxt.textContent = "New members join automatically";
- const ddesc = document.createElement("span");
- ddesc.className = "opt-desc";
- ddesc.textContent = "Everyone who joins the space starts in this group.";
- dtxt.appendChild(ddesc);
- drow.appendChild(defaultCb);
- drow.appendChild(dtxt);
- card.appendChild(drow);
- const syncDefault = () => {
- const gated = tierSel && tierSel.value;
- const on = openCb.checked && !gated;
- drow.style.display = on ? "" : "none";
- if (!on) defaultCb.checked = false;
- };
- syncDefault();
- openCb.addEventListener("change", syncDefault);
- if (tierSel) tierSel.addEventListener("change", syncDefault);
- }
- }
- }
-
- const filter = document.createElement("input");
- filter.placeholder = "Search";
- filter.className = "sheet-input";
- card.appendChild(filter);
-
- const list = document.createElement("div");
- list.className = "pick-list";
- card.appendChild(list);
-
- const selected = new Set();
- const create = document.createElement("button");
- // An open group may start with just its creator (others join via
- // Discover); a closed group still needs at least one other member.
- const updateCreate = () => {
- create.disabled = selected.size === 0 && !(openCb && openCb.checked);
- };
- if (openCb) openCb.onchange = updateCreate;
-
- const renderList = () => {
- const q = filter.value.trim().toLowerCase();
- list.innerHTML = "";
- const addRow = (e) => {
- if (mode === "group") {
- const row = document.createElement("label");
- row.className = "list-row";
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = selected.has(e.user_id);
- cb.onchange = () => {
- cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
- updateCreate();
- };
- row.appendChild(cb);
- row.appendChild(pickerName(e));
- list.appendChild(row);
- } else {
- const b = document.createElement("button");
- b.className = "list-row";
- b.appendChild(pickerName(e));
- b.onclick = () => {
- const dm = existingDM(e.user_id);
- if (dm) {
- backdrop.remove();
- openThread(dm.id); // already chatting — just reopen it
- return;
- }
- go(createThread([e.user_id], { group: false }), b);
- };
- list.appendChild(b);
- }
- };
- // Three groups in the one scrollable list: people, then bots/AI under
- // "Helpers", then personal apps under "Apps" (people picker only — a group's
- // member list can't include an app). People get a heading only when another
- // group sits alongside them (a lone people list needs no label); helpers and
- // apps always get theirs, since each is deliberately "not a person".
- const people = others.filter((e) => !e.is_agent && rosterMatches(e, q));
- // Bots off empties the Helpers section — existing bot DMs stay in the
- // sidebar; only starting new ones hides.
- const helpers = ENABLE_BOTS ? others.filter((e) => e.is_agent && rosterMatches(e, q)) : [];
- const apps = mode === "people" ? matchingApps(q) : [];
- const showAdmin = mode === "people" && adminMatches(q);
- if (!people.length && !helpers.length && !apps.length && !showAdmin) {
- const p = document.createElement("p");
- p.className = "sub";
- p.style.padding = "12px 28px";
- p.textContent = "No matches.";
- list.appendChild(p);
- return;
- }
- if (people.length && (helpers.length || apps.length || showAdmin)) list.appendChild(pickerSection("People"));
- for (const e of people) addRow(e);
- if (helpers.length) list.appendChild(pickerSection("Helpers"));
- for (const e of helpers) addRow(e);
- if (apps.length || showAdmin) list.appendChild(pickerSection("Apps"));
- for (const app of apps) addAppRow(list, app);
- if (showAdmin) addAppRow(list, adminApp);
- };
- filter.addEventListener("input", renderList);
- renderList();
-
- // Enter submits: in a group, create it; in the picker, open the top match.
- submitOnEnter(filter, () => {
- if (mode === "group") return create.click();
- const first = list.querySelector("button.list-row");
- if (first) first.click();
- });
- if (mode === "group") submitOnEnter(nameInput, () => create.click());
-
- if (mode === "group") {
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => render("people");
- create.textContent = "Create group";
- updateCreate(); // pre-ticked Open (Discover's empty state) starts enabled
- create.onclick = () =>
- go(
- createThread([...selected], {
- group: true,
- name: nameInput.value.trim(),
- open: !!(openCb && openCb.checked),
- openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "",
- default: !!(defaultCb && defaultCb.checked),
- }),
- create,
- );
- foot.appendChild(cancel);
- foot.appendChild(create);
- card.appendChild(foot);
- }
- };
- // Honour a requested start view only when it's usable, else fall back.
- let start = "people";
- if (startMode === "group" && others.length) start = "group";
- // "open-group" (Discover's empty state) works even with nobody else joined
- // yet — an open group may start with just its creator.
- if (startMode === "open-group") start = "group";
- if (startMode === "invite" && inviteAllowed()) start = "invite";
- render(start);
-}
-
-// Discover (⌘K → "Discover groups", or the new-chat sheet): the community
-// directory of open groups — join any of them on the spot. Each row's name
-// decrypts via its open_key + the instance key, so the whole directory is
-// readable by any member and by nobody else (the server sees only blobs).
-// Membership: the member's paid-access-levels surface. Their current
-// subscription (with the dated "renews / ends on" line — the reassuring
-// heart), plus any levels they can subscribe to. Reachable from Edit profile
-// and ⌘K when the space sells any level or the member already subscribes.
-// Anti-dark-pattern: cancel is a plain, equal-weight action and always states
-// the exact keep-until date; price + interval are shown before any commitment.
-async function showMembership() {
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const head = document.createElement("div");
- head.className = "sheet-head";
- const h = document.createElement("h2");
- h.textContent = "Membership";
- head.appendChild(h);
- card.appendChild(head);
-
- const status = statusLineEl();
- card.appendChild(status);
-
- const paint = async () => {
- await loadTiers();
- // Clear everything below the header + status line.
- while (status.nextSibling) status.nextSibling.remove();
-
- const sellable = tiers.filter((t) => t.price_cents > 0);
- const subscribed = tiers.filter((t) => t.status); // active | past_due | canceled
- if (!sellable.length && !subscribed.length) {
- const p = document.createElement("p");
- p.className = "membership-note";
- p.textContent = "This space doesn't offer paid access levels.";
- card.appendChild(p);
- return;
- }
-
- const note = document.createElement("p");
- note.className = "membership-note";
- note.textContent = "Subscriptions are handled securely by Stripe. You can change or cancel anytime.";
- card.appendChild(note);
-
- const busy = (btn, fn) => async () => {
- btn.disabled = true;
- const prev = btn.textContent;
- btn.textContent = "…";
- const res = await fn();
- if (res && res.url) {
- location.href = res.url; // hosted checkout / billing portal
- return;
- }
- btn.disabled = false;
- btn.textContent = prev;
- if (res) {
- flash(status, "Done.");
- await paint();
- } else {
- flash(status, "Something went wrong — try again.");
- }
- };
-
- // ---- Your subscription (current state) --------------------------------
- for (const t of subscribed) {
- const box = document.createElement("div");
- box.className = "plan-card current";
- const hd = document.createElement("div");
- hd.className = "plan-head";
- const nm = document.createElement("span");
- nm.className = "plan-name";
- nm.textContent = t.name;
- hd.appendChild(nm);
- const pr = levelPrice(t);
- if (pr) {
- const price = document.createElement("span");
- price.className = "plan-price";
- price.textContent = pr.text;
- const per = document.createElement("span");
- per.className = "per";
- per.textContent = " / " + pr.per;
- price.appendChild(per);
- hd.appendChild(price);
- }
- box.appendChild(hd);
-
- // The dated status line — icon (shape) + words, never colour alone.
- const st = document.createElement("div");
- st.className = "plan-state";
- const dot = document.createElement("span");
- dot.className = "dot";
- st.appendChild(dot);
- const txt = document.createElement("span");
- if (t.status === "active") {
- txt.textContent = t.paid_until ? `Renews on ${billingDate(t.paid_until)}` : "Active";
- } else if (t.status === "past_due") {
- st.classList.add("warn");
- dot.textContent = "";
- const warn = document.createElement("span");
- warn.setAttribute("aria-hidden", "true");
- warn.textContent = "⚠ ";
- st.replaceChild(warn, dot);
- txt.textContent = `Payment didn't go through — access ends ${billingDate(t.grace_deadline)} unless it's fixed.`;
- } else {
- // canceled / ending
- txt.textContent = `Your access ends on ${billingDate(t.grace_deadline || t.paid_until)}.`;
- }
- st.appendChild(txt);
- box.appendChild(st);
-
- const acts = document.createElement("div");
- acts.className = "plan-actions";
- if (t.status === "active") {
- // Change level (to another sellable level), Cancel, Manage billing.
- const others = sellable.filter((x) => x.id !== t.id);
- if (others.length) {
- const chg = pillBtn("Change level", true);
- chg.onclick = () => showChangeLevel(t, others, paint, status);
- acts.appendChild(chg);
- }
- const cancel = pillBtn("Cancel", true);
- cancel.onclick = () => confirmCancel(t, paint, status);
- acts.appendChild(cancel);
- } else if (t.status === "past_due") {
- const fix = pillBtn("Update payment", false);
- fix.onclick = busy(fix, () => levelPortal(t.id));
- acts.appendChild(fix);
- } else {
- // ending — offer to resume (un-cancels the same subscription, no new charge).
- const resume = pillBtn("Resume", false);
- resume.onclick = busy(resume, () => resumeLevelSub(t.id));
- acts.appendChild(resume);
- }
- const manage = pillBtn("Manage billing", true);
- manage.onclick = busy(manage, () => levelPortal(t.id));
- acts.appendChild(manage);
- box.appendChild(acts);
- card.appendChild(box);
- }
-
- // ---- Levels you can subscribe to --------------------------------------
- const canSubscribe = sellable.filter((t) => !t.status);
- if (canSubscribe.length) {
- if (subscribed.length) {
- const sec = document.createElement("div");
- sec.className = "membership-sec";
- sec.textContent = "Other access levels";
- card.appendChild(sec);
- }
- for (const t of canSubscribe) {
- const box = document.createElement("div");
- box.className = "plan-card";
- const hd = document.createElement("div");
- hd.className = "plan-head";
- const nm = document.createElement("span");
- nm.className = "plan-name";
- nm.textContent = t.name;
- hd.appendChild(nm);
- const pr = levelPrice(t);
- const price = document.createElement("span");
- price.className = "plan-price";
- price.textContent = pr.text;
- const per = document.createElement("span");
- per.className = "per";
- per.textContent = " / " + pr.per;
- price.appendChild(per);
- hd.appendChild(price);
- box.appendChild(hd);
- const acts = document.createElement("div");
- acts.className = "plan-actions";
- const sub = pillBtn("Subscribe", false);
- sub.onclick = busy(sub, () => subscribeToLevel(t.id));
- acts.appendChild(sub);
- box.appendChild(acts);
- card.appendChild(box);
- }
- }
- };
- await paint();
-}
-
-// A quiet pill button; loud=accent (the one prominent action), else the quiet
-// variant. Reuses the .pill-btn vocabulary from Discover.
-function pillBtn(label, quiet) {
- const b = document.createElement("button");
- b.className = "pill-btn" + (quiet ? " quiet" : "");
- b.textContent = label;
- return b;
-}
-
-// A small inline status line (reused across the membership flows).
-function statusLineEl() {
- const p = document.createElement("div");
- p.className = "admin-status";
- p.setAttribute("role", "status");
- p.style.cssText = "min-height:18px;font-size:13px;color:var(--muted)";
- return p;
-}
-
-// Show a transient message in an inline status element (never a floating toast).
-function flash(el, msg) {
- if (!el) return;
- el.textContent = msg;
- setTimeout(() => {
- if (el.textContent === msg) el.textContent = "";
- }, 2500);
-}
-
-// Cancel confirmation as a DATED PROMISE, not a threat: it names the exact day
-// access continues to, so cancelling never feels like losing everything now.
-function confirmCancel(t, repaint, status) {
- const until = billingDate(t.paid_until);
- const msg = until
- ? `You'll keep ${t.name} until ${until}. Cancel your subscription?`
- : `Cancel your ${t.name} subscription?`;
- if (!confirm(msg)) return;
- flash(status, "Cancelling…");
- cancelLevelSub(t.id).then((ok) => {
- flash(status, ok ? "Cancelled — you keep access until it ends." : "Couldn't cancel — try again.");
- repaint();
- });
-}
-
-// Change level: pick another sellable level; switches the subscription in place
-// (Stripe prorates the difference).
-function showChangeLevel(from, others, repaint, status) {
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Change level";
- card.appendChild(h);
- const note = document.createElement("p");
- note.className = "membership-note";
- note.textContent = `You're on ${from.name}. Switching adjusts your next bill for the difference.`;
- card.appendChild(note);
- for (const t of others) {
- const row = document.createElement("button");
- row.className = "list-row";
- const nm = document.createElement("span");
- nm.style.flex = "1";
- nm.textContent = t.name;
- const pr = levelPrice(t);
- const price = document.createElement("span");
- price.className = "plan-price";
- price.textContent = `${pr.text} / ${pr.per}`;
- row.append(nm, price);
- row.onclick = () => {
- if (!confirm(`Switch to ${t.name} (${pr.text} / ${pr.per})?`)) return;
- backdrop.remove();
- flash(status, "Switching…");
- changeLevelSub(from.id, t.id).then((ok) => {
- flash(status, ok ? "Switched." : "Couldn't switch — try again.");
- repaint();
- });
- };
- card.appendChild(row);
- }
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Back";
- cancel.onclick = () => backdrop.remove();
- foot.appendChild(cancel);
- card.appendChild(foot);
-}
-
-async function showDiscover() {
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const head = document.createElement("div");
- head.className = "sheet-head";
- const h = document.createElement("h2");
- h.textContent = "Discover groups";
- head.appendChild(h);
- card.appendChild(head);
- const note = document.createElement("p");
- note.className = "sub";
- note.style.padding = "0 4px 6px";
- note.textContent = "Groups anyone in this space can join — no invite needed.";
- card.appendChild(note);
-
- let rows = [];
- try {
- const r = await (await fetch(`/api/threads/open?token=${encodeURIComponent(session.token)}`)).json();
- rows = r.threads || [];
- } catch (e) {
- console.error(e);
- note.textContent = "Couldn't load open groups — try again.";
- return;
- }
- if (!rows.length) {
- // An empty directory is an invitation: hand over the one action that
- // fills it (the button lands in the new-group sheet with Open pre-ticked).
- note.textContent = "No open groups here yet. Start one and it shows up for everyone in this space.";
- const start = document.createElement("button");
- start.textContent = "Start an open group";
- start.onclick = () => {
- backdrop.remove();
- showNewChat("open-group");
- };
- card.appendChild(start);
- return;
- }
- if (!instanceKey) {
- // Same key-pending state as photos: the instance key hasn't reached this
- // device yet, so the directory can't be decrypted (or joined usefully).
- note.textContent = "Waiting for your key to arrive — try again in a moment.";
- return;
- }
-
- const list = document.createElement("div");
- list.className = "pick-list";
- card.appendChild(list);
- await loadTiers(); // gate keys + level names for the rows below
- for (const row of rows) {
- // Row states: joined always wins (membership IS access — Open must work
- // even if we've since left the gate level); then locked to another level
- // (an honest lock — the name STAYS sealed, that's the feature); then
- // ours-but-the-key-hasn't-arrived.
- const joined = !!row.joined;
- const locked = !joined && row.open_tier && !row.eligible;
- const keyPending = !joined && !locked && row.open_tier && !tierKeys[row.open_tier];
- let name = row.tier_name ? `A group for ${row.tier_name}` : "Open group";
- if (!locked && !keyPending) {
- const src = row.open_tier ? tierKeys[row.open_tier] : instanceKey;
- if (src) {
- try {
- const key = await importKey((await decrypt(src, row.open_key)).k);
- if (row.name_blob) name = (await decrypt(key, row.name_blob)).name || name;
- } catch (e) {
- console.error("discover row", row.id, e);
- if (!joined) continue; // undecryptable AND unjoinable — don't offer it
- }
- }
- }
- const item = document.createElement("div");
- item.className = "list-row static";
- item.appendChild(avatarEl(name));
- const info = document.createElement("div");
- info.className = "discover-main";
- const title = document.createElement("div");
- title.textContent = name;
- const sub = document.createElement("span");
- sub.className = "helper-desc";
- const count = row.member_count === 1 ? "1 member" : `${row.member_count} members`;
- if (joined && row.open_tier && !row.eligible) {
- sub.textContent = `${count} · you're in, but no longer in ${row.tier_name || "its level"}`;
- } else if (locked) {
- sub.textContent = row.tier_name ? `${count} · for ${row.tier_name} only` : `${count} · limited access`;
- } else if (keyPending) {
- sub.textContent = `${count} · your key is on its way`;
- } else {
- sub.textContent = row.default_join ? `${count} · everyone starts here` : count;
- }
- info.appendChild(title);
- info.appendChild(sub);
- item.appendChild(info);
- const btn = document.createElement("button");
- btn.className = "pill-btn";
- if (!joined && (locked || keyPending)) {
- // No dead Join buttons: the state is the row's text (never color
- // alone); a lock is information, not an error.
- btn.classList.add("quiet");
- btn.disabled = true;
- btn.textContent = locked ? "Locked" : "Waiting";
- item.appendChild(btn);
- list.appendChild(item);
- continue;
- }
- if (row.joined) {
- // Already yours — the quiet variant keeps Join the sheet's one loud act.
- btn.classList.add("quiet");
- btn.textContent = "Open";
- btn.onclick = () => {
- backdrop.remove();
- openThread(row.id);
- };
- } else {
- btn.textContent = "Join";
- btn.onclick = async () => {
- btn.disabled = true;
- try {
- const r = await fetch(`/api/threads/${encodeURIComponent(row.id)}/join`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token }),
- });
- if (!r.ok) throw new Error("join failed");
- await loadThreads();
- renderThreadList();
- // Tell the room — the same action-line shape announceJoin sends,
- // but in the joiner's own voice ("Paul joined", not "… added Paul").
- // The join field nudges other clients to refresh their member list.
- const key = threadKeys[row.id];
- if (key && ws && ws.readyState === WebSocket.OPEN) {
- const me = session.name || "New member";
- const payload = { name: me, text: `${me} joined`, join: myUserId ? [myUserId] : [], ts: Date.now(), action: true };
- if (myUserId) payload.uid = myUserId;
- ws.send(JSON.stringify({ type: "msg", thread_id: row.id, blob: await encrypt(key, payload) }));
- }
- // The sheet stays up until we're actually in: a failure above lands
- // in the catch with the retry button still visible (re-running the
- // join is safe — the server treats it as idempotent).
- backdrop.remove();
- openThread(row.id);
- } catch (e) {
- console.error(e);
- btn.disabled = false;
- btn.textContent = "Couldn't join — try again";
- }
- };
- }
- item.appendChild(btn);
- list.appendChild(item);
- }
- if (!list.childElementCount) {
- // Every row failed to decrypt (e.g. open_keys minted against a different
- // instance key) — say so rather than presenting a silently empty sheet.
- const p = document.createElement("p");
- p.className = "sub";
- p.style.padding = "0 4px 6px";
- p.textContent = "Couldn't read the group directory on this device — try again in a moment.";
- card.appendChild(p);
- }
-}
-
-// Apps directory (⌘K → "Open apps"): the personal apps as a plain pick list —
-// tap one to open (or create on first use) its app-thread. Same rows the
-// New-chat sheet shows under "Apps", including the connected-service gate: a
-// `requiresService` app only lists once its service is linked, and stays hidden
-// until /api/connected answers (no flash of a not-yet-usable app).
-function showApps() {
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const head = document.createElement("div");
- head.className = "sheet-head";
- const h = document.createElement("h2");
- h.textContent = "Apps";
- head.appendChild(h);
- card.appendChild(head);
- const list = document.createElement("div");
- list.className = "pick-list";
- card.appendChild(list);
- let connectedServices = null; // null = unknown yet; a Set once loaded
- const paint = () => {
- list.innerHTML = "";
- for (const app of personalApps()) {
- if (app.requiresService && !(connectedServices && connectedServices.has(app.requiresService)))
- continue;
- const b = document.createElement("button");
- b.className = "list-row";
- const g = document.createElement("span");
- g.className = "avatar app-avatar";
- appGlyphInto(g, app);
- const nm = document.createElement("span");
- nm.textContent = app.title || app.name;
- b.append(g, nm);
- b.onclick = async () => {
- backdrop.remove();
- const id = await resolveAppThread(app.name);
- if (id) openThread(id);
- };
- list.appendChild(b);
- }
- };
- fetch(`/api/connected?token=${encodeURIComponent(session.token)}`)
- .then((r) => (r.ok ? r.json() : { services: [] }))
- .then((j) => {
- connectedServices = new Set(j.services || []);
- paint();
- })
- .catch(() => {});
- paint();
-}
-
-// createThread generates a fresh key, wraps it to every member (me + picked),
-// and posts the thread (DM, or group with an encrypted name). Returns the id.
-async function createThread(otherIds, opts = {}) {
- const group = !!opts.group;
- const { key, b64 } = await generateKey();
- const memberIds = [myUserId, ...otherIds];
- const members = [];
- for (const uid of memberIds) {
- const e = roster.find((x) => x.user_id === uid);
- members.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
- }
- const body = { token: session.token, kind: group ? "group" : "dm", members };
- // Only store a name when one was actually given — a nameless group falls back
- // to its member names for display (see displayName), rather than "Group".
- if (group && opts.name) body.name_blob = await encrypt(key, { name: opts.name });
- // An open community group also ships its key encrypted under the instance
- // key — or under an access level's key when gated (opts.openTier), so only
- // that level can discover and join. The server can open neither blob.
- // Picked members still get their normal wrapped copy above.
- if (group && opts.open) {
- const src = opts.openTier ? tierKeys[opts.openTier] : instanceKey;
- if (!src) throw new Error("no wrapping key yet");
- body.visibility = "open";
- body.open_key = await encrypt(src, { k: b64 });
- if (opts.openTier) body.open_tier = opts.openTier;
- }
- const r = await (
- await fetch("/api/threads", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- })
- ).json();
- // Surface the server's refusal reason ("dms closed" — the person's DM door,
- // issue #578) so callers can answer with honest copy, not a generic failure.
- if (!r.thread_id) throw new Error(r.error || "no thread id");
- // The server dedupes DMs: if one already exists it returns it with existing:true.
- // Keep that thread's real key — don't overwrite it with the throwaway key we
- // generated for the would-be new thread (which no member could decrypt).
- if (!r.existing) {
- threadKeys[r.thread_id] = key;
- threadKeyB64[r.thread_id] = b64;
- }
- // An open, ungated group can be marked default (new members auto-join) at
- // creation — a separate admin-gated call after the thread exists. Ungated
- // only: the server refuses default on a level-gated group.
- if (opts.default && opts.open && !opts.openTier && !r.existing) {
- await fetch(`/api/threads/${encodeURIComponent(r.thread_id)}/default`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, default: true }),
- }).catch(() => {});
- }
- return r.thread_id;
-}
-
// ---- inviting a new person straight into a DM ---------------------------
// inviteSomeone creates a pending DM with a brand-new person plus a link they use