fix: pending-approval refresh + banner notifications + UI bundle#13
Merged
Conversation
Pre-L1, every sign request reached Clave via APNs → NSE, and NSE was the sole producer of "Approve Signing Request" banners. Post-L1 (PR #11), when Clave is foregrounded or in the 2s `.inactive` grace, L1 catches kind:24133 events directly via WebSocket and `SharedStorage.markEventProcessed` marks the event id. NSE then runs from the same APNs push, sees the dedupe, returns `.noEvents`, and produces a silent passive notification — correct, since L1 already handled it. But L1 itself never emitted any user-facing signal: no banner, and the in-process `SharedStorage.queuePendingRequest` write didn't broadcast a refresh signal, so the pending-approvals list only repopulated on tab-switch (HomeView's `.onAppear`) or full restart. Two wires fix both symptoms: 1. Refresh signal (`.pendingRequestsUpdated`) - `SharedStorage.queuePendingRequest` / `removePendingRequest` / `clearPendingRequests` post the notification in-process. - `AppState` observes it and refreshes `pendingRequests` on main. `@Observable` propagates to any subscribed view automatically. - `MainTabView` also refreshes on scenePhase `.active` so cross-process NSE writes (while we were backgrounded) get picked up — the in-process notification doesn't cross the NSE↔main-app boundary. 2. Banner emission (`PendingApprovalBanner`) - New `Shared/PendingApprovalBanner.swift` schedules a `UNNotificationRequest` matching the format NSE already uses for pending pushes (title "Approve Signing Request", `.active` interruption). Identifier is `pending-approval-<requestId>` so approve/deny can `clear()` the delivered banner. - `LightSigner.RequestResult` gains optional `pendingRequestId` so callers can schedule with the same id used in the queued `PendingRequest`. - L1 `ForegroundRelaySubscription.processEvent` schedules on `status == "pending"`. Foreground APNs push handler in `ClaveApp` also schedules (defensive — covers the rare case where NSE didn't process and main-app marked the event first; in normal operation NSE wins the race and this path returns "skipped-duplicate"). - `AppState.approvePendingRequest` / `denyPendingRequest` call `PendingApprovalBanner.clear` so the banner doesn't linger after the user has acted on it. - `ClaveApp.willPresent` now distinguishes: - Locally-scheduled banners (id prefix `pending-approval-`) → display directly, no re-processing. - APNs pushes with non-empty NSE-modified title → display (was being suppressed by unconditional `completionHandler([])`, hiding NSE's own pending banner when the app was foreground). - APNs pushes with empty title (NSE silent-success case) → suppress. NSE's own `deliverContent` `.pending` path is unchanged — it still produces a banner via `contentHandler` for the pure-background case (NSE only, no L1, no foreground push handler). The dedupe ensures only one of {NSE banner, L1-scheduled banner, foreground-handler-scheduled banner} actually fires per event. Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: needs build 25 archive + TestFlight install (next step). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundled UI/UX improvements going out alongside the L1 pending-approval refresh + banner fix in this PR so they all ride one TestFlight. **1. Pending approvals card edge padding** [Clave/Views/Home/PendingApprovalsView.swift] HomeView wraps it in a Section with `.listRowInsets(EdgeInsets())`, so the card has to self-pad to match its sibling rows (identityBar, statsRow). Added the missing `.padding(.horizontal)` after `.background`. Pre-existing since v1.0 UX sprint, not an L1 regression — only surfaced now because pending approvals weren't refreshing reliably enough for users to notice. **2. App-switcher snapshot protection (audit A10.1)** New `Clave/Views/Components/SnapshotProtected.swift` — ViewModifier that overlays the receiver with an opaque privacy view whenever scenePhase is not `.active`. Applied via `.snapshotProtected()` on the four sheets that render sensitive material: - ExportKeySheet (nsec) - ConnectSheet (bunker URI containing the bunker secret) - QRCodeView (QR code of bunker URI) - ApprovalSheet (incoming approval request, including client identity) iOS captures the app-switcher snapshot during the `.inactive` transition, so a per-sheet overlay is sufficient and avoids blanking the rest of the app on benign control-center swipes. **3. AvatarView prefers name initials over pubkey prefix** [Clave/Views/Components/AvatarView.swift] gains an optional `name: String?` param. When set and non-blank, shows up to two letters derived from the name (e.g. "Joe Bloggs" → "JB"; "Yakihonne" → "YA") in a proportional font. Pubkey-prefix fallback unchanged (monospaced). Gradient remains pubkey-derived so renames don't change the avatar color — only the text inside the circle. Updated four call sites to pass the available name: ClientDetailView header, HomeView clientRow + identityBar, ApprovalSheet. **4. ClientDetailView header + toolbar overhaul** - Tap the client name (or pencil affordance next to it) → existing Rename alert. The bottom-of-screen Rename button is removed. - Toolbar overflow menu (`ellipsis.circle`, top-right) now houses: - Connection Info → opens new `ConnectionInfoSheet` - Rename → same alert as the header tap - Unpair Client (destructive) → existing confirm alert - Bottom `actionsSection` removed entirely. The Unpair button used to live underneath the recent-activity list, which buried it. - New `ConnectionInfoSheet` shows: name, origin URL, npub, hex pubkey (both copyable), trust level, first-connected/last-seen timestamps, total request count, and the relay set the proxy watches for this client (when available via `ConnectedClient.relayUrls`). Verification: - xcodebuild -scheme Clave -destination 'generic/platform=iOS' build → BUILD SUCCEEDED - xcodebuild test on iPhone 17 Pro Max sim (iOS 26.4) → TEST SUCCEEDED - Device test: build 25 archive (next step). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reports: blank Notification Center entries accumulating again, much
worse with L1 than pre-L1.
Cause: every NSE wake for an event L1 already processed returns
"skipped-duplicate" → SigningResult(.noEvents) → NSE delivers content
with empty title + .passive interruption, then calls
removeDeliveredNotifications. The remove is racy — NSE process often
exits before iOS commits the notification, so the remove no-ops and
the blank entry sticks. L1 amplifies this because most APNs wakes are
now "L1 already handled it" duplicates rather than real work for NSE.
iOS doesn't expose a "deliver but don't add to NC" hint — `.passive`
just suppresses banner+sound, the NC entry is mandatory once the push
arrives. So we have to clean up after the fact.
Fix: on every scenePhase → .active in MainTabView, query
getDeliveredNotifications and remove any with empty title. Locally-
scheduled pending-approval banners ("Approve Signing Request"),
sign-failure banners ("Signing Failed"), and other real notifications
keep their title and are preserved. The main app process lives long
enough for the async UNUserNotificationCenter API to actually complete,
which the short-lived NSE process does not.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7 tasks
DocNR
added a commit
that referenced
this pull request
Apr 28, 2026
Rolls up PR #13 (pending-approval refresh + banner + UI bundle, already shipped as build 25 from the branch) and PR #15 (auto re-register with proxy on every launch). Build 25 was archived from PR #13 branch without merging to main; this commit catches main up to where build 25 already was, plus adds PR #15 on top for build 26. Tagging policy: tag v0.1.0-build26 immediately after archive as Pre-release on GitHub; flip to Latest once Apple clears external review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 28, 2026
DocNR
added a commit
that referenced
this pull request
May 2, 2026
Brainstorm review of design-system.md against shipped code surfaced 9 inconsistencies + 1 anti-pattern still present. Fixed everything in one batch so the next TestFlight archive carries it all. Code: - HomeView: drop .padding(.bottom, 8) on SlimIdentityBar invocation — slim banner owns its outer bottom padding (12pt); stacking another 8pt on top was double-counting (review #4) - HomeView: drop .padding(.bottom, 8) inside statsRow — listSectionSpacing(0) carries the gap to Connected Clients; the residual padding kept the visible gap excessive after polish round 2 (review #2) - AccountDetailView: avatarLarge letter fallback opacity 0.25 → 0.22 to align with SlimIdentityBar's 0.22 (review #3) - ConnectSheet: add .presentationBackground(Color(.systemGroupedBackground)) — was the last sheet still defaulting to translucent (review #9) - ApprovalSheet: rename @State capExceeded → showConnectionCapAlert for naming convention parity with HomeView (review #7) design-system.md: - New "Cross-platform applicability" section at the top — clarifies what carries directly to clave.casa web companion (color tokens, displayLabel rule, identity-vs-functional zone philosophy, avatar treatments, copy patterns, anti-patterns) vs what's iOS-only (SwiftUI modifiers, haptics, sheet/toolbar conventions) - §3 Typography: corrected initial-letter font scale — AvatarView uses size*0.35 mono (pubkey) or size*0.4 proportional (name); was wrongly documented as a single 0.37 (review #1) - §4 Avatars: added Treatment Selection Rule table (B on neutral bg, C on saturated theme gradient) + clarified 1-vs-2 letter behavior (review #5, #6) - §4 Sizing scale: expanded table to include initial font + border thickness per slot, with the ~5% border scaling rule (review #8) - §5 Spacing: explicit "single source of truth" note on slim banner bottom padding; new "Stats row" subsection capturing the ultraThinMaterial-on-small-cards-OK rule (review gap #10, #4) - §6 HomeView gradient: documented palette[0] defensive fallback when currentAccount is nil (review #13) - §7 Patterns: new "State variable naming" subsection with the showCapAlert / showAccountCapAlert / showConnectionCapAlert convention (review #7 doc side) - §11 Anti-patterns: audit-point note that ConnectSheet was the last surface to acquire .presentationBackground (review #9 doc side) Build green on iOS Simulator 26.4. pbxproj still 41 — assumes user hasn't yet archived 41; bump to 42 if needed before re-archive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.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.
Summary
Bundle of fixes targeting build 25 / TestFlight. Two themes:
1. L1 pending-approval refresh + banner regression (the main one)
Pre-L1, every sign request reached Clave via APNs → NSE, and NSE was the sole producer of "Approve Signing Request" banners. Post-L1 (PR #11), when Clave is foregrounded or in the 2s
.inactivegrace window, L1 catches kind:24133 events directly via WebSocket andSharedStorage.markEventProcessedmarks the event id. NSE then runs from the same APNs push, sees the dedupe, returns.noEvents, and produces a silent passive notification — correct, since L1 already handled it. But L1 itself never produced a user-facing signal:SharedStorage.queuePendingRequestwrites to UserDefaults but didn't broadcast a refresh signal, so the@Observable AppState.pendingRequestsstayed stale untilHomeView.onAppear(tab switch).Refresh fix:
SharedStorage.queuePendingRequest/removePendingRequest/clearPendingRequestspost.pendingRequestsUpdated.AppStateobserves it ininitand callsrefreshPendingRequests()on main.MainTabViewalso refreshes on scenePhase.activeso cross-process NSE writes get picked up on app foreground (the in-processNotificationCenterpost doesn't cross the NSE↔main-app boundary).Banner fix:
Shared/PendingApprovalBanner.swift: `schedule(requestId:clientPubkey:eventKind:)` + `clear(requestId:)`. Identifier `pending-approval-`.NSE's own `deliverContent .pending` path is unchanged — still produces a banner via `contentHandler` for the pure-background case. The dedupe ensures only one of {NSE banner, L1-scheduled banner, foreground-handler-scheduled banner} actually fires per event.
2. UI/UX bundle (additional commit `3401c09`)
Test plan
🤖 Generated with Claude Code