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>
3 files changed,
+993
−977
CLAUDE.md+2 −0web/static/newchat.js+985 −0web/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 |