fix(desktop): defer the first-run profile write until membership exists - #4837
Open
Tr1ckyMag1ca1 wants to merge 2 commits into
Open
fix(desktop): defer the first-run profile write until membership exists#4837Tr1ckyMag1ca1 wants to merge 2 commits into
Tr1ckyMag1ca1 wants to merge 2 commits into
Conversation
Onboarding classified "not a relay member" with a predicate that was
duplicated in both flows and matched only message-bearing rejections:
"You must be a relay member", "relay_membership_required",
"restricted: not a relay member", "invalid: you are not a relay member".
The relay's membership gate emits none of those. It answers with a
bodyless 404 (api::relay_members, MembershipDecision::Denied), which
classify_relay_error renders as the status-only string "relay returned
404 Not Found" with no detail suffix.
So a user who reached the profile step without relay membership got that
raw string printed under the username field and no way forward, instead
of the MembershipDenied screen and its Retry / Import key / Change
community affordances. It bites at the profile step specifically because
update_profile is a read-merge-write: query_relay runs before any write,
so the gate rejects the read.
Move the predicate to shared/lib/relayError.ts alongside
isRelayUnreachableError, teach it the bodyless-404 form, and have both
onboarding flows import it.
The 404 match is exact rather than a prefix. Every other relay 404 is an
api_error carrying {"error": "<msg>"}, which renders with a ": <detail>"
suffix — a host with no community bound is not a membership failure and
must keep routing elsewhere. desktop/src/shared/api/tauri.ts already
relies on this same equivalence.
This restores the recovery path; it does not reorder onboarding, so the
underlying first-run deadlock in block#3544 remains open.
Refs block#3544
Signed-off-by: Tr1ckyMag1ca1 <150954627+Tr1ckyMag1ca1@users.noreply.github.com>
First-run setup asks for a display name and avatar before the user has joined any community. A kind:0 write requires relay membership, so the save is refused and onboarding dead-ends — the largest observed drop-off on a live self-hosted community (block#3544). Membership cannot be satisfied at that point: the user has nothing to be a member of yet. So stop treating the refusal as terminal and defer the write instead. - New `features/profile/pendingProfileSave.ts` parks what the user typed and replays it once membership exists. It mirrors `avatarProfileSync`, but persists to localStorage: joining remounts the community-scoped tree, and the user may quit and relaunch before joining, so in-memory state would not survive the gap. - The parked value is identity-scoped. A different active pubkey discards it rather than writing one user's profile under another's key. - Flush runs at the community-ready transition in useCommunityInit — the first moment the write can succeed. It keeps the value parked if membership still has not landed, and drops it on a non-membership failure so a bad value cannot wedge every later boot. - MembershipDenied gains an optional "Finish this after I join" exit, supplied only by first-run onboarding. The invite flow omits it: a user mid-claim has a community to get into, and the existing actions are the way in. - Both membership-denied branches in OnboardingFlow now route through one helper that parks the draft, replacing duplicated inline handling. `resetPendingProfileSave()` is registered in `resetCommunityState()` per the community-switching contract; the persisted value deliberately survives, since it is keyed to an identity rather than a community and exists precisely to outlive the remount. Fixes block#3544 Signed-off-by: Tr1ckyMag1ca1 <150954627+Tr1ckyMag1ca1@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3544.
The deadlock
First-run setup asks for a display name and avatar before the user has joined any community. A
kind:0write requires relay membership, so the save is refused and setup dead-ends. Per #3544 this was the single largest drop-off on a live self-hosted community — corroborated there by four members left with nodisplay_name, which is what you'd expect from people who hit the wall and gave up.The key point: at that moment membership is unsatisfiable. The user has nothing to be a member of yet. Every affordance on the denied screen — retry, import a key, change community — assumes a community they are trying to get into. First-run users have none, so the refusal is terminal.
Note this is not fixed by deferring only the profile write:
update_profile(desktop/src-tauri/src/commands/profile.rs) is a read-merge-write and callsquery_relayfirst, so the gate rejects the read. #4821 covers recognizing that refusal; this PR covers surviving it.Approach
Stop treating the refusal as terminal — park what the user typed and replay it once membership exists.
features/profile/pendingProfileSave.ts(new) mirrors the existingavatarProfileSyncdeferred-write pattern, with one deliberate difference: it persists tolocalStorage. Joining a community remounts the community-scoped React tree, and the user may quit and relaunch before joining, so in-memory state would not survive the gap. (Relaunch-then-reclick is exactly how one user escaped this in practice.)Safety properties, each covered by a test:
Flush point: the community-ready transition in
useCommunityInit, the first moment the write can succeed. Failures there can never block readiness.MembershipDeniedgains an optionalonContinueWithoutProfile— "Finish this after I join" — supplied only by first-run onboarding. The invite flow omits the prop and is visually unchanged.OnboardingFlow: both membership-denied branches now route through oneenterMembershipDeniedhelper that parks the draft, replacing duplicated inline handling.Community-switching contract
resetPendingProfileSave()is registered inresetCommunityState()and documented in AGENTS.md, per the module-singleton rule. The persisted value deliberately survives the reset — it is keyed to an identity, not a community, and exists precisely to outlive the remount that joining causes. Only the in-memory instance is dropped.Testing
just desktop-ci— green (exit 0).pendingProfileSave.test.mjscovering flush outcomes, identity mismatch (including case-insensitive comparison), deferral, discard-on-hard-failure, malformed/foreign storage contents, and throwing storage.Not covered: the
OnboardingFlow/MembershipDeniedwiring has no component test — the repo tests this layer via Playwright, and a spec for "denied → continue → join → profile appears" needs a relay-backed fixture. Worth adding; flagging rather than silently skipping.Follow-up, deliberately not in scope
The invite flow (
CommunityOnboardingFlow) does not park its draft on denial. Its profile step runs after the community is applied, so the flush has already happened by then and parking would only pay off on a later community switch. Straightforward to add if reviewers want symmetry.