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>
9.0 KiB
title, type, status, date, origin
| title | type | status | date | origin |
|---|---|---|---|---|
| feat: Remote Signer Loading & Error UX | feat | active | 2026-03-20 | 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
@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@Stableannotation for Compose recomposition efficiency- No
Successstate — existing UI (filled heart, colored repost icon) already handles success - No
retrylambda — tapping the button again = retry - No
onErrorcallback — snackbar feedback added at call site via existingonZapFeedbackpattern
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:
SigningStatusBarat popup bottom (only for ComposeNoteDialog, not action buttons)
Per-button composable wrapping to prevent full-row recomposition:
@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.
@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
commonshas dependency on quartz (forSignerExceptions) — 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,isRepostedbooleans — they track liked/reposted STATUS, not signing progress (different concerns)
ComposeNoteDialog.kt — post button:
- Add
SigningStatefor signing, replaceisPostingboolean - Add
SigningStatusBarat 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
FollowStatefor follow status tracking
BookmarksScreen.kt — bookmark decryption:
- Wrap decryption in
signingState.execute()to catch signer errors
Acceptance Criteria
SigningStateclass with version-counter race protectionSigningAwareButtoncomposable for action buttonsSigningStatusBarfor ComposeNoteDialog- All 9 signing call sites wrapped — no more AWT crash dialogs
- Inline spinner while signing pending
- Error feedback via existing snackbar (feed) + red button state (dialogs)
- Elapsed time shown in ComposeNoteDialog status bar
isLiked/isRepostedbooleans kept for status tracking (not conflated with signing)./gradlew :desktopApp:compileKotlinpasses./gradlew spotlessApplyclean
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
desktopApp/.../ui/NoteActions.kt:538-624— existing zap loading patterndesktopApp/.../ui/NoteActions.kt:98-116— ZapFeedback sealed class (reuse for signing errors)quartz/.../nip01Core/signers/SignerExceptions.kt— all exception typesquartz/.../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 |