diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt index 76858b0776..4cc64d396a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/UserSuggestionState.kt @@ -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()} " + } + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt index 09027f1736..980eee74cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Id.kt @@ -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, diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt index 0dadfcdf1f..3bab42b3e3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Test.kt @@ -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")) + } }