mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Merge pull request #4316 from vitorpamplona/claude/relaxed-heisenberg-4j7wc5
Add NIP-42 relay auth event rendering and preview support
This commit is contained in:
+20
-4
@@ -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
|
||||
|
||||
+54
-11
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-1
@@ -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))
|
||||
|
||||
+22
@@ -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<Event>(auth.createdAt, auth.kind, auth.tags, auth.content)
|
||||
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(RelayAuthEvent.KIND))
|
||||
|
||||
assertEquals("relay.damus.io", info.contentPreview)
|
||||
assertEquals(template, info.previewTemplate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRegularPostPreviewIsStillItsContent() =
|
||||
runTest {
|
||||
val template = EventTemplate<Event>(1L, 1, emptyArray(), " hello world ")
|
||||
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(1))
|
||||
|
||||
assertEquals("hello world", info.contentPreview)
|
||||
}
|
||||
}
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
|
||||
|
||||
/**
|
||||
* One `relay` tag of a NIP-42 auth event (kind 22242), as a person deciding whether to sign it
|
||||
* must see it.
|
||||
*
|
||||
* The requesting app writes that tag verbatim, and a fresh signature over it lets the app log in
|
||||
* to that relay as the user (see `NostrSignerPermissionLedger` on why 22242 is never auto-allowed).
|
||||
* So the address shown is the one being signed: never the normalizer's repaired version (it adds
|
||||
* schemes, trims `%20`, splits run-on URLs), and never a value whose hidden characters could make
|
||||
* it read as a different relay.
|
||||
*/
|
||||
@Immutable
|
||||
data class RelayAuthTarget(
|
||||
/** The tag value, with invisible and direction-changing characters escaped as `\uXXXX`. */
|
||||
val display: String,
|
||||
/** For the relay's icon and NIP-11 info only. Null when the value is not a relay we could reach. */
|
||||
val url: NormalizedRelayUrl?,
|
||||
/** The value is not a plain relay address: it does not normalize, needed rewriting, or hid characters. */
|
||||
val unusual: Boolean,
|
||||
)
|
||||
|
||||
/** Every `relay` tag of a NIP-42 auth event or template, in tag order. Usually just one. */
|
||||
fun TagArray.relayAuthTargets(): List<RelayAuthTarget> =
|
||||
mapNotNull { tag ->
|
||||
if (tag.size > 1 && tag[0] == RelayTag.TAG_NAME && tag[1].isNotEmpty()) relayAuthTarget(tag[1]) else null
|
||||
}
|
||||
|
||||
fun relayAuthTarget(raw: String): RelayAuthTarget {
|
||||
val escaped = escapeHiddenChars(raw)
|
||||
val url = RelayUrlNormalizer.normalizeOrNull(raw)
|
||||
val plain = url != null && escaped == raw && sameAddress(raw, url)
|
||||
return RelayAuthTarget(
|
||||
// Only the default `wss://` is dropped: an insecure `ws://` stays visible.
|
||||
display = if (plain) url.url.removePrefix("wss://").removeSuffix("/") else escaped,
|
||||
url = url,
|
||||
unusual = !plain,
|
||||
)
|
||||
}
|
||||
|
||||
/** Only scheme/host case and a trailing slash may differ; anything else means the normalizer rewrote it. */
|
||||
private fun sameAddress(
|
||||
raw: String,
|
||||
url: NormalizedRelayUrl,
|
||||
) = raw.trimEnd('/').equals(url.url.trimEnd('/'), ignoreCase = true)
|
||||
|
||||
private fun isHidden(c: Char) =
|
||||
c.isISOControl() ||
|
||||
c in '\u200B'..'\u200F' ||
|
||||
c in '\u202A'..'\u202E' ||
|
||||
c in '\u2060'..'\u2069' ||
|
||||
c == '\u061C' ||
|
||||
c == '\uFEFF'
|
||||
|
||||
private fun escapeHiddenChars(raw: String): String {
|
||||
if (raw.none(::isHidden)) return raw
|
||||
return buildString(raw.length + 16) {
|
||||
raw.forEach { c ->
|
||||
if (isHidden(c)) {
|
||||
append("\\u")
|
||||
append(
|
||||
c.code
|
||||
.toString(16)
|
||||
.uppercase()
|
||||
.padStart(4, '0'),
|
||||
)
|
||||
} else {
|
||||
append(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.relayClient.auth
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class RelayAuthTargetsTest {
|
||||
@Test
|
||||
fun aPlainRelayShowsAsItsHost() {
|
||||
val target = relayAuthTarget("wss://relay.damus.io")
|
||||
assertEquals("relay.damus.io", target.display)
|
||||
assertNotNull(target.url)
|
||||
assertFalse(target.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun trailingSlashAndHostCaseAreNotUnusual() {
|
||||
assertFalse(relayAuthTarget("wss://relay.damus.io/").unusual)
|
||||
assertFalse(relayAuthTarget("wss://Relay.Damus.io").unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInsecureSchemeStaysVisible() {
|
||||
val target = relayAuthTarget("ws://relay.example.com")
|
||||
assertEquals("ws://relay.example.com", target.display)
|
||||
assertFalse(target.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aValueTheNormalizerWouldRepairIsShownVerbatim() {
|
||||
// The normalizer adds a scheme / trims %20; the user must see what is actually signed.
|
||||
val noScheme = relayAuthTarget("relay.damus.io")
|
||||
assertEquals("relay.damus.io", noScheme.display)
|
||||
assertTrue(noScheme.unusual)
|
||||
|
||||
val padded = relayAuthTarget("wss://relay.damus.io%20")
|
||||
assertEquals("wss://relay.damus.io%20", padded.display)
|
||||
assertTrue(padded.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hiddenDirectionCharactersAreEscapedNotRendered() {
|
||||
// RLO would make "wss://\u202Eoi.live.xyz" read as a different host.
|
||||
val target = relayAuthTarget("wss://\u202Eoi.live.xyz")
|
||||
assertEquals("wss://\\u202Eoi.live.xyz", target.display)
|
||||
assertTrue(target.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroWidthCharactersAreEscaped() {
|
||||
val target = relayAuthTarget("wss://relay.da\u200Bmus.io")
|
||||
assertEquals("wss://relay.da\\u200Bmus.io", target.display)
|
||||
assertTrue(target.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun garbageIsStillShownAndFlagged() {
|
||||
val target = relayAuthTarget("not a relay")
|
||||
assertEquals("not a relay", target.display)
|
||||
assertNull(target.url)
|
||||
assertTrue(target.unusual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsEveryRelayTagInOrderAndSkipsTheRest() {
|
||||
val tags =
|
||||
arrayOf(
|
||||
arrayOf("relay", "wss://a.example.com"),
|
||||
arrayOf("challenge", "abc"),
|
||||
arrayOf("relay", ""),
|
||||
arrayOf("relay"),
|
||||
arrayOf("relay", "wss://b.example.com"),
|
||||
)
|
||||
assertEquals(listOf("a.example.com", "b.example.com"), tags.relayAuthTargets().map { it.display })
|
||||
}
|
||||
}
|
||||
@@ -1134,6 +1134,11 @@
|
||||
<string name="relay_auth_session_section">Just for now</string>
|
||||
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
|
||||
<string name="relay_auth_forget_session">Forget this login</string>
|
||||
<string name="relay_auth_event_title">Relay login</string>
|
||||
<string name="relay_auth_event_explainer">Not a post. It logs you in to this relay: whoever holds it can read what the relay only shows to you, like your private messages.</string>
|
||||
<string name="relay_auth_event_unusual_relay">This is not a plain relay address. Check it before signing.</string>
|
||||
<string name="relay_auth_event_no_relay">No relay named in this login</string>
|
||||
<string name="relay_auth_event_challenge">Challenge: %1$s</string>
|
||||
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
|
||||
<string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won't log in to %1$s.</string>
|
||||
<string name="relay_auth_blocked_section">Blocked by your block list</string>
|
||||
@@ -4506,6 +4511,7 @@
|
||||
<string name="napplet_op_decrypt">read your private messages</string>
|
||||
<string name="napplet_op_decrypt_from">read your private messages with %1$s</string>
|
||||
<string name="napplet_op_encrypt">encrypt a message</string>
|
||||
<string name="napplet_op_relay_login">log in to relays as you</string>
|
||||
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
|
||||
<string name="napplet_permissions_title">Connected Apps</string>
|
||||
<string name="napplets">nApplets</string>
|
||||
|
||||
+61
@@ -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 })
|
||||
}
|
||||
}
|
||||
+6
@@ -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)
|
||||
}
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.note.types
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthTarget
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTarget
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_challenge
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_explainer
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_no_relay
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_unusual_relay
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelay
|
||||
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||
|
||||
/**
|
||||
* NIP-42 kind 22242: a login to a relay. It is not a post — its content is empty and nobody but
|
||||
* the relay ever sees it — so it renders as "log in to <relay>" instead of falling through to the
|
||||
* text-note layout. Mostly met in signer consent prompts, where an app asks to sign one, so the
|
||||
* relay is shown exactly as the event names it (see [RelayAuthTarget]).
|
||||
*/
|
||||
@Composable
|
||||
fun RenderRelayAuth(
|
||||
baseNote: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val noteEvent = baseNote.event as? RelayAuthEvent ?: return
|
||||
val relays = remember(noteEvent) { noteEvent.tags.relayAuthTargets() }
|
||||
val challenge = remember(noteEvent) { noteEvent.challenge() }
|
||||
|
||||
RelayAuthCard(relays, challenge) { relay ->
|
||||
RenderRelay(relay, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayAuthCard(
|
||||
relays: List<RelayAuthTarget>,
|
||||
challenge: String?,
|
||||
relayIcon: @Composable (NormalizedRelayUrl) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = stringRes(Res.string.relay_auth_event_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringRes(Res.string.relay_auth_event_explainer),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (relays.isEmpty()) {
|
||||
Text(
|
||||
text = stringRes(Res.string.relay_auth_event_no_relay),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(start = 32.dp),
|
||||
)
|
||||
}
|
||||
|
||||
relays.forEach { relay ->
|
||||
Column(modifier = Modifier.padding(start = 32.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val url = relay.url
|
||||
if (url != null) {
|
||||
relayIcon(url)
|
||||
} else {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
// Never ellipsized: the whole address is what the user is agreeing to.
|
||||
Text(
|
||||
text = relay.display,
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (relay.unusual) MaterialTheme.colorScheme.error else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
if (relay.unusual) {
|
||||
Text(
|
||||
text = stringRes(Res.string.relay_auth_event_unusual_relay),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
challenge?.let {
|
||||
Text(
|
||||
text = stringRes(Res.string.relay_auth_event_challenge, it),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RelayAuthCardPreview() {
|
||||
ThemeComparisonColumn {
|
||||
RelayAuthCard(
|
||||
relays = listOf(relayAuthTarget("wss://relay.damus.io"), relayAuthTarget("not a relay")),
|
||||
challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f",
|
||||
) { relay ->
|
||||
RenderRelayIcon(
|
||||
displayUrl = relay.url,
|
||||
iconUrl = null,
|
||||
loadProfilePicture = false,
|
||||
loadRobohash = true,
|
||||
pingInMs = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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,
|
||||
|
||||
+149
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user