feat: render NIP-42 relay logins (kind 22242) as a login card, not a post

Kind 22242 had no NoteCompose renderer, so signer consent prompts (NIP-46
bunker, napplets) previewed an auth request as an empty text note.

- commonsUI: RelayAuthCard shows "Relay login", a "nothing is posted"
  explainer, each relay (icon + URL) and the challenge; wired into the
  NoteCompose and ThreadFeedView dispatchers.
- Consent: the op label reads "log in to relays as you" instead of
  "sign for Relay Auth (kind: 22242)", and the preview line names the
  relay(s) instead of the empty content (batched rows were blank).
- quartz: TagArray.authRelays()/authChallenge() + RelayAuthEvent.relays().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSwjzewTFcwzqAKVJ1WJ9B
This commit is contained in:
Claude
2026-10-02 18:38:23 +00:00
parent 0407829f9d
commit 2e3e818945
10 changed files with 283 additions and 9 deletions
@@ -24,8 +24,12 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAut
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
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.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip42RelayAuth.authRelays
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.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 +95,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 +133,22 @@ object Nip46ConsentInfoBuilder {
) )
} }
/**
* The one-line preview of an event an app asks to sign. Usually its content; a NIP-42 relay
* login has none, so it names the relay(s) instead — otherwise a batched row reads as a blank
* "sign Relay Auth" with nothing to say where the user is logging in.
*/
fun signPreview(
kind: Int,
tags: TagArray,
content: String,
): String =
if (kind == RelayAuthEvent.KIND) {
tags.authRelays().joinToString(", ") { it.displayUrl() }
} else {
content.take(PREVIEW_MAX_CHARS).trim()
}
/** /**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails, * 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
@@ -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
@@ -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,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)
}
} }
@@ -1134,6 +1134,10 @@
<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">Proves to the relay that you control this account. Nothing is posted.</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>
@@ -4505,6 +4509,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>
@@ -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)
} }
@@ -0,0 +1,171 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note.types
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_challenge
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_explainer
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_no_relay
import com.vitorpamplona.amethyst.commons.resources.relay_auth_event_title
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelay
import com.vitorpamplona.amethyst.commons.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
/**
* NIP-42 kind 22242: a login to a relay. It is not a post — its content is empty and nobody but
* the relay ever sees it — so it renders as "log in to <relay>" instead of falling through to the
* text-note layout. Mostly met in signer consent prompts, where an app asks to sign one.
*/
@Composable
fun RenderRelayAuth(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val noteEvent = baseNote.event as? RelayAuthEvent ?: return
val relays = remember(noteEvent) { noteEvent.relays() }
val challenge = remember(noteEvent) { noteEvent.challenge() }
RelayAuthCard(relays, challenge) { relay ->
RenderRelay(relay, accountViewModel, nav)
}
}
@Composable
fun RelayAuthCard(
relays: List<NormalizedRelayUrl>,
challenge: String?,
relayIcon: @Composable (NormalizedRelayUrl) -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.Key,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column {
Text(
text = stringRes(Res.string.relay_auth_event_title),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = stringRes(Res.string.relay_auth_event_explainer),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (relays.isEmpty()) {
Text(
text = stringRes(Res.string.relay_auth_event_no_relay),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 32.dp),
)
}
relays.forEach { relay ->
Row(
modifier = Modifier.padding(start = 32.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
relayIcon(relay)
Text(
text = relay.displayUrl(),
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
challenge?.let {
Text(
text = stringRes(Res.string.relay_auth_event_challenge, it),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 32.dp),
)
}
}
}
@Preview
@Composable
private fun RelayAuthCardPreview() {
ThemeComparisonColumn {
RelayAuthCard(
relays = listOf(NormalizedRelayUrl("wss://relay.damus.io/")),
challenge = "4f2c9a1e-7b3d-4c8e-a6f0-2d9b1e3c5a7f",
) { relay ->
RenderRelayIcon(
displayUrl = relay.url,
iconUrl = null,
loadProfilePicture = false,
loadRobohash = true,
pingInMs = 0,
)
}
}
}
@@ -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,
@@ -42,7 +42,9 @@ class RelayAuthEvent(
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse) fun relay() = tags.firstNotNullOfOrNull(RelayTag::parse)
fun challenge() = tags.firstNotNullOfOrNull(ChallengeTag::parse) fun relays() = tags.authRelays()
fun challenge() = tags.authChallenge()
companion object { companion object {
const val KIND = 22242 const val KIND = 22242
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip42RelayAuth
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip42RelayAuth.tags.ChallengeTag
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
/** Every relay a NIP-42 auth event (or its unsigned template) logs into. Usually just one. */
fun TagArray.authRelays() = mapNotNull(RelayTag::parse)
fun TagArray.authChallenge() = fastFirstNotNullOfOrNull(ChallengeTag::parse)