Merge pull request #4316 from vitorpamplona/claude/relaxed-heisenberg-4j7wc5

Add NIP-42 relay auth event rendering and preview support
This commit is contained in:
Vitor Pamplona
2026-10-02 19:20:27 -04:00
committed by GitHub
13 changed files with 734 additions and 19 deletions
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -91,10 +94,7 @@ object Nip46ConsentInfoBuilder {
val preview = val preview =
when { when {
request is BunkerRequestSign -> request is BunkerRequestSign -> signPreview(request.event.kind, request.event.tags, request.event.content)
request.event.content
.take(PREVIEW_MAX_CHARS)
.trim()
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim() plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
else -> "" else -> ""
} }
@@ -132,6 +132,22 @@ object Nip46ConsentInfoBuilder {
) )
} }
/**
* The one-line preview of an event an app asks to sign. Usually its content; a NIP-42 relay
* login has none, so it names the relay(s) instead — otherwise a batched row reads as a blank
* "sign Relay Auth" with nothing to say where the user is logging in.
*/
fun signPreview(
kind: Int,
tags: TagArray,
content: String,
): String =
if (kind == RelayAuthEvent.KIND) {
tags.relayAuthTargets().joinToString(", ") { it.display }
} else {
content.take(PREVIEW_MAX_CHARS).trim()
}
/** /**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails, * Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
* refuses, returns nothing, or takes too long yields [failureText], because a request whose * refuses, returns nothing, or takes too long yields [failureText], because a request whose
@@ -72,6 +72,7 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
@@ -97,10 +98,16 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.commons.ui.components.ShowFullTextCache
import com.vitorpamplona.amethyst.commons.ui.components.blockInteractions
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.NoteBody
import com.vitorpamplona.amethyst.commons.ui.note.observeEdits
import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
@@ -347,11 +354,11 @@ private fun SignerConsentDialog(
} }
/** /**
* The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will * The "what you're acting on" block: the unsigned event rendered as a note (what it will look like
* look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext * once signed, see [UnsignedNotePreview]) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can * for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity * always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
* is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in. * is gone (only the foreground signer service alive) the note is skipped and the JSON stands in.
*/ */
@Composable @Composable
private fun SignerConsentPreview(info: SignerConsentInfo) { private fun SignerConsentPreview(info: SignerConsentInfo) {
@@ -381,13 +388,7 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
if (previewNote != null && accountViewModel != null) { if (previewNote != null && accountViewModel != null) {
NoteCompose( UnsignedNotePreview(previewNote, accountViewModel, previewNav)
baseNote = previewNote,
isQuotedNote = true,
quotesLeft = 0,
accountViewModel = accountViewModel,
nav = previewNav,
)
} else if (info.contentPreview.isNotBlank()) { } else if (info.contentPreview.isNotBlank()) {
Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall) Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall)
} }
@@ -423,6 +424,48 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
} }
} }
/**
* The event as it will read once signed: author line and content, nothing else. Deliberately not
* [com.vitorpamplona.amethyst.commons.ui.note.NoteCompose], which wraps a note in a reaction row,
* a long-press quick-action popup and a ⋮ options menu — controls that make no sense on a consent
* prompt, and would react to, zap or broadcast an event that does not exist yet. For the same
* reason a reply names its parent in one line ([ReplyRenderType.LINE]) instead of embedding it as
* a full note, and quotes are not expanded (`quotesLeft = 0`). Content-level actions are blocked
* too ([blockInteractions]).
*/
@Composable
private fun UnsignedNotePreview(
note: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
// Renderers fade long content into this color, so it must match the preview Surface.
val surface = MaterialTheme.colorScheme.surfaceVariant
val backgroundColor = remember(surface) { mutableStateOf(surface) }
val editState = observeEdits(note, accountViewModel)
// Everything being signed must be readable without a tap (taps are blocked below), so the
// text renderer starts expanded instead of cutting long content behind "Show more".
remember(note.idHex) { ShowFullTextCache.cache.put(note.idHex, true) }
// Read-only: renderers carry their own actions (poll votes, RSVPs, badge accepts, profile and
// link taps) that would act on an event that does not exist yet. Drags still scroll the dialog.
Column(Modifier.blockInteractions()) {
NoteBody(
baseNote = note,
showAuthorPicture = true,
unPackReply = ReplyRenderType.LINE,
showSecondRow = false,
quotesLeft = 0,
backgroundColor = backgroundColor,
editState = editState,
accountViewModel = accountViewModel,
nav = nav,
// An empty slot replaces the default ⋮ options menu.
moreOptions = {},
)
}
}
/** /**
* Shown when more than one request is awaiting consent at once (the signer services requests * Shown when more than one request is awaiting consent at once (the signer services requests
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_relay_login
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.amethyst.connectedApps.consent.Nip46ConsentInfoBuilder
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -46,12 +48,19 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */ /** Human-readable label for a [NostrSignerOp]. */
suspend fun NostrSignerOp.label(context: Context): String = suspend fun NostrSignerOp.label(context: Context): String =
when (this) { when (this) {
is NostrSignerOp.SignKind -> loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind) is NostrSignerOp.SignKind ->
if (kind == RelayAuthEvent.KIND) {
// A relay login is not "signing" anything the user would recognise; say what it does.
loadStringRes(Res.string.napplet_op_relay_login)
} else {
loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
}
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt) NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt) NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty)) is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
@@ -96,8 +105,8 @@ suspend fun buildSignerConsentInfo(
val summary = (narrowOp ?: op).label(context) val summary = (narrowOp ?: op).label(context)
val preview = val preview =
when (request) { when (request) {
is NappletRequest.Publish -> request.content.take(160).trim() is NappletRequest.Publish -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content)
is NappletRequest.SignEvent -> request.content.take(160).trim() is NappletRequest.SignEvent -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content)
is NappletRequest.PublishEncrypted -> request.content.take(160).trim() is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
// Encryption shows the plaintext the page wants sealed; decryption has only ciphertext, // Encryption shows the plaintext the page wants sealed; decryption has only ciphertext,
// which tells the user nothing, so its preview stays empty and the counterparty in // which tells the user nothing, so its preview stays empty and the counterparty in
@@ -105,6 +105,7 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_decision_deny
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_relay_login
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind
import com.vitorpamplona.amethyst.commons.resources.napplet_permissions_ask_each_time import com.vitorpamplona.amethyst.commons.resources.napplet_permissions_ask_each_time
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
@@ -141,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnli
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -838,7 +840,12 @@ private fun GrantOption(
@Composable @Composable
private fun NostrSignerOp.opLabel(): String = private fun NostrSignerOp.opLabel(): String =
when (this) { when (this) {
is NostrSignerOp.SignKind -> stringRes(Res.string.napplet_op_sign_kind, kind) is NostrSignerOp.SignKind ->
if (kind == RelayAuthEvent.KIND) {
stringRes(Res.string.napplet_op_relay_login)
} else {
stringRes(Res.string.napplet_op_sign_kind, kind)
}
NostrSignerOp.Encrypt -> stringRes(Res.string.napplet_op_encrypt) NostrSignerOp.Encrypt -> stringRes(Res.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> stringRes(Res.string.napplet_op_decrypt) NostrSignerOp.Decrypt -> stringRes(Res.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> stringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty)) is NostrSignerOp.DecryptFrom -> stringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
@@ -173,4 +175,24 @@ class Nip46ConsentInfoBuilderTest {
assertNull(info.narrowOp) assertNull(info.narrowOp)
assertNotNull(info.previewTemplate) assertNotNull(info.previewTemplate)
} }
@Test
fun aRelayLoginPreviewNamesTheRelayInsteadOfItsEmptyContent() =
runTest {
val auth = RelayAuthEvent.build(RelayUrlNormalizer.normalize("wss://relay.damus.io"), "chal-123")
val template = EventTemplate<Event>(auth.createdAt, auth.kind, auth.tags, auth.content)
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(RelayAuthEvent.KIND))
assertEquals("relay.damus.io", info.contentPreview)
assertEquals(template, info.previewTemplate)
}
@Test
fun aRegularPostPreviewIsStillItsContent() =
runTest {
val template = EventTemplate<Event>(1L, 1, emptyArray(), " hello world ")
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(1))
assertEquals("hello world", info.contentPreview)
}
} }
@@ -0,0 +1,98 @@
/*
* 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.relayClient.auth
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
/**
* One `relay` tag of a NIP-42 auth event (kind 22242), as a person deciding whether to sign it
* must see it.
*
* The requesting app writes that tag verbatim, and a fresh signature over it lets the app log in
* to that relay as the user (see `NostrSignerPermissionLedger` on why 22242 is never auto-allowed).
* So the address shown is the one being signed: never the normalizer's repaired version (it adds
* schemes, trims `%20`, splits run-on URLs), and never a value whose hidden characters could make
* it read as a different relay.
*/
@Immutable
data class RelayAuthTarget(
/** The tag value, with invisible and direction-changing characters escaped as `\uXXXX`. */
val display: String,
/** For the relay's icon and NIP-11 info only. Null when the value is not a relay we could reach. */
val url: NormalizedRelayUrl?,
/** The value is not a plain relay address: it does not normalize, needed rewriting, or hid characters. */
val unusual: Boolean,
)
/** Every `relay` tag of a NIP-42 auth event or template, in tag order. Usually just one. */
fun TagArray.relayAuthTargets(): List<RelayAuthTarget> =
mapNotNull { tag ->
if (tag.size > 1 && tag[0] == RelayTag.TAG_NAME && tag[1].isNotEmpty()) relayAuthTarget(tag[1]) else null
}
fun relayAuthTarget(raw: String): RelayAuthTarget {
val escaped = escapeHiddenChars(raw)
val url = RelayUrlNormalizer.normalizeOrNull(raw)
val plain = url != null && escaped == raw && sameAddress(raw, url)
return RelayAuthTarget(
// Only the default `wss://` is dropped: an insecure `ws://` stays visible.
display = if (plain) url.url.removePrefix("wss://").removeSuffix("/") else escaped,
url = url,
unusual = !plain,
)
}
/** Only scheme/host case and a trailing slash may differ; anything else means the normalizer rewrote it. */
private fun sameAddress(
raw: String,
url: NormalizedRelayUrl,
) = raw.trimEnd('/').equals(url.url.trimEnd('/'), ignoreCase = true)
private fun isHidden(c: Char) =
c.isISOControl() ||
c in '\u200B'..'\u200F' ||
c in '\u202A'..'\u202E' ||
c in '\u2060'..'\u2069' ||
c == '\u061C' ||
c == '\uFEFF'
private fun escapeHiddenChars(raw: String): String {
if (raw.none(::isHidden)) return raw
return buildString(raw.length + 16) {
raw.forEach { c ->
if (isHidden(c)) {
append("\\u")
append(
c.code
.toString(16)
.uppercase()
.padStart(4, '0'),
)
} else {
append(c)
}
}
}
}
@@ -0,0 +1,99 @@
/*
* 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.relayClient.auth
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RelayAuthTargetsTest {
@Test
fun aPlainRelayShowsAsItsHost() {
val target = relayAuthTarget("wss://relay.damus.io")
assertEquals("relay.damus.io", target.display)
assertNotNull(target.url)
assertFalse(target.unusual)
}
@Test
fun trailingSlashAndHostCaseAreNotUnusual() {
assertFalse(relayAuthTarget("wss://relay.damus.io/").unusual)
assertFalse(relayAuthTarget("wss://Relay.Damus.io").unusual)
}
@Test
fun anInsecureSchemeStaysVisible() {
val target = relayAuthTarget("ws://relay.example.com")
assertEquals("ws://relay.example.com", target.display)
assertFalse(target.unusual)
}
@Test
fun aValueTheNormalizerWouldRepairIsShownVerbatim() {
// The normalizer adds a scheme / trims %20; the user must see what is actually signed.
val noScheme = relayAuthTarget("relay.damus.io")
assertEquals("relay.damus.io", noScheme.display)
assertTrue(noScheme.unusual)
val padded = relayAuthTarget("wss://relay.damus.io%20")
assertEquals("wss://relay.damus.io%20", padded.display)
assertTrue(padded.unusual)
}
@Test
fun hiddenDirectionCharactersAreEscapedNotRendered() {
// RLO would make "wss://\u202Eoi.live.xyz" read as a different host.
val target = relayAuthTarget("wss://\u202Eoi.live.xyz")
assertEquals("wss://\\u202Eoi.live.xyz", target.display)
assertTrue(target.unusual)
}
@Test
fun zeroWidthCharactersAreEscaped() {
val target = relayAuthTarget("wss://relay.da\u200Bmus.io")
assertEquals("wss://relay.da\\u200Bmus.io", target.display)
assertTrue(target.unusual)
}
@Test
fun garbageIsStillShownAndFlagged() {
val target = relayAuthTarget("not a relay")
assertEquals("not a relay", target.display)
assertNull(target.url)
assertTrue(target.unusual)
}
@Test
fun readsEveryRelayTagInOrderAndSkipsTheRest() {
val tags =
arrayOf(
arrayOf("relay", "wss://a.example.com"),
arrayOf("challenge", "abc"),
arrayOf("relay", ""),
arrayOf("relay"),
arrayOf("relay", "wss://b.example.com"),
)
assertEquals(listOf("a.example.com", "b.example.com"), tags.relayAuthTargets().map { it.display })
}
}
@@ -1134,6 +1134,11 @@
<string name="relay_auth_session_section">Just for now</string> <string name="relay_auth_session_section">Just for now</string>
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string> <string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
<string name="relay_auth_forget_session">Forget this login</string> <string name="relay_auth_forget_session">Forget this login</string>
<string name="relay_auth_event_title">Relay login</string>
<string name="relay_auth_event_explainer">Not a post. It logs you in to this relay: whoever holds it can read what the relay only shows to you, like your private messages.</string>
<string name="relay_auth_event_unusual_relay">This is not a plain relay address. Check it before signing.</string>
<string name="relay_auth_event_no_relay">No relay named in this login</string>
<string name="relay_auth_event_challenge">Challenge: %1$s</string>
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string> <string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
<string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won't log in to %1$s.</string> <string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won't log in to %1$s.</string>
<string name="relay_auth_blocked_section">Blocked by your block list</string> <string name="relay_auth_blocked_section">Blocked by your block list</string>
@@ -4506,6 +4511,7 @@
<string name="napplet_op_decrypt">read your private messages</string> <string name="napplet_op_decrypt">read your private messages</string>
<string name="napplet_op_decrypt_from">read your private messages with %1$s</string> <string name="napplet_op_decrypt_from">read your private messages with %1$s</string>
<string name="napplet_op_encrypt">encrypt a message</string> <string name="napplet_op_encrypt">encrypt a message</string>
<string name="napplet_op_relay_login">log in to relays as you</string>
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string> <string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
<string name="napplet_permissions_title">Connected Apps</string> <string name="napplet_permissions_title">Connected Apps</string>
<string name="napplets">nApplets</string> <string name="napplets">nApplets</string>
@@ -0,0 +1,61 @@
/*
* 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.components
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
private val ACTIVATION_KEYS = setOf(Key.Enter, Key.NumPadEnter, Key.Spacebar, Key.DirectionCenter)
/**
* Makes everything inside read-only: taps, long-presses and keyboard activation never reach a
* child, but drags pass through, so the content can sit inside a scrolling container. Used where
* a note is shown for inspection only, e.g. a signer consent prompt, whose renderers would
* otherwise vote, RSVP, accept badges or open quick actions on an event that does not exist yet.
*
* Presses and releases are consumed in the [PointerEventPass.Initial] pass, before any child sees
* them, and tap/click detectors ignore a consumed press. Movement is left alone, so the parent's
* scroll still claims the drag. Only the activation keys are swallowed: Tab still moves focus out.
*/
fun Modifier.blockInteractions(): Modifier =
this
.onPreviewKeyEvent { it.key in ACTIVATION_KEYS }
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial).consume()
do {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.fastForEach {
if (it.changedToDownIgnoreConsumed() || it.changedToUpIgnoreConsumed()) it.consume()
}
} while (event.changes.fastAny { it.pressed })
}
}
@@ -221,6 +221,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicationIndex
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicationSection import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicationSection
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAuth
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayGroupMessage import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayGroupMessage
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
@@ -372,6 +373,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
@@ -1307,6 +1309,10 @@ private fun RenderNoteRow(
RenderRelayLeaveRequest(baseNote, accountViewModel, nav) RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
} }
is RelayAuthEvent -> {
RenderRelayAuth(baseNote, accountViewModel, nav)
}
is PinListEvent -> { is PinListEvent -> {
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
} }
@@ -0,0 +1,195 @@
/*
* 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.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthTarget
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTarget
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_challenge
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_explainer
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_no_relay
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_unusual_relay
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelay
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
/**
* NIP-42 kind 22242: a login to a relay. It is not a post — its content is empty and nobody but
* the relay ever sees it — so it renders as "log in to <relay>" instead of falling through to the
* text-note layout. Mostly met in signer consent prompts, where an app asks to sign one, so the
* relay is shown exactly as the event names it (see [RelayAuthTarget]).
*/
@Composable
fun RenderRelayAuth(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? RelayAuthEvent ?: return
val relays = remember(noteEvent) { noteEvent.tags.relayAuthTargets() }
val challenge = remember(noteEvent) { noteEvent.challenge() }
RelayAuthCard(relays, challenge) { relay ->
RenderRelay(relay, accountViewModel, nav)
}
}
@Composable
fun RelayAuthCard(
relays: List<RelayAuthTarget>,
challenge: String?,
relayIcon: @Composable (NormalizedRelayUrl) -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.Key,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = stringRes(Res.string.relay_auth_event_title),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringRes(Res.string.relay_auth_event_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (relays.isEmpty()) {
Text(
text = stringRes(Res.string.relay_auth_event_no_relay),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 32.dp),
)
}
relays.forEach { relay ->
Column(modifier = Modifier.padding(start = 32.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
val url = relay.url
if (url != null) {
relayIcon(url)
} else {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = MaterialTheme.colorScheme.error,
)
}
// Never ellipsized: the whole address is what the user is agreeing to.
Text(
text = relay.display,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
color = if (relay.unusual) MaterialTheme.colorScheme.error else Color.Unspecified,
)
}
if (relay.unusual) {
Text(
text = stringRes(Res.string.relay_auth_event_unusual_relay),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
challenge?.let {
Text(
text = stringRes(Res.string.relay_auth_event_challenge, it),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 32.dp),
)
}
}
}
@Preview
@Composable
private fun RelayAuthCardPreview() {
ThemeComparisonColumn {
RelayAuthCard(
relays = listOf(relayAuthTarget("wss://relay.damus.io"), relayAuthTarget("not a relay")),
challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f",
) { relay ->
RenderRelayIcon(
displayUrl = relay.url,
iconUrl = null,
loadProfilePicture = false,
loadRobohash = true,
pingInMs = 0,
)
}
}
}
@@ -223,6 +223,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicMessage
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicationSection import com.vitorpamplona.amethyst.commons.ui.note.types.RenderPublicationSection
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAuth
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayLeaveRequest import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayLeaveRequest
@@ -341,6 +342,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
@@ -1119,6 +1121,8 @@ private fun FullBleedNoteCompose(
RenderRelayJoinRequest(baseNote, accountViewModel, nav) RenderRelayJoinRequest(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayLeaveRequestEvent) { } else if (noteEvent is RelayLeaveRequestEvent) {
RenderRelayLeaveRequest(baseNote, accountViewModel, nav) RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
} else if (noteEvent is RelayAuthEvent) {
RenderRelayAuth(baseNote, accountViewModel, nav)
} else if (noteEvent is TextNoteModificationEvent) { } else if (noteEvent is TextNoteModificationEvent) {
RenderTextModificationEvent( RenderTextModificationEvent(
note = baseNote, note = baseNote,
@@ -0,0 +1,149 @@
/*
* 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.desktop.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.pressKey
import androidx.compose.ui.test.requestFocus
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.components.blockInteractions
import org.junit.Rule
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* [blockInteractions] guards the signer consent preview: nothing inside may be clicked, long-pressed
* or keyboard-activated (a poll vote there would sign a second event), yet the dialog must still
* scroll when a drag starts on the preview.
*/
class BlockInteractionsUiTest {
@get:Rule
val compose = createComposeRule()
private val clicks = mutableIntStateOf(0)
private val longClicks = mutableIntStateOf(0)
private val scroll = ScrollState(0)
@OptIn(ExperimentalFoundationApi::class)
private fun setContent(blocked: Boolean) {
compose.setContent {
Box(Modifier.size(300.dp)) {
Column(Modifier.verticalScroll(scroll)) {
Column(Modifier.testTag("preview").then(if (blocked) Modifier.blockInteractions() else Modifier)) {
Button(onClick = { clicks.intValue++ }) { Text("Vote") }
Box(
Modifier
.testTag("pressable")
.fillMaxWidth()
.height(800.dp)
.combinedClickable(onLongClick = { longClicks.intValue++ }, onClick = { clicks.intValue++ }),
)
}
}
}
}
}
@Test
fun withoutTheModifierAClickLands() {
setContent(blocked = false)
compose.onNodeWithText("Vote").performClick()
compose.waitForIdle()
assertEquals(1, clicks.intValue)
}
@Test
fun withoutTheModifierALongPressLands() {
setContent(blocked = false)
compose.onNodeWithTag("pressable").performTouchInput { longClick() }
compose.waitForIdle()
assertEquals(1, longClicks.intValue)
}
@Test
fun withoutTheModifierEnterActivates() {
setContent(blocked = false)
compose.onNodeWithText("Vote").requestFocus()
compose.onNodeWithText("Vote").performKeyInput { pressKey(Key.Enter) }
compose.waitForIdle()
assertEquals(1, clicks.intValue)
}
@Test
fun aClickIsSwallowed() {
setContent(blocked = true)
compose.onNodeWithText("Vote").performClick()
compose.onNodeWithTag("pressable").performClick()
compose.waitForIdle()
assertEquals(0, clicks.intValue)
}
@Test
fun aLongPressIsSwallowed() {
setContent(blocked = true)
compose.onNodeWithTag("pressable").performTouchInput { longClick() }
compose.waitForIdle()
assertEquals(0, longClicks.intValue)
assertEquals(0, clicks.intValue)
}
@Test
fun keyboardActivationIsSwallowed() {
setContent(blocked = true)
compose.onNodeWithText("Vote").requestFocus()
compose.onNodeWithText("Vote").performKeyInput { pressKey(Key.Enter) }
compose.onNodeWithText("Vote").performKeyInput { pressKey(Key.Spacebar) }
compose.waitForIdle()
assertEquals(0, clicks.intValue)
}
@Test
fun aDragStartingOnThePreviewStillScrollsTheParent() {
setContent(blocked = true)
compose.onNodeWithTag("pressable").performTouchInput { swipeUp() }
compose.waitForIdle()
assertTrue(scroll.value > 0, "parent did not scroll: ${scroll.value}")
assertEquals(0, clicks.intValue)
}
}