From 2e3e8189458a4fc3b9444df92b52d591a7cede8f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 18:38:23 +0000 Subject: [PATCH 1/4] feat: render NIP-42 relay logins (kind 22242) as a login card, not a post Kind 22242 had no NoteCompose renderer, so signer consent prompts (NIP-46 bunker, napplets) previewed an auth request as an empty text note. - commonsUI: RelayAuthCard shows "Relay login", a "nothing is posted" explainer, each relay (icon + URL) and the challenge; wired into the NoteCompose and ThreadFeedView dispatchers. - Consent: the op label reads "log in to relays as you" instead of "sign for Relay Auth (kind: 22242)", and the preview line names the relay(s) instead of the empty content (batched rows were blank). - quartz: TagArray.authRelays()/authChallenge() + RelayAuthEvent.relays(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B --- .../consent/Nip46ConsentInfoBuilder.kt | 25 ++- .../amethyst/napplet/NostrSignerOpLabels.kt | 15 +- .../napplets/ConnectedAppDetailScreen.kt | 9 +- .../consent/Nip46ConsentInfoBuilderTest.kt | 22 +++ .../composeResources/values/strings.xml | 5 + .../amethyst/commons/ui/note/NoteCompose.kt | 6 + .../commons/ui/note/types/RelayAuth.kt | 171 ++++++++++++++++++ .../loggedIn/threadview/ThreadFeedView.kt | 4 + .../quartz/nip42RelayAuth/RelayAuthEvent.kt | 4 +- .../quartz/nip42RelayAuth/TagArrayExt.kt | 31 ++++ 10 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt index c8eebe886a..fdcc046cd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt @@ -24,8 +24,12 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAut import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp 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.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip42RelayAuth.authRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.utils.Log @@ -91,10 +95,7 @@ object Nip46ConsentInfoBuilder { val preview = when { - request is BunkerRequestSign -> - request.event.content - .take(PREVIEW_MAX_CHARS) - .trim() + request is BunkerRequestSign -> signPreview(request.event.kind, request.event.tags, request.event.content) plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim() else -> "" } @@ -132,6 +133,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.authRelays().joinToString(", ") { it.displayUrl() } + } else { + content.take(PREVIEW_MAX_CHARS).trim() + } + /** * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 1d46b6ece6..75a9bb4fb4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -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_from 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.nip46_signer_allow_always_for import com.vitorpamplona.amethyst.commons.ui.loadStringRes 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.SignerConsentInfo 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.signers.EventTemplate import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.utils.TimeUtils /** Human-readable label for a [NostrSignerOp]. */ suspend fun NostrSignerOp.label(context: Context): String = 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.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt) 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 preview = when (request) { - is NappletRequest.Publish -> request.content.take(160).trim() - is NappletRequest.SignEvent -> request.content.take(160).trim() + is NappletRequest.Publish -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content) + is NappletRequest.SignEvent -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content) is NappletRequest.PublishEncrypted -> request.content.take(160).trim() // 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index cf52314cdb..16ebfbc601 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -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_from 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_permissions_ask_each_time 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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -838,7 +840,12 @@ private fun GrantOption( @Composable private fun NostrSignerOp.opLabel(): String = 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.Decrypt -> stringRes(Res.string.napplet_op_decrypt) is NostrSignerOp.DecryptFrom -> stringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty)) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilderTest.kt index 59cc601902..015a380df2 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilderTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilderTest.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp 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.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt @@ -173,4 +175,24 @@ class Nip46ConsentInfoBuilderTest { assertNull(info.narrowOp) assertNotNull(info.previewTemplate) } + + @Test + fun aRelayLoginPreviewNamesTheRelayInsteadOfItsEmptyContent() = + runTest { + val auth = RelayAuthEvent.build(RelayUrlNormalizer.normalize("wss://relay.damus.io"), "chal-123") + val template = EventTemplate(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(1L, 1, emptyArray(), " hello world ") + val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(1)) + + assertEquals("hello world", info.contentPreview) + } } diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 99b2ba83b9..82e11bc2a9 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1134,6 +1134,10 @@ Just for now Logged in until you restart Amethyst Forget this login + Relay login + Proves to the relay that you control this account. Nothing is posted. + No relay named in this login + Challenge: %1$s %1$s will ask again the next time it needs you. Not restored. “Never log in” is on, so Amethyst won't log in to %1$s. Blocked by your block list @@ -4505,6 +4509,7 @@ read your private messages read your private messages with %1$s encrypt a message + log in to relays as you sign for %1$s (kind: %2$d) Connected Apps nApplets diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt index 55adb2159f..bca367d13f 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCompose.kt @@ -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.RenderReaction 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.RenderRelayGroupMessage 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.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip40Expiration.expiration +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent @@ -1307,6 +1309,10 @@ private fun RenderNoteRow( RenderRelayLeaveRequest(baseNote, accountViewModel, nav) } + is RelayAuthEvent -> { + RenderRelayAuth(baseNote, accountViewModel, nav) + } + is PinListEvent -> { RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav) } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt new file mode 100644 index 0000000000..4771cdbf46 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt @@ -0,0 +1,171 @@ +/* + * 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.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.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.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.nip01Core.relay.normalizer.displayUrl +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 " instead of falling through to the + * text-note layout. Mostly met in signer consent prompts, where an app asks to sign one. + */ +@Composable +fun RenderRelayAuth( + baseNote: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = baseNote.event as? RelayAuthEvent ?: return + val relays = remember(noteEvent) { noteEvent.relays() } + val challenge = remember(noteEvent) { noteEvent.challenge() } + + RelayAuthCard(relays, challenge) { relay -> + RenderRelay(relay, accountViewModel, nav) + } +} + +@Composable +fun RelayAuthCard( + relays: List, + 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 -> + Row( + modifier = Modifier.padding(start = 32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + relayIcon(relay) + Text( + text = relay.displayUrl(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + challenge?.let { + Text( + text = stringRes(Res.string.relay_auth_event_challenge, it), + style = MaterialTheme.typography.labelSmall.copy(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(NormalizedRelayUrl("wss://relay.damus.io/")), + challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f", + ) { relay -> + RenderRelayIcon( + displayUrl = relay.url, + iconUrl = null, + loadProfilePicture = false, + loadRobohash = true, + pingInMs = 0, + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt index ca61da19fc..63a74cbada 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -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.RenderReaction 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.RenderRelayJoinRequest 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.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent @@ -1119,6 +1121,8 @@ private fun FullBleedNoteCompose( RenderRelayJoinRequest(baseNote, accountViewModel, nav) } else if (noteEvent is RelayLeaveRequestEvent) { RenderRelayLeaveRequest(baseNote, accountViewModel, nav) + } else if (noteEvent is RelayAuthEvent) { + RenderRelayAuth(baseNote, accountViewModel, nav) } else if (noteEvent is TextNoteModificationEvent) { RenderTextModificationEvent( note = baseNote, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index e030bafc20..8505a1caa0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -42,7 +42,9 @@ class RelayAuthEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) + fun relays() = tags.authRelays() + + fun challenge() = tags.authChallenge() companion object { const val KIND = 22242 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt new file mode 100644 index 0000000000..a60c94ef3f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt @@ -0,0 +1,31 @@ +/* + * 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.quartz.nip42RelayAuth + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull +import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag +import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag + +/** Every relay a NIP-42 auth event (or its unsigned template) logs into. Usually just one. */ +fun TagArray.authRelays() = mapNotNull(RelayTag::parse) + +fun TagArray.authChallenge() = fastFirstNotNullOfOrNull(ChallengeTag::parse) From 5bfe049db3aa21e4bb2357499566fecf7071ab0a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:40:50 +0000 Subject: [PATCH 2/4] fix: show the relay a 22242 login names verbatim, and warn when it's odd Audit of the relay-login card. The requesting app writes the relay tag of a 22242 verbatim, and a fresh signature lets it AUTH to that relay as the user (giftwrap inbox, paid-relay quota), so the relay is the decision. - The card and the consent preview showed the normalizer's *repaired* URL (it adds schemes, trims %20, splits run-on URLs) and silently dropped values it rejected, reporting "No relay named" for an event that does name one. They now show the tag value as signed. - Bidi overrides and zero-width characters in that value are escaped as \uXXXX instead of rendered, so an RLO can't make one host read as another. Any such value, or one that needed rewriting or won't normalize, is shown in the error color with a "check before signing" note. ws:// is no longer stripped from the display. - The explainer said "Nothing is posted", which undersold a login grant at the moment of consent; it now says what holding it allows. - The relay address is never ellipsized; the challenge line uses Text(fontFamily) instead of copying a TextStyle per recomposition. - The helper lives in commons (RelayAuthTarget); the quartz TagArray helpers from the previous commit had no callers left and are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B --- .../consent/Nip46ConsentInfoBuilder.kt | 5 +- .../relayClient/auth/RelayAuthTargets.kt | 98 ++++++++++++++++++ .../relayClient/auth/RelayAuthTargetsTest.kt | 99 +++++++++++++++++++ .../composeResources/values/strings.xml | 3 +- .../commons/ui/note/types/RelayAuth.kt | 62 ++++++++---- .../quartz/nip42RelayAuth/RelayAuthEvent.kt | 4 +- .../quartz/nip42RelayAuth/TagArrayExt.kt | 31 ------ 7 files changed, 245 insertions(+), 57 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt index fdcc046cd8..7d84156e75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/Nip46ConsentInfoBuilder.kt @@ -23,13 +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.toNarrowSignerOp 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.TagArray import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent -import com.vitorpamplona.quartz.nip42RelayAuth.authRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.utils.Log @@ -144,7 +143,7 @@ object Nip46ConsentInfoBuilder { content: String, ): String = if (kind == RelayAuthEvent.KIND) { - tags.authRelays().joinToString(", ") { it.displayUrl() } + tags.relayAuthTargets().joinToString(", ") { it.display } } else { content.take(PREVIEW_MAX_CHARS).trim() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt new file mode 100644 index 0000000000..2242192e13 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargets.kt @@ -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 = + 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) + } + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt new file mode 100644 index 0000000000..81bb15e4a9 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/auth/RelayAuthTargetsTest.kt @@ -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 }) + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index 82e11bc2a9..4dd494e2ba 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1135,7 +1135,8 @@ Logged in until you restart Amethyst Forget this login Relay login - Proves to the relay that you control this account. Nothing is posted. + 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. + This is not a plain relay address. Check it before signing. No relay named in this login Challenge: %1$s %1$s will ask again the next time it needs you. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt index 4771cdbf46..547e9a3aaa 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt @@ -32,6 +32,7 @@ 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 @@ -40,11 +41,15 @@ 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 @@ -52,13 +57,13 @@ 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.nip01Core.relay.normalizer.displayUrl 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 " instead of falling through to the - * text-note layout. Mostly met in signer consent prompts, where an app asks to sign one. + * 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( @@ -67,7 +72,7 @@ fun RenderRelayAuth( nav: INav, ) { val noteEvent = baseNote.event as? RelayAuthEvent ?: return - val relays = remember(noteEvent) { noteEvent.relays() } + val relays = remember(noteEvent) { noteEvent.tags.relayAuthTargets() } val challenge = remember(noteEvent) { noteEvent.challenge() } RelayAuthCard(relays, challenge) { relay -> @@ -77,7 +82,7 @@ fun RenderRelayAuth( @Composable fun RelayAuthCard( - relays: List, + relays: List, challenge: String?, relayIcon: @Composable (NormalizedRelayUrl) -> Unit, ) { @@ -122,26 +127,45 @@ fun RelayAuthCard( } relays.forEach { relay -> - Row( - modifier = Modifier.padding(start = 32.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - relayIcon(relay) - Text( - text = relay.displayUrl(), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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.copy(fontFamily = FontFamily.Monospace), + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -156,7 +180,7 @@ fun RelayAuthCard( private fun RelayAuthCardPreview() { ThemeComparisonColumn { RelayAuthCard( - relays = listOf(NormalizedRelayUrl("wss://relay.damus.io/")), + relays = listOf(relayAuthTarget("wss://relay.damus.io"), relayAuthTarget("not a relay")), challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f", ) { relay -> RenderRelayIcon( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt index 8505a1caa0..e030bafc20 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/RelayAuthEvent.kt @@ -42,9 +42,7 @@ class RelayAuthEvent( ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) - fun relays() = tags.authRelays() - - fun challenge() = tags.authChallenge() + fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) companion object { const val KIND = 22242 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt deleted file mode 100644 index a60c94ef3f..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip42RelayAuth/TagArrayExt.kt +++ /dev/null @@ -1,31 +0,0 @@ -/* - * 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.quartz.nip42RelayAuth - -import com.vitorpamplona.quartz.nip01Core.core.TagArray -import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull -import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag -import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag - -/** Every relay a NIP-42 auth event (or its unsigned template) logs into. Usually just one. */ -fun TagArray.authRelays() = mapNotNull(RelayTag::parse) - -fun TagArray.authChallenge() = fastFirstNotNullOfOrNull(ChallengeTag::parse) From 67c1b171bac82d68059dd95afebfb28829633a01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 19:56:44 +0000 Subject: [PATCH 3/4] fix: drop reactions, quick actions and the options menu from consent previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signer consent dialog (NIP-46 bunker and napplet requests, single and batched) previewed the event to sign through NoteCompose(isQuotedNote). That still carries the reaction row, the long-press quick-action popup and the ⋮ options menu, so a user could like, zap or broadcast an event that does not exist yet from a consent prompt. The preview now renders through NoteBody: author line and content only, with an empty moreOptions slot so the ⋮ menu is gone. A reply names its parent in one line (ReplyRenderType.LINE) instead of embedding the parent as a full NoteCompose with its own reaction row, and quotes stay unexpanded (quotesLeft = 0) for the same reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B --- .../consent/SignerConsentActivity.kt | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index e5dfa30710..d1ce473320 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -72,6 +72,7 @@ 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.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always @@ -98,9 +99,13 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage 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.stringRes +import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler @@ -347,11 +352,11 @@ private fun SignerConsentDialog( } /** - * The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will - * look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext + * The "what you're acting on" block: the unsigned event rendered as a note (what it will look like + * 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 * 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 private fun SignerConsentPreview(info: SignerConsentInfo) { @@ -381,13 +386,7 @@ private fun SignerConsentPreview(info: SignerConsentInfo) { ) { Column(modifier = Modifier.padding(12.dp)) { if (previewNote != null && accountViewModel != null) { - NoteCompose( - baseNote = previewNote, - isQuotedNote = true, - quotesLeft = 0, - accountViewModel = accountViewModel, - nav = previewNav, - ) + UnsignedNotePreview(previewNote, accountViewModel, previewNav) } else if (info.contentPreview.isNotBlank()) { Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall) } @@ -423,6 +422,42 @@ 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`). + */ +@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) + + Column { + 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 * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected From ad67564bbff3e352e6828e4323d9f665552c2d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:29:35 +0000 Subject: [PATCH 4/4] fix: make the consent preview read-only, not just reaction-free Audit follow-up. Dropping NoteCompose's reaction row left the content renderers' own actions live inside the signer consent preview: poll votes, calendar RSVPs, badge accepts, follow-set toggles, channel/community joins, plus profile/link taps and the relay card's info link. A tap there would sign a second event against one that may never exist. - New commonsUI Modifier.blockInteractions(): consumes presses/releases in the Initial pointer pass (so no child click/long-click starts) and the activation keys (Enter/Space/D-pad center; Tab still leaves), while leaving movement alone so a drag on the preview still scrolls the dialog. Covered by a desktop Compose UI test with no-modifier controls. - With taps blocked, "Show more" could no longer be reached, so the preview seeds ShowFullTextCache: everything being signed is shown in full without a tap. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B --- .../consent/SignerConsentActivity.kt | 12 +- .../ui/components/BlockInteractions.kt | 61 +++++++ .../desktop/ui/BlockInteractionsUiTest.kt | 149 ++++++++++++++++++ 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BlockInteractions.kt create mode 100644 desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/BlockInteractionsUiTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index d1ce473320..7f6200b68f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -98,6 +98,8 @@ 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.service.call.CallSessionBridge 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.INav import com.vitorpamplona.amethyst.commons.ui.note.NoteBody @@ -428,7 +430,8 @@ private fun SignerConsentPreview(info: SignerConsentInfo) { * 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`). + * a full note, and quotes are not expanded (`quotesLeft = 0`). Content-level actions are blocked + * too ([blockInteractions]). */ @Composable private fun UnsignedNotePreview( @@ -440,8 +443,13 @@ private fun UnsignedNotePreview( 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) } - Column { + // 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, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BlockInteractions.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BlockInteractions.kt new file mode 100644 index 0000000000..041dd6b5aa --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/BlockInteractions.kt @@ -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 }) + } + } diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/BlockInteractionsUiTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/BlockInteractionsUiTest.kt new file mode 100644 index 0000000000..ce45ba09cf --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/ui/BlockInteractionsUiTest.kt @@ -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) + } +}