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() + } +}