Skip to content

fix: pending-approval refresh + banner notifications + UI bundle#13

Merged
DocNR merged 3 commits into
mainfrom
fix/pending-approvals-refresh-and-banner
Apr 28, 2026
Merged

fix: pending-approval refresh + banner notifications + UI bundle#13
DocNR merged 3 commits into
mainfrom
fix/pending-approvals-refresh-and-banner

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented Apr 28, 2026

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 .inactive grace window, 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 produced a user-facing signal:

  • SharedStorage.queuePendingRequest writes to UserDefaults but didn't broadcast a refresh signal, so the @Observable AppState.pendingRequests stayed stale until HomeView.onAppear (tab switch).
  • L1 didn't schedule any local notification when it queued a pending request.

Refresh fix:

  • SharedStorage.queuePendingRequest / removePendingRequest / clearPendingRequests post .pendingRequestsUpdated.
  • AppState observes it in init and calls refreshPendingRequests() on main.
  • MainTabView also refreshes on scenePhase .active so cross-process NSE writes get picked up on app foreground (the in-process NotificationCenter post doesn't cross the NSE↔main-app boundary).

Banner fix:

  • New Shared/PendingApprovalBanner.swift: `schedule(requestId:clientPubkey:eventKind:)` + `clear(requestId:)`. Identifier `pending-approval-`.
  • `LightSigner.RequestResult` gains `pendingRequestId: String?`.
  • `ForegroundRelaySubscription` schedules a banner when L1 catches a request needing approval. `ClaveApp` foreground push handler also schedules (defensive — covers the rare case where NSE didn't process and main-app marked the event first).
  • `AppState.approvePendingRequest` / `denyPendingRequest` call `PendingApprovalBanner.clear`.
  • `ClaveApp.willPresent` distinguishes locally-scheduled banners (display directly), APNs with NSE-set non-empty title (display — was being suppressed by unconditional `completionHandler([])`), and APNs with empty title (suppress, NSE silent-success case).

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`)

  • Pending approvals card edge padding: added `.padding(.horizontal)` after `.background` so the orange-bordered card stops touching screen edges. Pre-existing since v1.0 UX sprint, not L1-caused, only surfaced once pending approvals started rendering reliably.
  • Snapshot protection (audit A10.1): new `SnapshotProtected.swift` ViewModifier overlays an opaque privacy view when scenePhase ≠ `.active`. Applied to `ExportKeySheet`, `ConnectSheet`, `QRCodeView`, `ApprovalSheet`. iOS captures the app-switcher snapshot during `.inactive`, so per-sheet overlays prevent leaking nsec / bunker secret / QR / incoming approval into the snapshot.
  • AvatarView name initials: gains optional `name: String?`. When set, shows up to two letters from the name (e.g. "Joe Bloggs" → "JB"; "Yakihonne" → "YA") in proportional font. Pubkey-prefix fallback unchanged. Gradient stays pubkey-derived so renames don't change avatar color. Updated four call sites.
  • ClientDetailView UX rework:
    • Tap client name (or pencil affordance) in header → existing rename alert. Bottom Rename button removed.
    • New toolbar overflow menu (`ellipsis.circle`, top-right): Connection Info, Rename, Unpair Client. Unpair was previously buried below the recent-activity list.
    • New `ConnectionInfoSheet`: name, origin URL, npub + hex pubkey (both copyable), trust level, first-connected/last-seen timestamps, total request count, paired relay set.

Test plan

  • `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 TestFlight) — scenarios:
    • Foregrounded Clave + sign request needing approval → banner pops within 1s, pending row appears immediately (no tab switch needed).
    • Backgrounded Clave + sign request → NSE banner pops; opening Clave shows pending row regardless of starting tab.
    • Brief `.inactive` (control-center swipe) + sign → exactly one banner.
    • Approve/Deny → banner clears from Notification Center; pending row disappears.
    • Full-trust client signing → still silent (no regression).
    • App-switcher: pull up app switcher with ExportKeySheet / ConnectSheet / QRCodeView / ApprovalSheet open → snapshot shows lock-shield overlay, not the sensitive content.
    • Rename a client; verify avatar initials update from pubkey prefix to name initials in HomeView client row, ClientDetailView header, and ApprovalSheet header.
    • ClientDetailView toolbar Menu: Connection Info shows correct values; Rename works; Unpair confirms then dismisses.

🤖 Generated with Claude Code

DocNR and others added 2 commits April 27, 2026 23:17
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>
@DocNR DocNR changed the title fix(L1): pending-approval UI refresh + banner notifications fix: pending-approval refresh + banner notifications + UI bundle Apr 28, 2026
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>
@DocNR DocNR merged commit af149a4 into main Apr 28, 2026
@DocNR DocNR deleted the fix/pending-approvals-refresh-and-banner branch April 28, 2026 13:36
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>
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>
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