refactor: extract ProfileFetcher into AppState extension (Stage 3b)#37
Merged
Conversation
Stage 3b of the AppState god-object split. Largest of the three Stage 3 sub-extractions — moves all kind:0 profile metadata + image cache + multi-relay fetch + cross-relay merge logic out of AppState.swift into a dedicated extension file. Moves out of Clave/AppState.swift: - loadCachedProfileImage() — disk image cache loader - updateCurrentProfile(_:) — write-through to accounts list (private) - cachedImageURL(for:) func + cachedImageURL var (computed) - cacheImage(from:pubkey:) — Bug F invariant preserved (explicit pubkey) - fetchProfile(for:) instance — multi-relay parallel fanout - fetchProfileIfNeeded() — 1h throttled current-account fetch - refreshProfile(for:) + refreshProfileAsync(for:) — on-demand refresh - fetchProfilesForAllAccountsIfNeeded() — all-accounts throttled - refreshAllProfiles() — all-accounts pull-to-refresh - struct FetchedKind0 — nested type used by tests - static mergeKind0(_:) — pure cross-relay selection (already unit-tested) - static fetchProfile(from:pubkey:) — single-relay kind:0 fetch Into the new file: - Clave/AppState+ProfileFetcher.swift (303 LOC) — extension AppState What stays in AppState.swift: - var profile (computed) — kept on the class to preserve @observable macro behavior intact (computed properties in extensions on @observable classes is an untested edge; defensive 3-line cost). Pointer comment added. - var profileImage (stored) — Swift forbids stored properties in extensions. Pointer comment added. - The 4 reset call sites for `profileImage = nil` (in account switching/deleting flows) — they remain in AppState.swift and resolve through the @observable property setter. Also dropped `private` modifier on `persistAccounts()` — required because the extension's `updateCurrentProfile` and `fetchProfile(for:)` call it across files. `private` doesn't cross file boundaries even within extensions of the same type. Will move to AccountManager in Stage 4. Zero behavior change: - Function bodies preserved byte-for-byte. - Original `private` modifiers preserved on file-local methods (updateCurrentProfile, cachedImageURL var, cacheImage, fetchProfile instance + static). - @mainactor on refreshProfileAsync preserved. - All Shared/ dependencies (LightRelay) and AppState calls (persistAccounts, accounts, currentAccount, profileImage) work unchanged from the extension. Test impact: zero changes. - AppStateProfileMergeTests references AppState.FetchedKind0 + AppState.mergeKind0 — both still resolve correctly with the nested-type-in-extension pattern. External callers unchanged: - HomeView.swift:120,124 (refreshAllProfiles, fetchProfilesForAllAccountsIfNeeded) - AccountDetailView.swift:65 (refreshProfileAsync) - MultiAccountDiagnosticsView.swift:154 (fetchProfileIfNeeded) All call via appState.<method> — works as extension method dispatch. No pbxproj edits — Clave/ directory is auto-synced. Verification: - xcodebuild test -skip-testing:ClaveUITests on iPhone 17 / iOS 26.4: - Pre-baseline (main @ d8b5a55): 232 passed / 0 failed - Post-extraction (this branch): 232 passed / 0 failed - ** TEST SUCCEEDED ** in both runs AppState.swift: 1,770 -> 1,483 LOC (-287, -16.2%). Combined Stages 1+2+3a+3b: 2,338 -> 1,483 LOC (-36.6% from original). Stage 3c (ProxyClient, ~400 LOC) follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
DocNR
added a commit
that referenced
this pull request
May 8, 2026
…ht (#38) Internal-only build for on-device verification of two merges: - Stage 3b ProfileFetcher extraction (#37): 287 LOC moved from AppState.swift into Clave/AppState+ProfileFetcher.swift; -36.6% AppState size since the start of the refactor sprint. - Nostrconnect connect-flow polish (#36): switch-back overlay copy for same-device pairs (paste path), QR rescan dedup, and Universal-Link clarification in docs/nip46-compatibility.md. Bumps CURRENT_PROJECT_VERSION 66 -> 67 across all 4 targets in both Debug and Release configs. On-device verification gates (all in one TF cycle): - Profile flows: account add/switch/delete, PFP correctness across switches, AccountDetailView pull-to-refresh, multi-account profile fan-out fetch. - Same-device nostrconnect via paste -> new "Switch back to your client app" subtitle should render. - Cross-device nostrconnect via QR scan -> original "Stay in Clave" subtitle preserved. - QR cancel-and-rescan loop should now break (dedup against lastAcceptedScanCode). Stage 3c (ProxyClient, ~400 LOC) is the next refactor PR. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3 tasks
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.
Summary
Stage 3b of the AppState god-object split. Moves all kind:0 profile metadata + image cache + multi-relay fetch + cross-relay merge logic out of
AppState.swiftinto a dedicated extension file.Numbers
Clave/AppState.swiftClave/AppState+ProfileFetcher.swiftCombined Stages 1+2+3a+3b: AppState.swift down from 2,338 → 1,483 LOC (−36.6%). On track toward the ~300-LOC slim AppState the BACKLOG sketches.
What moved
Into Clave/AppState+ProfileFetcher.swift:
loadCachedProfileImage()— disk image cache loader (called fromloadState())updateCurrentProfile(_:)— write-through toaccountslist + persist (private)cachedImageURL(for:)func +cachedImageURLcomputed var (the var isprivate)cacheImage(from:pubkey:)— Bug F invariant preserved (explicit pubkey,private)fetchProfile(for:)instance — multi-relay parallel fanout (private)fetchProfileIfNeeded()— 1h throttled current-account fetchrefreshProfile(for:)+refreshProfileAsync(for:)— on-demandfetchProfilesForAllAccountsIfNeeded()— all-accounts throttledrefreshAllProfiles()— all-accounts pull-to-refreshstruct FetchedKind0— nested type used by testsstatic func mergeKind0(_:)— pure cross-relay selection (already unit-tested)static func fetchProfile(from:pubkey:)— single-relay (private)What stays in AppState.swift
var profile(computed) — kept on the class to preserve@Observablemacro behavior. Computed properties in extensions on@Observableclasses is an untested edge; defensive 3-line cost. Pointer comment added.var profileImage(stored,UIImage?) — Swift forbids stored properties in extensions. Pointer comment added.profileImage = nil(in account switching/deleting flows) — resolve through the@Observableproperty setter.One small AppState.swift change required
Dropped
privatefrompersistAccounts()(line 464). The extension'supdateCurrentProfileandfetchProfile(for:)call it across file boundaries —privatedoesn't reach there even within extensions of the same type (same constraint as Stage 2's resolution).persistAccountswill move intoAccountManagerin Stage 4 anyway.Zero behavior change
privatemodifiers preserved on file-local methods@MainActoronrefreshProfileAsyncpreservedLightRelay) and AppState calls work unchanged from the extensionTest impact: zero
AppStateProfileMergeTestsreferencesAppState.FetchedKind0+AppState.mergeKind0— both still resolve correctly with the nested-type-in-extension pattern. No test edits.HomeView,AccountDetailView,MultiAccountDiagnosticsView) call viaappState.<method>— works unchanged.Test plan
xcodebuild test -skip-testing:ClaveUITestson iPhone 17 / iOS 26.4 againstmain(pre-baseline): 232 passed / 0 failed ✅** TEST SUCCEEDED **in both runs (no test count change)chore/pbxproj-build-67PR; archive build 67 + on-device exercise of profile-related flows (account add/switch/delete, pull-to-refresh, AccountDetailView profile load)What changed exposes one minor pre-existing assumption
persistAccounts()becominginternal(instead ofprivate) is a tiny API surface increase but no real consumer outside the Profile/Account code paths exists. Stage 4 will repackage these intoAccountManagerproperly.Stage 3c
ProxyClient(~400 LOC) follows as the last Stage 3 sub-PR.🤖 Generated with Claude Code