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..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,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.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.nip19Bech32.entities.NPub +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.utils.Log @@ -91,10 +94,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 +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, * refuses, returns nothing, or takes too long yields [failureText], because a request whose 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..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 @@ -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 @@ -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.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.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 +354,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 +388,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 +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 * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected 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/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 35ee7b63ac..a445e2a187 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -1134,6 +1134,11 @@ Just for now Logged in until you restart Amethyst Forget this login + Relay login + 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. Not restored. “Never log in” is on, so Amethyst won't log in to %1$s. Blocked by your block list @@ -4506,6 +4511,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/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/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..547e9a3aaa --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/types/RelayAuth.kt @@ -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 " 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, + 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, + ) + } + } +} 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/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) + } +}