Skip to content

refactor: extract ProfileFetcher into AppState extension (Stage 3b)#37

Merged
DocNR merged 1 commit into
mainfrom
refactor/profile-fetcher-extension
May 8, 2026
Merged

refactor: extract ProfileFetcher into AppState extension (Stage 3b)#37
DocNR merged 1 commit into
mainfrom
refactor/profile-fetcher-extension

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 8, 2026

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.swift into a dedicated extension file.

Numbers

File Before After Δ
Clave/AppState.swift 1,770 LOC 1,483 LOC −287 (−16.2%)
Clave/AppState+ProfileFetcher.swift 303 LOC +303 (new)

Combined 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 from loadState())
  • updateCurrentProfile(_:) — write-through to accounts list + persist (private)
  • cachedImageURL(for:) func + cachedImageURL computed var (the var is private)
  • cacheImage(from:pubkey:) — Bug F invariant preserved (explicit pubkey, private)
  • fetchProfile(for:) instance — multi-relay parallel fanout (private)
  • fetchProfileIfNeeded() — 1h throttled current-account fetch
  • refreshProfile(for:) + refreshProfileAsync(for:) — on-demand
  • fetchProfilesForAllAccountsIfNeeded() — all-accounts throttled
  • refreshAllProfiles() — all-accounts pull-to-refresh
  • struct FetchedKind0 — nested type used by tests
  • static 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 @Observable macro behavior. Computed properties in extensions on @Observable classes is an untested edge; defensive 3-line cost. Pointer comment added.
  • var profileImage (stored, UIImage?) — Swift forbids stored properties in extensions. Pointer comment added.
  • 4 reset call sites for profileImage = nil (in account switching/deleting flows) — resolve through the @Observable property setter.

One small AppState.swift change required

Dropped private from persistAccounts() (line 464). The extension's updateCurrentProfile and fetchProfile(for:) call it across file boundaries — private doesn't reach there even within extensions of the same type (same constraint as Stage 2's resolution). persistAccounts will move into AccountManager in Stage 4 anyway.

Zero behavior change

  • Function bodies preserved byte-for-byte
  • Original private modifiers preserved on file-local methods
  • @MainActor on refreshProfileAsync preserved
  • All Shared/ dependencies (LightRelay) and AppState calls work unchanged from the extension

Test impact: zero

  • AppStateProfileMergeTests references AppState.FetchedKind0 + AppState.mergeKind0 — both still resolve correctly with the nested-type-in-extension pattern. No test edits.
  • External callers (HomeView, AccountDetailView, MultiAccountDiagnosticsView) call via appState.<method> — works unchanged.

Test plan

  • xcodebuild test -skip-testing:ClaveUITests on iPhone 17 / iOS 26.4 against main (pre-baseline): 232 passed / 0 failed
  • Same command on this branch: 232 passed / 0 failed
  • ** TEST SUCCEEDED ** in both runs (no test count change)
  • After merge: separate chore/pbxproj-build-67 PR; 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() becoming internal (instead of private) is a tiny API surface increase but no real consumer outside the Profile/Account code paths exists. Stage 4 will repackage these into AccountManager properly.

Stage 3c

ProxyClient (~400 LOC) follows as the last Stage 3 sub-PR.

🤖 Generated with Claude Code

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>
@DocNR DocNR merged commit 3f03810 into main May 8, 2026
@DocNR DocNR deleted the refactor/profile-fetcher-extension branch May 8, 2026 02:49
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant