Files
amethyst/docs/plans/2026-03-20-feat-remote-signer-loading-error-ux-plan.md
T
nrobi144 250bb5a1ad feat(multi-account): display names, middle-truncated npub, npub-only account fix
Account switcher dropdown improvements:
- Two-row display: Display Name on top, npub (middle-truncated) below
  e.g. 'Alice' / 'npub1abc...wxyz · Bunker'
- Middle-truncation for npub: shows first 10 + last 6 chars
- Resolves display names from DesktopLocalCache user metadata
- Confirmation dialog also shows display name
- npub-only (view-only) accounts now persist to encrypted storage
  (ensureCurrentAccountInStorage called in onLoginSuccess)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-29 09:35:33 +03:00

206 lines
9.0 KiB
Markdown

---
title: "feat: Remote Signer Loading & Error UX"
type: feat
status: active
date: 2026-03-20
origin: docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md
---
# feat: Remote Signer Loading & Error UX
## Overview
Add proper loading indicators and error handling for all NIP-46 remote signer (bunker) operations. Currently, signing timeouts crash as AWT system popup. Fix with a lightweight `SigningState` holder + catch-all helper, reusing existing UI patterns (inline `CircularProgressIndicator`, snackbar feedback).
## Problem Statement
- 9 signing call sites — only zaps and note publishing have error handling
- Reactions, reposts, bookmarks crash on 30s timeout (`TimedOutException`)
- No loading feedback while waiting for signer approval
- Exceptions propagate to AWT unhandled exception handler
## Proposed Solution
Two lightweight layers (see brainstorm: `docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md`):
### Layer 1: SigningState
Per-component state holder. Tracks Idle/Pending/Error — no Success state (existing UI like filled heart already handles success). Uses version counter to prevent race conditions on auto-reset.
**File:** `desktopApp/.../ui/signing/SigningState.kt`
```kotlin
@Stable
class SigningState {
var state by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
private var version = 0
suspend fun <T> execute(
operation: String,
block: suspend () -> T,
): T? {
val myVersion = ++version
state = SigningOpState.Pending(operation, System.currentTimeMillis())
return try {
val result = withContext(Dispatchers.IO) { block() }
state = SigningOpState.Idle
result
} catch (e: SignerExceptions.TimedOutException) {
setError(myVersion, "Signer timed out")
null
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
setError(myVersion, "Signing rejected")
null
} catch (e: SignerExceptions) {
setError(myVersion, e.message ?: "Signing failed")
null
}
}
private suspend fun setError(v: Int, message: String) {
state = SigningOpState.Error(message)
delay(5000)
if (version == v) state = SigningOpState.Idle
}
fun reset() { state = SigningOpState.Idle }
}
sealed class SigningOpState {
data object Idle : SigningOpState()
data class Pending(val operation: String, val startTimeMs: Long) : SigningOpState()
data class Error(val message: String) : SigningOpState()
}
```
Key design decisions from deepening:
- **Version counter** prevents race condition where auto-reset overwrites a new Pending state
- **`withContext(IO)`** wraps block — signer.sign() does network I/O, shouldn't block Main
- **`@Stable`** annotation for Compose recomposition efficiency
- **No `Success` state** — existing UI (filled heart, colored repost icon) already handles success
- **No `retry` lambda** — tapping the button again = retry
- **No `onError` callback** — snackbar feedback added at call site via existing `onZapFeedback` pattern
### Layer 2: UI Integration
No new composables — reuse existing patterns:
- **Loading:** Inline `CircularProgressIndicator(Modifier.size(16.dp))` (already used for zap loading)
- **Error in feed:** Reuse `onZapFeedback(ZapFeedback.Error(message))` → existing snackbar in Main.kt
- **Error in dialogs:** Button turns red (`errorContainer`), shows error text, resets after 5s
- **Elapsed time in dialogs:** `SigningStatusBar` at popup bottom (only for ComposeNoteDialog, not action buttons)
Per-button composable wrapping to prevent full-row recomposition:
```kotlin
@Composable
fun SigningAwareButton(
signingState: SigningState,
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (val s = signingState.state) {
is SigningOpState.Pending -> {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
}
is SigningOpState.Error -> {
Icon(icon, contentDescription, tint = MaterialTheme.colorScheme.error, modifier = modifier)
}
else -> {
IconButton(onClick = onClick, modifier = modifier.size(32.dp)) {
Icon(icon, contentDescription, Modifier.size(20.dp))
}
}
}
}
```
### SigningStatusBar (ComposeNoteDialog only)
Shows "Waiting for signer approval... (12s)" anchored to dialog bottom with rounded corners. Only used in ComposeNoteDialog — not for action buttons.
```kotlin
@Composable
fun SigningStatusBar(opState: SigningOpState, modifier: Modifier = Modifier)
```
## Implementation
### Phase 1: SigningState + UI Components in Commons
- Create `commons/src/commonMain/.../ui/signing/SigningState.kt`
- Create `commons/src/commonMain/.../ui/signing/SigningAwareButton.kt`
- Create `commons/src/commonMain/.../ui/signing/SigningStatusBar.kt`
- Verify `commons` has dependency on quartz (for `SignerExceptions`) — already does
### Phase 2: Wire all call sites
**NoteActions.kt** — reaction, repost, bookmark buttons:
- Each gets its own `remember { SigningState() }`
- Replace current icon rendering with `SigningAwareButton`
- On error, also call `onZapFeedback(ZapFeedback.Error(message))` for snackbar
- Keep existing `isLiked`, `isReposted` booleans — they track liked/reposted STATUS, not signing progress (different concerns)
**ComposeNoteDialog.kt** — post button:
- Add `SigningState` for signing, replace `isPosting` boolean
- Add `SigningStatusBar` at dialog bottom
- Button: spinner while pending, red + error text on failure
**UserProfileScreen.kt** — follow/unfollow + profile edit:
- Wrap existing signing calls in `signingState.execute()`
- Keep existing `FollowState` for follow status tracking
**BookmarksScreen.kt** — bookmark decryption:
- Wrap decryption in `signingState.execute()` to catch signer errors
## Acceptance Criteria
- [x] `SigningState` class with version-counter race protection
- [x] `SigningAwareButton` composable for action buttons
- [x] `SigningStatusBar` for ComposeNoteDialog
- [x] All 9 signing call sites wrapped — no more AWT crash dialogs
- [x] Inline spinner while signing pending
- [x] Error feedback via existing snackbar (feed) + red button state (dialogs)
- [x] Elapsed time shown in ComposeNoteDialog status bar
- [x] `isLiked`/`isReposted` booleans kept for status tracking (not conflated with signing)
- [x] `./gradlew :desktopApp:compileKotlin` passes
- [x] `./gradlew spotlessApply` clean
## Files Modified
| File | Change |
|------|--------|
| `commons/.../ui/signing/SigningState.kt` | **New** in commons/commonMain — ~40 LOC state holder (reusable across Android + Desktop) |
| `commons/.../ui/signing/SigningAwareButton.kt` | **New** in commons/commonMain — ~25 LOC per-button composable |
| `commons/.../ui/signing/SigningStatusBar.kt` | **New** in commons/commonMain — ~30 LOC dialog status bar |
| `desktopApp/.../ui/NoteActions.kt` | Wire SigningState to reaction/repost/bookmark |
| `desktopApp/.../ui/ComposeNoteDialog.kt` | Wire SigningState to post + add status bar |
| `desktopApp/.../ui/UserProfileScreen.kt` | Wire SigningState to follow/unfollow + edit |
| `desktopApp/.../ui/BookmarksScreen.kt` | Wire SigningState to bookmark decryption |
**Placement rationale:** `SigningState`, `SigningAwareButton`, `SigningStatusBar` use only Compose Multiplatform APIs (`mutableStateOf`, `CircularProgressIndicator`, Material3) and `SignerExceptions` from quartz. No platform-specific code. Placing in `commons/commonMain/` makes them reusable by Android app too.
## Sources & References
- **Origin:** [docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md](docs/brainstorms/2026-03-20-remote-signer-ux-brainstorm.md)
- `desktopApp/.../ui/NoteActions.kt:538-624` — existing zap loading pattern
- `desktopApp/.../ui/NoteActions.kt:98-116` — ZapFeedback sealed class (reuse for signing errors)
- `quartz/.../nip01Core/signers/SignerExceptions.kt` — all exception types
- `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt:289` — exception conversion, 30s timeout
### Deepening Findings Applied
| Agent | Finding | Applied |
|-------|---------|---------|
| **Coroutines** | Race condition on delay reset | Version counter pattern |
| **Coroutines** | block() needs IO dispatcher | `withContext(Dispatchers.IO)` wraps block |
| **Compose** | Needs `@Stable` annotation | Added to SigningState |
| **Compose** | Full row recomposes on single button change | Extract `SigningAwareButton` per-button composable |
| **Compose** | Don't conflate signing state with liked/reposted status | Keep existing booleans, SigningState only tracks signing |
| **Simplicity** | No `Success` state needed (UI handles it) | Removed |
| **Simplicity** | No `retry` lambda needed (tap = retry) | Removed |
| **Simplicity** | Reuse existing `ZapFeedback.Error` + snackbar for errors | Yes, no new feedback mechanism |
| **Simplicity** | No separate `SigningProgressIndicator` composable | Use inline `CircularProgressIndicator` in `SigningAwareButton` |