| index 334125c..43e3f8b 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -8781,8 +8781,14 @@ function showNewChat(startMode) { |
| 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(); |
| @@ -8792,7 +8798,16 @@ function showNewChat(startMode) { |
| } catch (e) { |
| console.error(e); |
| btn.disabled = false; |
| - btn.textContent = "Couldn't start — try again"; |
| + 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"; |
| + } |
| } |
| }; |
| |
| @@ -9715,13 +9730,26 @@ async function createThread(otherIds, opts = {}) { |
| // to join. The browser generates the DM key, wraps it to us (so we hold it) and |
| // encrypts it to the invite secret (so the joiner recovers it) — the server only |
| // stores blobs. Returns the pending thread id; the link lands in inviteLinks. |
| +// An error whose message is written FOR the member — the sheet's failure |
| +// handler (go, above) shows these inline instead of the generic label. |
| +function friendlyError(message) { |
| + return Object.assign(new Error(message), { friendly: true }); |
| +} |
| + |
| +// Why an invite can't mint yet: the invitee's name seals under the shared |
| +// instance key, which hasn't reached this device. Honest on its own; #785 |
| +// upgrades it with holder liveness ("…haven't been online since May"). |
| +function keylessInviteReason() { |
| + return "This space's shared key hasn't reached your device yet — it arrives automatically when another member who holds it comes online. Until then, invites can't be created."; |
| +} |
| + |
| async function inviteSomeone(name, tierID = "") { |
| const T = randomSecret(); |
| const { id, wrapKey, claimHash } = await deriveInvite(T); |
| const { key, b64 } = await generateKey(); |
| const me = roster.find((e) => e.user_id === myUserId); |
| - if (!me || !me.public_key) throw new Error("your account isn't loaded yet"); |
| - if (!instanceKey) throw new Error("still setting up — try again in a moment"); |
| + if (!me || !me.public_key) throw friendlyError("Your account is still loading — give it a second and try again."); |
| + if (!instanceKey) throw friendlyError(keylessInviteReason()); |
| const selfWrapped = await wrapThreadKey(b64, me.public_key); |
| const wrapped = await encrypt(wrapKey, { threadKey: b64 }); |
| // The invitee's name is stored encrypted under the instance key (the server never |