mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +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.decryptCounterparty
|
||||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
|
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
|
||||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||||
|
import com.vitorpamplona.amethyst.commons.relayClient.auth.relayAuthTargets
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
|
||||||
import com.vitorpamplona.quartz.utils.Log
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
@@ -91,10 +94,7 @@ object Nip46ConsentInfoBuilder {
|
|||||||
|
|
||||||
val preview =
|
val preview =
|
||||||
when {
|
when {
|
||||||
request is BunkerRequestSign ->
|
request is BunkerRequestSign -> signPreview(request.event.kind, request.event.tags, request.event.content)
|
||||||
request.event.content
|
|
||||||
.take(PREVIEW_MAX_CHARS)
|
|
||||||
.trim()
|
|
||||||
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
|
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,22 @@ object Nip46ConsentInfoBuilder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one-line preview of an event an app asks to sign. Usually its content; a NIP-42 relay
|
||||||
|
* login has none, so it names the relay(s) instead — otherwise a batched row reads as a blank
|
||||||
|
* "sign Relay Auth" with nothing to say where the user is logging in.
|
||||||
|
*/
|
||||||
|
fun signPreview(
|
||||||
|
kind: Int,
|
||||||
|
tags: TagArray,
|
||||||
|
content: String,
|
||||||
|
): String =
|
||||||
|
if (kind == RelayAuthEvent.KIND) {
|
||||||
|
tags.relayAuthTargets().joinToString(", ") { it.display }
|
||||||
|
} else {
|
||||||
|
content.take(PREVIEW_MAX_CHARS).trim()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
|
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
|
||||||
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
|
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
|
||||||
|
|||||||
+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.favorites.FavoriteAppIcon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||||
|
import com.vitorpamplona.amethyst.commons.model.Note
|
||||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
|
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_allow_always
|
||||||
@@ -97,10 +98,16 @@ import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
|
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
|
||||||
import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
|
import com.vitorpamplona.amethyst.commons.service.call.CallSessionBridge
|
||||||
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
|
import com.vitorpamplona.amethyst.commons.ui.components.RobohashFallbackAsyncImage
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.ShowFullTextCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.blockInteractions
|
||||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.NoteCompose
|
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.NoteBody
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.observeEdits
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.types.ReplyRenderType
|
||||||
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||||
|
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||||
@@ -347,11 +354,11 @@ private fun SignerConsentDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will
|
* The "what you're acting on" block: the unsigned event rendered as a note (what it will look like
|
||||||
* look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
|
* once signed, see [UnsignedNotePreview]) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
|
||||||
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
|
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
|
||||||
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
|
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
|
||||||
* is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in.
|
* is gone (only the foreground signer service alive) the note is skipped and the JSON stands in.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SignerConsentPreview(info: SignerConsentInfo) {
|
private fun SignerConsentPreview(info: SignerConsentInfo) {
|
||||||
@@ -381,13 +388,7 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
|
|||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(12.dp)) {
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
if (previewNote != null && accountViewModel != null) {
|
if (previewNote != null && accountViewModel != null) {
|
||||||
NoteCompose(
|
UnsignedNotePreview(previewNote, accountViewModel, previewNav)
|
||||||
baseNote = previewNote,
|
|
||||||
isQuotedNote = true,
|
|
||||||
quotesLeft = 0,
|
|
||||||
accountViewModel = accountViewModel,
|
|
||||||
nav = previewNav,
|
|
||||||
)
|
|
||||||
} else if (info.contentPreview.isNotBlank()) {
|
} else if (info.contentPreview.isNotBlank()) {
|
||||||
Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall)
|
Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
@@ -423,6 +424,48 @@ private fun SignerConsentPreview(info: SignerConsentInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event as it will read once signed: author line and content, nothing else. Deliberately not
|
||||||
|
* [com.vitorpamplona.amethyst.commons.ui.note.NoteCompose], which wraps a note in a reaction row,
|
||||||
|
* a long-press quick-action popup and a ⋮ options menu — controls that make no sense on a consent
|
||||||
|
* prompt, and would react to, zap or broadcast an event that does not exist yet. For the same
|
||||||
|
* reason a reply names its parent in one line ([ReplyRenderType.LINE]) instead of embedding it as
|
||||||
|
* a full note, and quotes are not expanded (`quotesLeft = 0`). Content-level actions are blocked
|
||||||
|
* too ([blockInteractions]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UnsignedNotePreview(
|
||||||
|
note: Note,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
// Renderers fade long content into this color, so it must match the preview Surface.
|
||||||
|
val surface = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val backgroundColor = remember(surface) { mutableStateOf(surface) }
|
||||||
|
val editState = observeEdits(note, accountViewModel)
|
||||||
|
// Everything being signed must be readable without a tap (taps are blocked below), so the
|
||||||
|
// text renderer starts expanded instead of cutting long content behind "Show more".
|
||||||
|
remember(note.idHex) { ShowFullTextCache.cache.put(note.idHex, true) }
|
||||||
|
|
||||||
|
// Read-only: renderers carry their own actions (poll votes, RSVPs, badge accepts, profile and
|
||||||
|
// link taps) that would act on an event that does not exist yet. Drags still scroll the dialog.
|
||||||
|
Column(Modifier.blockInteractions()) {
|
||||||
|
NoteBody(
|
||||||
|
baseNote = note,
|
||||||
|
showAuthorPicture = true,
|
||||||
|
unPackReply = ReplyRenderType.LINE,
|
||||||
|
showSecondRow = false,
|
||||||
|
quotesLeft = 0,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
editState = editState,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
// An empty slot replaces the default ⋮ options menu.
|
||||||
|
moreOptions = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shown when more than one request is awaiting consent at once (the signer services requests
|
* Shown when more than one request is awaiting consent at once (the signer services requests
|
||||||
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
|
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
|
|||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_relay_login
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
|
||||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
|
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
|
||||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||||
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.kindNameFor
|
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.kindNameFor
|
||||||
|
import com.vitorpamplona.amethyst.connectedApps.consent.Nip46ConsentInfoBuilder
|
||||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
@@ -46,12 +48,19 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
|||||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/** Human-readable label for a [NostrSignerOp]. */
|
/** Human-readable label for a [NostrSignerOp]. */
|
||||||
suspend fun NostrSignerOp.label(context: Context): String =
|
suspend fun NostrSignerOp.label(context: Context): String =
|
||||||
when (this) {
|
when (this) {
|
||||||
is NostrSignerOp.SignKind -> loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
|
is NostrSignerOp.SignKind ->
|
||||||
|
if (kind == RelayAuthEvent.KIND) {
|
||||||
|
// A relay login is not "signing" anything the user would recognise; say what it does.
|
||||||
|
loadStringRes(Res.string.napplet_op_relay_login)
|
||||||
|
} else {
|
||||||
|
loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
|
||||||
|
}
|
||||||
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
|
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
|
||||||
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
|
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
|
||||||
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||||
@@ -96,8 +105,8 @@ suspend fun buildSignerConsentInfo(
|
|||||||
val summary = (narrowOp ?: op).label(context)
|
val summary = (narrowOp ?: op).label(context)
|
||||||
val preview =
|
val preview =
|
||||||
when (request) {
|
when (request) {
|
||||||
is NappletRequest.Publish -> request.content.take(160).trim()
|
is NappletRequest.Publish -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content)
|
||||||
is NappletRequest.SignEvent -> request.content.take(160).trim()
|
is NappletRequest.SignEvent -> Nip46ConsentInfoBuilder.signPreview(request.kind, request.tags, request.content)
|
||||||
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
|
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
|
||||||
// Encryption shows the plaintext the page wants sealed; decryption has only ciphertext,
|
// Encryption shows the plaintext the page wants sealed; decryption has only ciphertext,
|
||||||
// which tells the user nothing, so its preview stays empty and the counterparty in
|
// which tells the user nothing, so its preview stays empty and the counterparty in
|
||||||
|
|||||||
+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
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
|
||||||
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_relay_login
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind
|
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_permissions_ask_each_time
|
import com.vitorpamplona.amethyst.commons.resources.napplet_permissions_ask_each_time
|
||||||
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
|
import com.vitorpamplona.amethyst.commons.resources.napplet_policy_full_trust
|
||||||
@@ -141,6 +142,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnli
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -838,7 +840,12 @@ private fun GrantOption(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NostrSignerOp.opLabel(): String =
|
private fun NostrSignerOp.opLabel(): String =
|
||||||
when (this) {
|
when (this) {
|
||||||
is NostrSignerOp.SignKind -> stringRes(Res.string.napplet_op_sign_kind, kind)
|
is NostrSignerOp.SignKind ->
|
||||||
|
if (kind == RelayAuthEvent.KIND) {
|
||||||
|
stringRes(Res.string.napplet_op_relay_login)
|
||||||
|
} else {
|
||||||
|
stringRes(Res.string.napplet_op_sign_kind, kind)
|
||||||
|
}
|
||||||
NostrSignerOp.Encrypt -> stringRes(Res.string.napplet_op_encrypt)
|
NostrSignerOp.Encrypt -> stringRes(Res.string.napplet_op_encrypt)
|
||||||
NostrSignerOp.Decrypt -> stringRes(Res.string.napplet_op_decrypt)
|
NostrSignerOp.Decrypt -> stringRes(Res.string.napplet_op_decrypt)
|
||||||
is NostrSignerOp.DecryptFrom -> stringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
is NostrSignerOp.DecryptFrom -> stringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||||
|
|||||||
+22
@@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
|
||||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
|
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
|
||||||
@@ -173,4 +175,24 @@ class Nip46ConsentInfoBuilderTest {
|
|||||||
assertNull(info.narrowOp)
|
assertNull(info.narrowOp)
|
||||||
assertNotNull(info.previewTemplate)
|
assertNotNull(info.previewTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRelayLoginPreviewNamesTheRelayInsteadOfItsEmptyContent() =
|
||||||
|
runTest {
|
||||||
|
val auth = RelayAuthEvent.build(RelayUrlNormalizer.normalize("wss://relay.damus.io"), "chal-123")
|
||||||
|
val template = EventTemplate<Event>(auth.createdAt, auth.kind, auth.tags, auth.content)
|
||||||
|
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(RelayAuthEvent.KIND))
|
||||||
|
|
||||||
|
assertEquals("relay.damus.io", info.contentPreview)
|
||||||
|
assertEquals(template, info.previewTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRegularPostPreviewIsStillItsContent() =
|
||||||
|
runTest {
|
||||||
|
val template = EventTemplate<Event>(1L, 1, emptyArray(), " hello world ")
|
||||||
|
val info = build(BunkerRequestSign(event = template), op = NostrSignerOp.SignKind(1))
|
||||||
|
|
||||||
|
assertEquals("hello world", info.contentPreview)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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_section">Just for now</string>
|
||||||
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
|
<string name="relay_auth_session_row_desc">Logged in until you restart Amethyst</string>
|
||||||
<string name="relay_auth_forget_session">Forget this login</string>
|
<string name="relay_auth_forget_session">Forget this login</string>
|
||||||
|
<string name="relay_auth_event_title">Relay login</string>
|
||||||
|
<string name="relay_auth_event_explainer">Not a post. It logs you in to this relay: whoever holds it can read what the relay only shows to you, like your private messages.</string>
|
||||||
|
<string name="relay_auth_event_unusual_relay">This is not a plain relay address. Check it before signing.</string>
|
||||||
|
<string name="relay_auth_event_no_relay">No relay named in this login</string>
|
||||||
|
<string name="relay_auth_event_challenge">Challenge: %1$s</string>
|
||||||
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
|
<string name="relay_auth_session_forgotten_undo">%1$s will ask again the next time it needs you.</string>
|
||||||
<string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won't log in to %1$s.</string>
|
<string name="relay_auth_session_undo_blocked">Not restored. “Never log in” is on, so Amethyst won't log in to %1$s.</string>
|
||||||
<string name="relay_auth_blocked_section">Blocked by your block list</string>
|
<string name="relay_auth_blocked_section">Blocked by your block list</string>
|
||||||
@@ -4506,6 +4511,7 @@
|
|||||||
<string name="napplet_op_decrypt">read your private messages</string>
|
<string name="napplet_op_decrypt">read your private messages</string>
|
||||||
<string name="napplet_op_decrypt_from">read your private messages with %1$s</string>
|
<string name="napplet_op_decrypt_from">read your private messages with %1$s</string>
|
||||||
<string name="napplet_op_encrypt">encrypt a message</string>
|
<string name="napplet_op_encrypt">encrypt a message</string>
|
||||||
|
<string name="napplet_op_relay_login">log in to relays as you</string>
|
||||||
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
|
<string name="napplet_op_sign_kind_named">sign for %1$s (kind: %2$d)</string>
|
||||||
<string name="napplet_permissions_title">Connected Apps</string>
|
<string name="napplet_permissions_title">Connected Apps</string>
|
||||||
<string name="napplets">nApplets</string>
|
<string name="napplets">nApplets</string>
|
||||||
|
|||||||
+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.RenderPublicationSection
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAuth
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayGroupMessage
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayGroupMessage
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
|
||||||
@@ -372,6 +373,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
|||||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
||||||
@@ -1307,6 +1309,10 @@ private fun RenderNoteRow(
|
|||||||
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
|
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RelayAuthEvent -> {
|
||||||
|
RenderRelayAuth(baseNote, accountViewModel, nav)
|
||||||
|
}
|
||||||
|
|
||||||
is PinListEvent -> {
|
is PinListEvent -> {
|
||||||
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
|
RenderPinListEvent(baseNote, backgroundColor, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|||||||
+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.RenderPublicationSection
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderReaction
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAddMember
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayAuth
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayDiscovery
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayJoinRequest
|
||||||
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayLeaveRequest
|
import com.vitorpamplona.amethyst.commons.ui.note.types.RenderRelayLeaveRequest
|
||||||
@@ -341,6 +342,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
|
|||||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent
|
||||||
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent
|
||||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||||
|
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
|
||||||
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
|
||||||
@@ -1119,6 +1121,8 @@ private fun FullBleedNoteCompose(
|
|||||||
RenderRelayJoinRequest(baseNote, accountViewModel, nav)
|
RenderRelayJoinRequest(baseNote, accountViewModel, nav)
|
||||||
} else if (noteEvent is RelayLeaveRequestEvent) {
|
} else if (noteEvent is RelayLeaveRequestEvent) {
|
||||||
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
|
RenderRelayLeaveRequest(baseNote, accountViewModel, nav)
|
||||||
|
} else if (noteEvent is RelayAuthEvent) {
|
||||||
|
RenderRelayAuth(baseNote, accountViewModel, nav)
|
||||||
} else if (noteEvent is TextNoteModificationEvent) {
|
} else if (noteEvent is TextNoteModificationEvent) {
|
||||||
RenderTextModificationEvent(
|
RenderTextModificationEvent(
|
||||||
note = baseNote,
|
note = baseNote,
|
||||||
|
|||||||
+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