eleven / messenger Public

web: the invite sheet says WHY it couldn't start (#786) (#795)

showNewChat's shared failure handler renders our own friendly-flagged error
messages inline in the sheet (role=alert, occupying layout — never a toast);
network/unknown errors keep the generic label. inviteSomeone's guards become
friendly errors, including honest keyless-member copy ('this space's shared
key hasn't reached your device yet…') — the state #785 will enrich with
holder liveness and give admins a way out of.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e1380c92ec38d01b57f020a75ab57a73b51eb796 parent f6b1ab5
2 files changed, +35 −3
  • web/static/shell.js +31 −3
  • web/static/style.css +4 −0
diff --git a/web/static/shell.js b/web/static/shell.js
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
diff --git a/web/static/style.css b/web/static/style.css
index 6d11195..740600d 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -2287,6 +2287,10 @@ input.filter-check:focus-visible { outline: 2px solid var(--accent); outline-off
.member-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member-remove { flex: none; background: transparent; color: var(--danger); font-weight: 600; font-size: 14px; padding: 4px 8px; }
.sheet-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
+/* An inline failure reason in a sheet (#786) — occupies layout space above
+ the buttons, never a floating toast. Colour is not the only signal: the
+ role=alert announcement + the wording carry it. */
+.sheet-error { color: var(--danger, #c0392b); font-size: 14px; margin: 10px 0 0; }
/* Open groups. Two pieces of vocabulary, both built from the sheet's existing
language so community UI reads as native, not bolted on: