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:
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()
|
||||
}
|
||||
}
|
||||
+41
-38
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user