Merge pull request #2054 from vitorpamplona/claude/edit-field-visual-transform-GDY9G

Refactor user mention parsing to support nprofile and nostr: URIs
This commit is contained in:
Vitor Pamplona
2026-03-31 09:09:59 -04:00
committed by GitHub
2 changed files with 64 additions and 16 deletions
@@ -26,8 +26,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import kotlin.coroutines.cancellation.CancellationException
class UrlUserTagOutputTransformation(
@@ -36,15 +35,20 @@ class UrlUserTagOutputTransformation(
override fun TextFieldBuffer.transformOutput() {
val text = asCharSequence().toString()
// Find all @npub mentions using regex and replace in reverse order
// Find all user mentions using regex and replace in reverse order
// so that earlier indices remain valid after replacements.
val npubRegex = Regex("@npub1[a-z0-9]{58}")
val matches = npubRegex.findAll(text).toList().reversed()
// Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1...
val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)")
val matches = mentionRegex.findAll(text).toList().reversed()
for (match in matches) {
try {
val key = decodePublicKey(match.value.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val bech32 =
match.value
.removePrefix("@")
.removePrefix("nostr:")
val hex = decodePublicKeyAsHexOrNull(bech32) ?: continue
val user = LocalCache.getOrCreateUser(hex)
val displayName = "@${user.toBestDisplayName()}"
replace(match.range.first, match.range.last + 1, displayName)
@@ -31,8 +31,10 @@ import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextDecoration
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import kotlin.coroutines.cancellation.CancellationException
data class RangesChanges(
@@ -60,20 +62,19 @@ fun buildAnnotatedStringWithUrlHighlighting(
text.text.split('\n').joinToString("\n") { paragraph: String ->
paragraph.split(' ').joinToString(" ") { word: String ->
try {
if (word.startsWith("@npub") && word.length >= 64) {
val keyB32 = word.substring(0, 64)
val restOfWord = word.substring(64)
val mention = parseUserMention(word)
if (mention != null) {
val (keyPortion, restOfWord, hexKey) = mention
val startIndex = builderBefore.toString().length
builderBefore.append(
"$keyB32$restOfWord ",
"$keyPortion$restOfWord ",
) // accounts for the \n at the end of each paragraph
val endIndex = startIndex + keyB32.length
val endIndex = startIndex + keyPortion.length
val key = decodePublicKey(keyB32.removePrefix("@"))
val user = LocalCache.getOrCreateUser(key.toHexKey())
val user = LocalCache.getOrCreateUser(hexKey)
val newWord = "@${user.toBestDisplayName()}"
val startNew = builderAfter.toString().length
@@ -181,3 +182,46 @@ fun buildAnnotatedStringWithUrlHighlighting(
numberOffsetTranslator,
)
}
private data class UserMention(
val keyPortion: String,
val restOfWord: String,
val hexKey: String,
)
private fun parseUserMention(word: String): UserMention? {
var key = word
val prefix: String
if (key.startsWith("nostr:", true)) {
prefix = key.substring(0, 6)
key = key.substring(6)
} else if (key.startsWith("@")) {
prefix = "@"
key = key.substring(1)
} else {
return null
}
if (key.startsWith("npub1", true) && key.length >= 63) {
val keyB32 = key.substring(0, 63)
val restOfWord = key.substring(63)
val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null
return UserMention("$prefix$keyB32", restOfWord, hex)
} else if (key.startsWith("nprofile1", true)) {
val parsed = Nip19Parser.uriToRoute(key) ?: return null
val entity = parsed.entity
if (entity !is NProfile && entity !is NPub) return null
val hex =
when (entity) {
is NProfile -> entity.hex
is NPub -> entity.hex
else -> return null
}
val bech32Len = parsed.nip19raw.length
val restOfWord = key.substring(bech32Len)
return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex)
}
return null
}