From 5ba888d9f9f583c7535af311c372158a354f0154 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Sun, 13 Sep 2026 12:59:26 +0200 Subject: [PATCH] feat(ui): the new-chat dialog opens the person, and no longer starts the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/npub-profile-preview.md, and the user-visible change: "Direct message via npub" now shows who this is before anything is created. The dialog's button reads "Find profile" and pushes ProfilePreviewRoute; the chat is started from there. The field keeps what it parsed rather than whether it parsed. Its check and its decode used to be two different functions, which is how "nostr:npub1…" passed the one and failed the other, closing the dialog with nothing to show for it; CredentialParser.npubOrNull is the sign-in decode exposed for an address field -- an nsec is null there, because a field asking whom to write to must never take a secret for an answer -- and the enabled button and the route it pushes read the same key. The nip05 branch still lands on the placeholder, and the test says so. "Start chat" goes with its only reader. Co-Authored-By: Claude Opus 5 Pulled-From: curated/curated@923b59ec3211eb5074439e382b32dd0ca54ecfe9 --- .../composeResources/values/strings.xml | 2 +- .../compose/identity/CredentialParser.kt | 27 ++++- .../StartDirectMessageToNpubOrNip05Dialog.kt | 79 ++++++------ .../identity/CredentialParserJvmTest.kt | 24 ++++ ...DirectMessageToNpubOrNip05DialogJvmTest.kt | 114 ++++++++++++++++++ 5 files changed, 203 insertions(+), 43 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c337db92..3b45342b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -234,7 +234,6 @@ Source dialect Start Start a group chat - Start chat Start chat via npub or nip05 Start key ceremony Startup error @@ -486,6 +485,7 @@ Switch to it The profile you are in stays on this device. + Find profile Looking for this profile on the relays. Start new chat diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt index b8560fde..33866cb1 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/identity/CredentialParser.kt @@ -131,20 +131,39 @@ object CredentialParser { return Result.Recognised(SignInCredential.NostrSecret(privateKey, privateKey.nostrPublicKeyHex())) } + /** + * The x-only key under an `npub1…`, with or without a `nostr:` prefix, or null. + * + * The one shape a message address can arrive in, for the field that takes one. Hex + * stays a secret here for the reason [parse] gives, and an nsec is null because it is + * not an address -- a field asking whom to write to must never take a secret for an + * answer. Same decode as the sign-in branch, so the two cannot disagree. + */ + fun npubOrNull(raw: String): HexKey? { + val token = raw.trim().removePrefix("nostr:").lowercase() + if (!token.startsWith("npub1")) return null + return decodeNpub(token) + } + + private fun npub(token: String): Result = + decodeNpub(token) + ?.let { Result.Recognised(SignInCredential.NostrPublicKey(it)) } + ?: Result.Refused(CredentialProblem.InvalidKey) + /** * Thirty-two bytes under the `npub` prefix that name a point on the curve -- the * counterpart of the `isValid()` a secret is checked with, since not every x * coordinate has a point above it. */ - private fun npub(token: String): Result { + private fun decodeNpub(token: String): HexKey? { val bytes = runCatching { val (hrp, data) = Bech32.decodeBytes(token) require(hrp == "npub") { "not an npub" } require(data.size == 32) { "not an x-only key" } data - }.getOrNull() ?: return Result.Refused(CredentialProblem.InvalidKey) + }.getOrNull() ?: return null val xOnly = XonlyPublicKey(ByteVector32(bytes)) - if (!xOnly.publicKey.isValid()) return Result.Refused(CredentialProblem.InvalidKey) - return Result.Recognised(SignInCredential.NostrPublicKey(xOnly.value.toHex())) + if (!xOnly.publicKey.isValid()) return null + return xOnly.value.toHex() } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt index 9fe197c1..521d0a9e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05Dialog.kt @@ -14,7 +14,7 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Start +import androidx.compose.material.icons.filled.PersonSearch import androidx.compose.material3.Card import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -32,9 +32,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import press.mantra.compose.extensions.bech32ToHexOrNull -import press.mantra.compose.ui.composable.navigation.routes.ChatRoomMessagingRoute +import press.mantra.compose.identity.CredentialParser import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.theme.MantraTheme import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -47,11 +47,22 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import mantra.composeapp.generated.resources.Res import org.jetbrains.compose.resources.stringResource +import mantra.composeapp.generated.resources.find_profile import mantra.composeapp.generated.resources.input_npub_or_nip05 -import mantra.composeapp.generated.resources.start_chat import mantra.composeapp.generated.resources.start_chat_via_npub_or_nip05 +import press.mantra.compose.ui.composable.widgets.Decorative import press.mantra.compose.ui.theme.ConformancePreviews +/** + * Whom to write to, as an npub or a nip05. + * + * An npub no longer starts a chat from here: it opens the person's profile, and the + * chat is started from there once the user has seen who it is. The field keeps what + * it parsed rather than whether it parsed -- the enabled button and the route it + * pushes read the same decoded key, which is what closed the gap where `nostr:npub1…` + * passed the check and then failed the decode in silence. A nip05 still lands on the + * placeholder; resolving one is out of scope. See docs/npub-profile-preview.md. + */ @Composable fun StartDirectMessageToNpubOrNip05Dialog( activeUserPublicKey: HexKey, @@ -65,7 +76,10 @@ fun StartDirectMessageToNpubOrNip05Dialog( } ) { - val startChatButtonEnabled = remember { mutableStateOf(false) } + // The key under a pasted npub, or null while the field holds anything else. + val parsedPublicKey = remember { mutableStateOf(null) } + // A nip05 is recognised by shape alone; nothing resolves it yet. + val emailLikeAddressEntered = remember { mutableStateOf(false) } Card( modifier = Modifier @@ -123,63 +137,52 @@ fun StartDirectMessageToNpubOrNip05Dialog( }, lineLimits = TextFieldLineLimits.SingleLine, inputTransformation = { - val input = asCharSequence().toString().lowercase() + val input = asCharSequence().toString() - if (input.startsWith("npub1") || input.startsWith("nostr:npub1")) { - // Check for valid npub - startChatButtonEnabled.value = true - } else if (Parser.parseEmailLikeAddress(input) != null) { - // Check for valid npub - startChatButtonEnabled.value = true - } else { - startChatButtonEnabled.value = false - } + parsedPublicKey.value = CredentialParser.npubOrNull(input) + emailLikeAddressEntered.value = parsedPublicKey.value == null && + Parser.parseEmailLikeAddress(input.lowercase()) != null } ) TextButton( - enabled = startChatButtonEnabled.value, + enabled = parsedPublicKey.value != null || emailLikeAddressEntered.value, onClick = { + // Read before the dialog closes: the state lives in it. + val publicKey = parsedPublicKey.value + val input = textFieldState.text.toString() + scope.launch { toggleOpenDialogSetting.invoke(false) }.invokeOnCompletion { - val input = textFieldState.text.toString() - - val emailLikeAddress = Parser.parseEmailLikeAddress(input) - - if (emailLikeAddress != null) { - // TODO: Get public Key from hex... + if (publicKey != null) { onNavigateToRoute.invoke( - ImplementationPendingRoute( - "Direct Message to ${textFieldState.text}" + ProfilePreviewRoute( + activeUserPublicKey = activeUserPublicKey, + profilePublicKey = publicKey, ) ) } else { - val publicKey = input.bech32ToHexOrNull() - - if (publicKey != null) { - onNavigateToRoute.invoke( - ChatRoomMessagingRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = publicKey, - relayHint = null - ) + // TODO: Resolve the nip05 to a public key, then the same route. + onNavigateToRoute.invoke( + ImplementationPendingRoute( + "Direct Message to $input" ) - } + ) } - } } ) { - Text(stringResource(Res.string.start_chat)) + Text(stringResource(Res.string.find_profile)) Spacer( modifier = Modifier.width(MaterialTheme.spacing.space125) ) Icon( - Icons.Default.Start, - contentDescription = "Start chat" + Icons.Default.PersonSearch, + // The label is beside it. + contentDescription = Decorative ) } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt index fd444982..d661009a 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/CredentialParserJvmTest.kt @@ -5,6 +5,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotEquals +import kotlin.test.assertNull /** * Every row of the sign-in screen's table, in and out. @@ -114,4 +115,27 @@ class CredentialParserJvmTest { assertEquals(CredentialProblem.InvalidKey, refused("0".repeat(64))) assertEquals(CredentialProblem.InvalidKey, refused("f".repeat(64))) } + + // --- the address form: an npub or nothing --- + + /** The three spellings the sign-in accepts, plus the whitespace a paste brings. */ + @Test + fun `an address is an npub in any of its spellings`() { + for (input in listOf(npub, "nostr:$npub", npub.uppercase(), " $npub\n")) { + assertEquals(publicKey, CredentialParser.npubOrNull(input), "for '$input'") + } + } + + /** + * A field asking whom to write to must never take a secret for an answer, and it + * takes nothing else either: the hex the sign-in reads as a secret, an nprofile's + * TLV, and an npub with a bad checksum are all nothing here. + */ + @Test + fun `an address is not a secret, a hex, an nprofile or a broken npub`() { + val nprofile = "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p" + for (input in listOf(nsec, "nostr:$nsec", hex, publicKey, nprofile, npub.dropLast(1) + "q", "", " ")) { + assertNull(CredentialParser.npubOrNull(input), "for '$input'") + } + } } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt new file mode 100644 index 00000000..d29710c6 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/widgets/dialogs/StartDirectMessageToNpubOrNip05DialogJvmTest.kt @@ -0,0 +1,114 @@ +package press.mantra.compose.ui.composable.widgets.dialogs + +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute +import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * What the new-chat dialog does with what is pasted into it. + * + * The button and the route read one decoded key, so the case that used to close the + * dialog and do nothing -- `nostr:npub1…`, which passed the check and failed the + * decode -- is the row worth having. The nip05 row pins where that branch still + * lands, so that it is recorded rather than assumed. + */ +@OptIn(ExperimentalTestApi::class) +class StartDirectMessageToNpubOrNip05DialogJvmTest { + + private val activeUserPublicKey = "a".repeat(64) + private val npub = "npub1zutzeysacnf9rru6zqwmxd54mud0k44tst6l70ja5mhv8jjumytsd2x7nu" + private val publicKey = "17162c921dc4d2518f9a101db33695df1afb56ab82f5ff3e5da6eec3ca5cd917" + private val nsec = "nsec10allq0gjx7fddtzef0ax00mdps9t2kmtrldkyjfs8l5xruwvh2dq0lhhkp" + + @Test + fun `a pasted npub opens the person's profile, and starts no chat`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Find profile").assertIsNotEnabled() + onNodeWithText("Input npub... or nip05").performTextInput(npub) + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** The row that failed before: accepted by the field's check, refused by the decode, and nothing happened. */ + @Test + fun `a nostr-prefixed npub is the same person`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("nostr:$npub") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ProfilePreviewRoute(activeUserPublicKey, profilePublicKey = publicKey), opened) + } + + /** A field asking whom to write to must never take a secret for an answer. */ + @Test + fun `an nsec, or an npub with a broken checksum, enables nothing`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(nsec) + onNodeWithText("Find profile").assertIsNotEnabled() + } + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput(npub.dropLast(1) + "q") + onNodeWithText("Find profile").assertIsNotEnabled() + } + + assertNull(opened) + } + + /** Recognised by shape alone, and still the placeholder: resolving one is out of scope. */ + @Test + fun `a nip05 still lands on the placeholder`() { + var opened: Route? = null + + render(onNavigateToRoute = { opened = it }) { + onNodeWithText("Input npub... or nip05").performTextInput("alice@example.com") + onNodeWithText("Find profile").assertIsEnabled().performClick() + } + + assertEquals(ImplementationPendingRoute("Direct Message to alice@example.com"), opened) + } + + private fun render( + onNavigateToRoute: (Route) -> Unit, + assertions: ComposeUiTest.() -> Unit, + ) = runDesktopComposeUiTest(width = 400, height = 800) { + setContent { + MantraTheme { + ProvideSnackbarHost { + StartDirectMessageToNpubOrNip05Dialog( + activeUserPublicKey = activeUserPublicKey, + scope = rememberCoroutineScope(), + toggleOpenDialogSetting = {}, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + + assertions() + // The navigation runs when the close-the-dialog job completes; give it the frame. + waitForIdle() + } +}