feat(ui): the new-chat dialog opens the person, and no longer starts the chat

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 <noreply@anthropic.com>
Pulled-From: curated/curated@923b59ec32
This commit is contained in:
Kgothatso Ngako
2026-09-13 12:59:26 +02:00
parent 39c056a2f2
commit 5ba888d9f9
5 changed files with 203 additions and 43 deletions

View File

@@ -234,7 +234,6 @@
<string name="source_dialect">Source dialect</string>
<string name="start">Start</string>
<string name="start_a_group_chat">Start a group chat</string>
<string name="start_chat">Start chat</string>
<string name="start_chat_via_npub_or_nip05">Start chat via npub or nip05</string>
<string name="start_key_ceremony">Start key ceremony</string>
<string name="startup_error">Startup error</string>
@@ -486,6 +485,7 @@
<string name="switch_to_it">Switch to it</string>
<string name="the_profile_you_are_in_stays_on_this_device">The profile you are in stays on this device.</string>
<!-- The profile preview: the person behind a pasted npub, before a chat with them is started. -->
<string name="find_profile">Find profile</string>
<string name="looking_for_this_profile_on_the_relays">Looking for this profile on the relays.</string>
<string name="start_new_chat">Start new chat</string>
</resources>

View File

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

View File

@@ -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<HexKey?>(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
)
}
}

View File

@@ -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'")
}
}
}

View File

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