feat(nip46): show which account is acting on the consent dialogs

Both signer dialogs now render the account's avatar + display name instead of
a raw pubkey / coordinate hex, so it's clear which logged-in identity is
approving, signing, encrypting, or decrypting:

- Connect dialog: replaces the client transport-pubkey line with the account
  being connected to (avatar + name).
- Per-op dialog: replaces the meaningless coordinate hex with the account that
  would sign/encrypt/decrypt.

The account is resolved from the coordinate's signer pubkey
(Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a
shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash
fallback). SignerConnectInfo/SignerConsentInfo carry the account
name/picture/pubkey; the napplet/browser paths leave them null and keep their
existing domain line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:32 +00:00
parent 00ec826e8b
commit 4fa41f1a67
7 changed files with 138 additions and 13 deletions
@@ -0,0 +1,68 @@
/*
* 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.connectedApps.consent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
/**
* The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
* identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a
* raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture].
*/
@Composable
fun ConnectedAccountRow(
name: String,
picture: String?,
pubKey: String?,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RobohashFallbackAsyncImage(
robot = pubKey ?: name,
model = picture,
contentDescription = null,
modifier = Modifier.size(26.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Text(
name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -168,12 +168,17 @@ private fun SignerConnectScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account is being connected (avatar + name), not a raw pubkey.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
} else {
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
Spacer(Modifier.height(16.dp))
@@ -33,6 +33,14 @@ data class SignerConnectInfo(
val coordinate: String,
val domain: String,
val iconUrl: String? = null,
/**
* The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When
* [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back
* to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
)
/**
@@ -167,12 +167,10 @@ private fun SignerConsentDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
}
val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
@@ -42,6 +42,14 @@ data class SignerConsentInfo(
*/
val rawData: String = "",
val iconUrl: String? = null,
/**
* The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which
* logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the
* robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
)
/**
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.nip46Signer
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
@@ -29,6 +30,7 @@ import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.label
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
@@ -65,7 +67,18 @@ object Nip46ConsentBridge {
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "")
val info = SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image)
// The identity being connected to lives in the coordinate; show it as an avatar + name.
val face = accountFace(coordinate)
val info =
SignerConnectInfo(
appletTitle = title,
coordinate = coordinate,
domain = domain,
iconUrl = meta?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
)
// Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't
// hold the single-consumer loop hostage against every other client.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
@@ -92,6 +105,7 @@ object Nip46ConsentBridge {
""
}
val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else ""
val face = accountFace(coordinate)
val consentInfo =
SignerConsentInfo(
appletTitle = title,
@@ -101,10 +115,26 @@ object Nip46ConsentBridge {
contentPreview = preview,
rawData = rawData,
iconUrl = info?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConsentCoordinator.requestConsent(context, consentInfo)
} ?: SignerOpGrant.DenyOnce
}
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
private fun accountFace(coordinate: String): AccountFace {
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
private data class AccountFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
}
@@ -254,6 +254,14 @@ class Nip46PermissionAuthorizer(
/** The client pubkey of a `nip46:<signer>:<client>` coordinate, or `null` if it is not one. */
fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null
/** The signer (account identity) pubkey of a `nip46:<signer>:<client>` coordinate, or `null`. */
fun signerPubKeyOf(coordinate: String): HexKey? =
if (coordinate.startsWith("$COORDINATE_PREFIX:")) {
coordinate.substringAfter("$COORDINATE_PREFIX:").substringBefore(':').ifBlank { null }
} else {
null
}
/** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */
fun BunkerRequest.toSignerOp(): NostrSignerOp? =
when (this) {