From ad67564bbff3e352e6828e4323d9f665552c2d14 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 20:29:35 +0000 Subject: [PATCH] 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) + } +}