mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
+42
-6
@@ -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,
|
||||
|
||||
+67
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user