Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover

Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…

Also closes duplicate proposal 4b90b41f, which pointed at the same commits.

Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-20 14:13:17 -04:00
co-authored by Claude Opus 4.8
3 changed files with 129 additions and 6 deletions
@@ -110,12 +110,16 @@ class UserSuggestionState(
.map(::userSearchTermOrNull)
.map { prefix ->
if (prefix != null) {
// NIP-05 resolution: user@domain or bare .bit domain
// NIP-05 resolution: full `name@domain` form, or bare
// `.bit` domain synthesised as the wildcard `_@domain`.
// Bare DNS domains aren't accepted here on purpose: a
// `.com`/`.io`/etc. that happens to host nostr.json is
// ambiguous with a regular URL the user might be typing.
val nip05 =
if (prefix.contains('@')) {
if (prefix.endsWith(".bit", ignoreCase = true) && !prefix.contains('@')) {
Nip05Id.parseLenient(prefix)
} else if (prefix.contains('@')) {
Nip05Id.parse(prefix)
} else if (prefix.endsWith(".bit", ignoreCase = true)) {
Nip05Id("_", prefix.lowercase())
} else {
null
}
@@ -231,7 +235,7 @@ class UserSuggestionState(
item: User,
): TextFieldValue {
val lastWordStart = message.selection.end - word.length
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
return TextFieldValue(
message.text.replaceRange(lastWordStart, message.selection.end, wordToInsert),
@@ -244,11 +248,43 @@ class UserSuggestionState(
word: String,
item: User,
) {
val wordToInsert = "@${item.pubkeyNpub()} "
val wordToInsert = mentionInsertion(word, item)
state.edit {
val lastWordStart = selection.end - word.length
replace(lastWordStart, selection.end, wordToInsert)
selection = TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length)
}
}
/**
* The token to insert into the message text when the author picks [item]
* from the suggestion popover. When the author was typing a NIP-05
* mention (full `m@testls.bit` or bare-domain `.bit` form), we insert
* `nostr:nprofile1…` directly so the send-time tagger doesn't need to
* re-resolve anything — it parses the bech32 inline via its existing
* `nprofile1` branch, with no main-thread I/O. For every other path
* (search by name, typed npub/nprofile, hex) we keep the existing
* `@npub1…` form to preserve current behaviour.
*
* Pre-resolved NIP-05 hits already have their relay hints pushed into
* the account cache by [nip05ResolutionFlow] before this runs, so
* [User.toNProfile] picks them up automatically.
*/
private fun mentionInsertion(
word: String,
item: User,
): String {
val typed = userSearchTermOrNull(word)
val wasNip05Mention =
typed != null &&
(
(typed.endsWith(".bit", ignoreCase = true) && !typed.contains('@')) ||
(typed.contains('@') && Nip05Id.parse(typed) != null)
)
return if (wasNip05Mention) {
"nostr:${item.toNProfile()} "
} else {
"@${item.pubkeyNpub()} "
}
}
}
@@ -69,6 +69,26 @@ data class Nip05Id(
return Nip05Id(name, domain)
}
/**
* Lenient parser used by mention/text-rendering paths. Accepts either:
* - a full NIP-05 identifier (`name@domain.tld`), parsed as in [parse]; or
* - a bare domain (`domain.tld`), synthesized as the wildcard form
* `_@domain.tld` per NIP-05.
*
* Note that a bare-domain hit produces a [Nip05Id] whose [name] is `_`;
* use [hasLocalPart] / [toDisplayValue] when rendering.
*/
fun parseLenient(candidate: String): Nip05Id? {
val trimmed = candidate.trim()
if (trimmed.isEmpty()) return null
if (trimmed.contains('@')) return parse(trimmed)
// Bare-domain form: synthesize the wildcard local-part.
val lower = trimmed.lowercase()
if (!DOMAIN_REGEX.matches(lower)) return null
if (lower.substringAfterLast('.').all { it.isDigit() }) return null
return Nip05Id("_", lower)
}
fun assemble(
name: String,
domain: String,
@@ -274,4 +274,71 @@ class Nip05Test {
assertEquals(result, newResult)
}
// ── parseLenient ──────────────────────────────────────────────────
// parseLenient is used by mention/text-rendering paths and accepts both
// the full `name@domain` NIP-05 form and a bare domain that is synthesized
// as the `_@domain` wildcard form per NIP-05.
@Test
fun `parseLenient accepts full form like Nip05Id parse`() {
val full = Nip05Id.parseLenient("alice@example.com")
assertNotNull(full)
assertEquals("alice", full.name)
assertEquals("example.com", full.domain)
}
@Test
fun `parseLenient accepts dot bit full form`() {
val parsed = Nip05Id.parseLenient("m@testls.bit")
assertNotNull(parsed)
assertEquals("m", parsed.name)
assertEquals("testls.bit", parsed.domain)
}
@Test
fun `parseLenient synthesizes wildcard for bare domain`() {
val bare = Nip05Id.parseLenient("testls.bit")
assertNotNull(bare)
assertEquals("_", bare.name)
assertEquals("testls.bit", bare.domain)
assertEquals(false, bare.hasLocalPart())
assertEquals("testls.bit", bare.toDisplayValue())
assertEquals("_@testls.bit", bare.toValue())
}
@Test
fun `parseLenient synthesizes wildcard for non-bit bare domain`() {
val bare = Nip05Id.parseLenient("nostr.example.org")
assertNotNull(bare)
assertEquals("_", bare.name)
assertEquals("nostr.example.org", bare.domain)
}
@Test
fun `parseLenient lowercases input`() {
val parsed = Nip05Id.parseLenient("M@TestLS.BIT")
assertNotNull(parsed)
assertEquals("m", parsed.name)
assertEquals("testls.bit", parsed.domain)
}
@Test
fun `parseLenient rejects single-label garbage`() {
assertNull(Nip05Id.parseLenient("justaword"))
assertNull(Nip05Id.parseLenient(""))
assertNull(Nip05Id.parseLenient(" "))
}
@Test
fun `parseLenient rejects IP literal disguised as a bare domain`() {
assertNull(Nip05Id.parseLenient("192.168.1.1"))
}
@Test
fun `parseLenient rejects malformed full form`() {
assertNull(Nip05Id.parseLenient("@example.com"))
assertNull(Nip05Id.parseLenient("foo@"))
assertNull(Nip05Id.parseLenient("foo@bar"))
}
}