eleven / messenger Public

fix the stuck "Photo syncing…" state for late-joining members (#29)

A member only gets the per-instance key (used to encrypt profile photos)
when an existing holder is online to wrap it to them — E2EE means the
server can't. Two gaps made this feel permanently stuck:

  1. When a holder granted the key (on their next app-open, or via a
     nudge), the server stored the wrapped blob but never told the
     granted member, so their open tab kept showing "Photo syncing…"
     until a manual reload.
  2. The nudge only fired once, at boot. A member sitting on the
     Edit-profile sheet had no way to retry.

Fix, all keyless (the signal carries no key material, routed only to the
granted users):

  - server: after storing grants, send `instance_key_granted` over WS to
    exactly the granted user_ids.
  - client: on `instance_key_granted`, fetch + unwrap the key live and
    flip the open profile sheet from "Photo syncing…" to "Change photo"
    (refreshProfilePhotoBtn) — no reload.
  - client: re-nudge holders + re-poll whenever Edit-profile opens while
    the key is missing, so the user has a real retry.

Net: a stuck member self-heals the instant any holder opens the app.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 6429a70db8546a3d2d638c397673a50d9d69ddd3 parent 7f76428
2 files changed, +39 −0
  • main.go +9 −0
  • web/static/chat.js +30 −0
diff --git a/main.go b/main.go
index 827acae..0bc733a 100644
--- a/main.go
+++ b/main.go
@@ -622,6 +622,7 @@ func (s *Server) postInstanceKey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusForbidden, "no user for token")
return
}
+ granted := map[string]bool{}
for _, g := range body.Grants {
if g.UserID == "" || g.Wrapped == "" || len(g.Wrapped) > 4096 || !s.db.userExists(g.UserID) {
continue
@@ -630,6 +631,14 @@ func (s *Server) postInstanceKey(w http.ResponseWriter, r *http.Request) {
httpError(w, http.StatusInternalServerError, "db error")
return
}
+ granted[g.UserID] = true
+ }
+ // Nudge the just-granted users' open tabs to fetch + unwrap right away, so a
+ // member stops showing "Photo syncing…" the instant a holder wraps the key to
+ // them — without waiting for a reload. Keyless signal, routed only to them.
+ if len(granted) > 0 {
+ out, _ := json.Marshal(map[string]any{"type": "instance_key_granted"})
+ s.hub.broadcast <- outbound{members: granted, data: out}
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
diff --git a/web/static/chat.js b/web/static/chat.js
index 2f6192f..0d993ed 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -233,12 +233,23 @@ async function pollForInstanceKey(tries = 6) {
instanceKeyB64 = await unwrapThreadKey(r.wrapped, session.userPriv);
instanceKey = await importKey(instanceKeyB64);
refreshAvatars();
+ refreshProfilePhotoBtn();
} catch {}
return;
}
}
}
+// Flip the open Edit-profile sheet's photo control from "Photo syncing…" to
+// "Change photo" the moment the instance key arrives (live, no reload needed).
+function refreshProfilePhotoBtn() {
+ const b = document.getElementById("me-photo-btn");
+ if (b && instanceKey) {
+ b.disabled = false;
+ b.textContent = "Change photo";
+ }
+}
+
// Wrap the instance key to every roster member not already in `have`.
async function grantInstanceKeyTo(have) {
if (!instanceKeyB64) return;
@@ -1750,6 +1761,18 @@ function connectWS() {
}
return;
}
+ if (msg.type === "instance_key_granted") {
+ // A holder just wrapped the instance key to us. Pick it up live (fetch +
+ // unwrap) so an open tab stops showing "Photo syncing…" without a reload.
+ if (!instanceKey) {
+ await setupInstanceKey();
+ if (instanceKey) {
+ refreshAvatars();
+ refreshProfilePhotoBtn();
+ }
+ }
+ return;
+ }
if (msg.type === "avatar") {
const e = roster.find((x) => x.user_id === msg.user_id);
if (e) e.avatar = msg.blob_id;
@@ -2512,8 +2535,15 @@ function editProfile() {
const photoBtn = document.createElement("button");
photoBtn.className = "linkish";
photoBtn.type = "button";
+ photoBtn.id = "me-photo-btn"; // so refreshProfilePhotoBtn() can flip it live
photoBtn.textContent = instanceKey ? "Change photo" : "Photo syncing…";
photoBtn.disabled = !instanceKey;
+ // Don't just sit on "Photo syncing…": prod any online holder to wrap the key to
+ // us now, then poll for it. Picks up live via refreshProfilePhotoBtn().
+ if (!instanceKey) {
+ fetch(`/api/instance-key/nudge?token=${encodeURIComponent(session.token)}`, { method: "POST" }).catch(() => {});
+ pollForInstanceKey();
+ }
const photoInput = document.createElement("input");
photoInput.type = "file";
photoInput.accept = "image/*";