feat(nip46): preview the event as a signed note in the sign dialog

The per-op consent dialog now renders a sign_event/publish request as a real
NoteCompose preview — what the note will actually look like once signed
(text + media + mentions, authored by the signing account) — instead of only
a quoted content snippet. The "Show event" JSON toggle stays as a fallback for
anyone who wants the raw payload.

Reuses the live AccountViewModel via CallSessionBridge (the same handle
CallActivity uses to render app UI from a standalone Activity), builds a
transient unsigned Note with RumorAssembler.assembleRumor + createTempDraftNote
(never persisted/verified — the same path the composer uses to preview an
unsent post), and passes EmptyNav so taps don't navigate out. SignerConsentInfo
carries the EventTemplate; both the NIP-46 bridge and the napplet
buildSignerConsentInfo populate it for Publish/SignEvent.

Falls back to the content quote + JSON when the AccountViewModel isn't
available (main Activity gone) or the op has no event (encrypt/decrypt).

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 4fa41f1a67
commit 99f0c61f72
5 changed files with 66 additions and 2 deletions
@@ -22,6 +22,19 @@ the client.
- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst
opens the signer screen and pairs (cold start AND already-running).
## Note preview in the sign dialog (device-only)
- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose
preview** (text + media + mentions, authored by the signing account), with
the "Show event" JSON toggle still available below it.
- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent.
- [ ] When the main Activity is gone (app fully backgrounded, only the signer
foreground service alive → `CallSessionBridge.accountViewModel` is null),
the dialog falls back to the plain content quote + JSON without crashing.
- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone
dialog Activity; if it reads a CompositionLocal only provided by the main
scaffold it could crash at runtime (compiles fine). Verify on device; if it
misbehaves, the JSON fallback path is one boolean away.
## Consent (Tier 1)
- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret
shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any
@@ -66,7 +66,13 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
class SignerConsentActivity : ComponentActivity() {
@@ -119,6 +125,25 @@ private fun SignerConsentDialog(
val scrollState = rememberScrollState()
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
// Reuse the live AccountViewModel (via CallSessionBridge, the same handle CallActivity uses) to
// render the unsigned event as a real note preview — what it will actually look like. Best-effort:
// if the main Activity is gone (only the foreground signer service alive) we fall back to the JSON.
val accountViewModel = remember { CallSessionBridge.accountViewModel }
val previewNav = remember { EmptyNav() }
val previewNote =
remember(info, accountViewModel) {
val template = info.previewTemplate
val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey
if (template != null && author != null && accountViewModel != null) {
runCatching {
val unsigned = RumorAssembler.assembleRumor<Event>(author, template)
accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author))
}.getOrNull()
} else {
null
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -173,7 +198,7 @@ private fun SignerConsentDialog(
}
}
val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
if (hasContent) {
Spacer(Modifier.height(12.dp))
Surface(
@@ -185,7 +210,16 @@ private fun SignerConsentDialog(
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(12.dp)) {
if (info.contentPreview.isNotBlank()) {
if (previewNote != null && accountViewModel != null) {
// The event rendered as it will look once signed.
NoteCompose(
baseNote = previewNote,
isQuotedNote = true,
quotesLeft = 0,
accountViewModel = accountViewModel,
nav = previewNav,
)
} else if (info.contentPreview.isNotBlank()) {
Text(
"${info.contentPreview}",
style = MaterialTheme.typography.bodySmall,
@@ -24,6 +24,8 @@ import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -50,6 +52,12 @@ data class SignerConsentInfo(
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a
* note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
)
/**
@@ -118,6 +118,7 @@ object Nip46ConsentBridge {
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
@@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -86,6 +87,12 @@ fun buildSignerConsentInfo(
}
else -> ""
}
val previewTemplate =
when (request) {
is NappletRequest.Publish -> EventTemplate<Event>(TimeUtils.now(), request.kind, request.tags, request.content)
is NappletRequest.SignEvent -> EventTemplate<Event>(request.createdAt, request.kind, request.tags, request.content)
else -> null
}
return SignerConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
@@ -94,6 +101,7 @@ fun buildSignerConsentInfo(
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
previewTemplate = previewTemplate,
)
}