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