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