250bb5a1ad
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>
206 lines
9.0 KiB
Markdown
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` |
|