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>
This commit is contained in:
nrobi144
2026-04-29 09:35:33 +03:00
parent 66310ca336
commit 250bb5a1ad
24 changed files with 7404 additions and 17 deletions
@@ -0,0 +1,78 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun SigningAwareButton(
signingState: SigningState,
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
when (signingState.state) {
is SigningOpState.Pending -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary,
)
}
}
is SigningOpState.Error -> {
Box(modifier = modifier.size(32.dp), contentAlignment = Alignment.Center) {
Icon(
icon,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
}
}
is SigningOpState.Idle -> {
IconButton(onClick = onClick, modifier = modifier.size(32.dp)) {
Icon(
icon,
contentDescription = contentDescription,
tint = tint,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.cancellation.CancellationException
sealed class SigningOpState {
data object Idle : SigningOpState()
data object Pending : SigningOpState()
data class Error(
val message: String,
) : SigningOpState()
}
/**
* Global signing status any [SigningState] instance updates this when signing starts/ends.
* Observe [globalState] from a screen-level composable to show a persistent status bar.
*/
object GlobalSigningStatus {
var globalState by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
private val activeCount = AtomicInteger(0)
fun onPending() {
activeCount.incrementAndGet()
globalState = SigningOpState.Pending
}
fun onIdle() {
if (activeCount.decrementAndGet() <= 0) {
activeCount.set(0)
globalState = SigningOpState.Idle
}
}
fun onError(message: String) {
activeCount.decrementAndGet()
globalState = SigningOpState.Error(message)
}
}
/**
* Per-component state holder for remote signer operations.
* Tracks Idle/Pending/Error no Success state (existing UI handles success).
* Error resets to Idle on next execute() call (tap = retry).
* Also updates [GlobalSigningStatus] for screen-level status bar.
*/
@Stable
class SigningState {
var state by mutableStateOf<SigningOpState>(SigningOpState.Idle)
private set
var errorMessage: String? = null
private set
suspend fun <T> execute(block: suspend () -> T): T? {
if (state is SigningOpState.Pending) return null
state = SigningOpState.Pending
GlobalSigningStatus.onPending()
errorMessage = null
return try {
val result = withContext(Dispatchers.IO) { block() }
state = SigningOpState.Idle
GlobalSigningStatus.onIdle()
result
} catch (e: CancellationException) {
state = SigningOpState.Idle
GlobalSigningStatus.onIdle()
throw e
} catch (e: SignerExceptions.TimedOutException) {
setError("Signer timed out")
null
} catch (e: SignerExceptions.ManuallyUnauthorizedException) {
setError("Signing rejected")
null
} catch (e: SignerExceptions) {
setError(e.message ?: "Signing failed")
null
} catch (e: Exception) {
setError(e.message ?: "Operation failed")
null
}
}
private fun setError(message: String) {
errorMessage = message
state = SigningOpState.Error(message)
GlobalSigningStatus.onError(message)
}
}
@@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.signing
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
@Composable
fun SigningStatusBar(
opState: SigningOpState,
modifier: Modifier = Modifier,
) {
var showPending by remember { mutableStateOf(false) }
LaunchedEffect(opState) {
if (opState is SigningOpState.Pending) {
delay(500)
showPending = true
} else {
showPending = false
}
}
val visible = showPending || opState is SigningOpState.Error
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier,
) {
when (opState) {
is SigningOpState.Pending -> {
var elapsedSeconds by remember { mutableLongStateOf(0L) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
elapsedSeconds++
}
}
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Text(
text = "Waiting for signer approval... (${elapsedSeconds}s)",
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Error -> {
Snackbar(
shape = RoundedCornerShape(8.dp),
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(horizontal = 16.dp),
) {
Text(
text = opState.message,
style = MaterialTheme.typography.bodyMedium,
)
}
}
is SigningOpState.Idle -> {}
}
}
}