mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+21
-4
@@ -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
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+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)
|
||||||
}
|
}
|
||||||
|
|||||||
+171
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,
|
||||||
|
|||||||
+3
-1
@@ -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)
|
||||||
Reference in New Issue
Block a user