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 NgakoandClaude Opus 5 committed 2026-09-13 16:32:39 +02:00
1 parent 39c056a2f2
commit 5ba888d9f9
5 files changed
+203 -43

No files matched your search

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