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>
2 files changed,
+39
−0
main.go+9 −0web/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/*"; |