// 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 { ICON_WRENCH } from "/static/icons.js"; import { 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"; import { avatarFor, avatarEl, paintAvatar } from "/static/avatars.js"; import { decrypt, encrypt, generateKey, importKey, wrapThreadKey } from "/static/crypto.js"; import { appGlyphInto, personalApps, groupCapableApps, appByName, installGroupApp } from "/static/apps.js"; import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; // ---- new chat (roster picker) ------------------------------------------- // The app pickers' view of the registry: with member-made apps off, App Studio // (the make-your-own entry point) hides — deploys would 403 anyway. Member apps // already deployed stay listed: like the wider apps gate, off only stops new // use, it never breaks what members already rely on. export function pickerApps() { return personalApps().filter((a) => ENABLE_MEMBER_APPS || a.name !== "appstudio"); } // 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 ? [] : pickerApps().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. Empty for everyone else. Deliberately exempt // from the apps gate: Admin is how an admin switches apps back on. Webhooks // (also system-scope, also admin-only — docs/webhooks.md) rides the same row // logic. const adminApps = verbs.isAdmin() ? [ { name: "admin", title: "Admin", glyph: "🛠️", logo: ICON_WRENCH }, { name: "webhooks", title: "Webhooks", glyph: "📥" }, ] : []; const adminMatching = (q) => adminApps.filter((a) => a.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(); // A personal copy that already exists opens directly — the common, // repeat case stays one tap. With the full places UI (PLACES_UI), an // app you've never opened lands in the Apps sheet FOCUSED on it // instead: its birth is a place decision (just for you / a chat / // everyone), never a silent create — only directory-listed apps have a // sheet entry to land on; host-mounted rows (Admin) keep the direct // open. With the places UI hidden (the default — § The pullback), // tapping an app just sets it up in its one obvious place: the direct // personal create this row always used to do. const existing = Object.keys(threadApp).find((id) => threadApp[id] === app.name); if (existing) return verbs.openThread(existing); if (!PLACES_UI || app.listed !== true) { const id = await verbs.resolveAppThread(app.name); if (id) verbs.openThread(id); return; } verbs.manageGroupApps({ space: true, focus: app.name }); }; 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") { // Offered whenever creating could succeed: people to pick, OR the // instance key that lets an open group start with just its creator // (the others-only gate predates open groups; native never had it). if (others.length || instanceKey) { 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; } // Nobody else here yet: the people picker has nothing to offer, so show // the invite empty state — EXCEPT in group mode with the instance key, // where an open group can start with just its creator; fall through to // the real group view (name, Open toggle, create). if (!others.length && !(mode === "group" && instanceKey)) { 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 || adminApps.length) { const list = document.createElement("div"); list.className = "pick-list"; list.appendChild(pickerSection("Apps")); for (const app of apps) addAppRow(list, app); for (const app of adminApps) addAppRow(list, app); 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); } } } // Start-with-apps (group mode): pick shared apps at the group's birth — // parity with native's NewGroupView, and the best teaching moment for // "added here, shared here". The picks land as ordinary groupapps.add // events in the fresh log, exactly like ⋮ → Apps afterwards. const birthApps = new Set(); if (mode === "group" && ENABLE_APPS && groupCapableApps().length) { const alist = document.createElement("div"); alist.className = "pick-list"; alist.appendChild(pickerSection("Start with apps")); for (const app of groupCapableApps()) { const row = document.createElement("label"); row.className = "list-row"; const cb = document.createElement("input"); cb.type = "checkbox"; cb.onchange = () => (cb.checked ? birthApps.add(app.name) : birthApps.delete(app.name)); const body = document.createElement("span"); body.style.cssText = "flex:1;min-width:0;display:flex;align-items:center;gap:8px;"; const g = document.createElement("span"); g.className = "avatar app-avatar"; g.style.cssText = "width:26px;height:26px;font-size:14px;"; appGlyphInto(g, app); const nm = document.createElement("span"); nm.textContent = app.title || app.name; body.append(g, nm); row.append(cb, body); alist.appendChild(row); } card.appendChild(alist); } const filter = document.createElement("input"); filter.placeholder = "Search"; filter.className = "sheet-input"; // A search box over nobody (the solo open-group path) is just confusing. if (!others.length) filter.style.display = "none"; 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 adminHits = mode === "people" ? adminMatching(q) : []; if (!people.length && !helpers.length && !apps.length && !adminHits.length) { 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 || adminHits.length)) 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 || adminHits.length) list.appendChild(pickerSection("Apps")); for (const app of apps) addAppRow(list, app); for (const app of adminHits) addAppRow(list, app); }; 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( (async () => { const tid = await createThread([...selected], { group: true, name: nameInput.value.trim(), open: !!(openCb && openCb.checked), openTier: (openCb && openCb.checked && tierSel && tierSel.value) || "", default: !!(defaultCb && defaultCb.checked), }); // Birth apps ride the fresh log; best-effort in order — a failed // add is recoverable from ⋮ → Apps, never a failed group. for (const name of birthApps) { const app = appByName(name); if (app) await installGroupApp(tid, app).catch(() => {}); } return tid; })(), 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.once ? " · once" : " / " + 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.lifetime && t.status === "active") { txt.textContent = "Yours for keeps — paid once."; } else 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.lifetime) { // A one-time purchase has no billing to manage: no cancel, no // resume, no portal — the box is just the good news above. } else 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.once ? " · once" : " / " + 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.once ? `${pr.text} · once` : `${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 pickerApps()) { 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; }