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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user