diff --git a/.gitignore b/.gitignore index 255852890..f928a5380 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +/.idea/androidTestResultsUserPreferences.xml .DS_Store /build /captures diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/ExampleInstrumentedTest.kt deleted file mode 100644 index f8f606b2e..000000000 --- a/app/src/androidTest/java/com/vitorpamplona/amethyst/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.vitorpamplona.amethyst - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.vitorpamplona.amethyst", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt new file mode 100644 index 000000000..815ce88f0 --- /dev/null +++ b/app/src/androidTest/java/com/vitorpamplona/amethyst/UrlUserTagTransformationTest.kt @@ -0,0 +1,96 @@ +package com.vitorpamplona.amethyst + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.decodePublicKey +import com.vitorpamplona.amethyst.ui.actions.buildAnnotatedStringWithUrlHighlighting +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class EUrlUserTagTransformationTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.vitorpamplona.amethyst", appContext.packageName) + } + + @Test + fun transformationText() { + val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")) + user.info.displayName = "Vitor Pamplona" + + var transformedText = buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red + ) + + assertEquals("New Hey @Vitor Pamplona", transformedText.text.text) + + assertEquals(0, transformedText.offsetMapping.originalToTransformed(0)) // Before N + assertEquals(4, transformedText.offsetMapping.originalToTransformed(4)) // Before H + assertEquals(8, transformedText.offsetMapping.originalToTransformed(8)) // Before @ + assertEquals(8, transformedText.offsetMapping.originalToTransformed(9)) // Before n + assertEquals(8, transformedText.offsetMapping.originalToTransformed(10)) // Before p + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) // Before u + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) // Before b + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) // Before 1 + + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) + + + assertEquals(0, transformedText.offsetMapping.transformedToOriginal(0)) + assertEquals(4, transformedText.offsetMapping.transformedToOriginal(4)) + assertEquals(8, transformedText.offsetMapping.transformedToOriginal(8)) + assertEquals(12, transformedText.offsetMapping.transformedToOriginal(9)) + + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) + } + + @Test + fun transformationTextTwoKeys() { + val user = LocalCache.getOrCreateUser(decodePublicKey("npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z")) + user.info.displayName = "Vitor Pamplona" + + var transformedText = buildAnnotatedStringWithUrlHighlighting( + AnnotatedString("New Hey @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z and @npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z"), + Color.Red + ) + + assertEquals("New Hey @Vitor Pamplona and @Vitor Pamplona", transformedText.text.text) + + assertEquals(9, transformedText.offsetMapping.originalToTransformed(11)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(12)) + assertEquals(9, transformedText.offsetMapping.originalToTransformed(13)) + + assertEquals(23, transformedText.offsetMapping.originalToTransformed(70)) // Before 5 + assertEquals(23, transformedText.offsetMapping.originalToTransformed(71)) // Before z + assertEquals(23, transformedText.offsetMapping.originalToTransformed(72)) // Before + assertEquals(24, transformedText.offsetMapping.originalToTransformed(73)) // Before a + assertEquals(25, transformedText.offsetMapping.originalToTransformed(74)) // Before n + assertEquals(26, transformedText.offsetMapping.originalToTransformed(75)) // Before d + assertEquals(27, transformedText.offsetMapping.originalToTransformed(76)) // Before + assertEquals(28, transformedText.offsetMapping.originalToTransformed(77)) // Before @ + assertEquals(28, transformedText.offsetMapping.originalToTransformed(78)) // Before n + + assertEquals(68, transformedText.offsetMapping.transformedToOriginal(22)) // Before a + assertEquals(72, transformedText.offsetMapping.transformedToOriginal(23)) // Before + assertEquals(73, transformedText.offsetMapping.transformedToOriginal(24)) // Before a + assertEquals(74, transformedText.offsetMapping.transformedToOriginal(25)) // Before n + assertEquals(75, transformedText.offsetMapping.transformedToOriginal(26)) // Before d + assertEquals(76, transformedText.offsetMapping.transformedToOriginal(27)) // Before + assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @ + } +} \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index bfede65c8..7241cc474 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -98,7 +98,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet() val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex()) - val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).filter { + val mentions = replyToEvent.mentions.plus(replyToEvent.pubKey.toHex()).plus(modifiedMentionsHex).filter { it in modifiedMentionsHex } @@ -114,7 +114,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet = val signedEvent = TextNoteEvent.create( msg = message, replyTos = null, - mentions = null, + mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(), privateKey = loggedIn.privKey!! ) Client.send(signedEvent) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 94e5976d3..7e1136844 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -132,7 +132,7 @@ object LocalCache { val user = getOrCreateUser(event.pubKey) if (event.createdAt > user.updatedFollowsAt) { - Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}") + //Log.d("CL", "AAA ${user.toBestDisplayName()} ${event.follows.size}") user.updateFollows( event.follows.map { try { @@ -312,6 +312,10 @@ object LocalCache { } + fun findUsersStartingWith(username: String): List { + return users.values.filter { it.info.anyNameStartsWith(username) } + } + // Observers line up here. val live: LocalCacheLiveData = LocalCacheLiveData(this) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt index e1770426f..247b2dbd0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/User.kt @@ -124,6 +124,11 @@ class UserMetadata { var iris: String? = null var main_relay: String? = null var twitter: String? = null + + fun anyNameStartsWith(prefix: String): Boolean { + return listOfNotNull(name, username, display_name, displayName, nip05, lud06, lud16) + .filter { it.startsWith(prefix, true) }.isNotEmpty() + } } class UserLiveData(val user: User): LiveData(UserState(user)) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index f5d661b7d..046fe32c1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -1,16 +1,24 @@ package com.vitorpamplona.amethyst.ui.actions import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedTextField import androidx.compose.material.Surface @@ -29,9 +37,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.viewmodel.compose.viewModel @@ -39,9 +50,14 @@ import coil.compose.AsyncImage import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.UrlPreview +import com.vitorpamplona.amethyst.ui.components.VideoView import com.vitorpamplona.amethyst.ui.components.imageExtension +import com.vitorpamplona.amethyst.ui.components.isValidURL +import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator +import com.vitorpamplona.amethyst.ui.components.videoExtension import com.vitorpamplona.amethyst.ui.navigation.UploadFromGallery import com.vitorpamplona.amethyst.ui.note.ReplyInformation +import com.vitorpamplona.amethyst.ui.note.UserDisplay import kotlinx.coroutines.delay import nostr.postr.events.TextNoteEvent @@ -99,7 +115,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account postViewModel.sendPost() onClose() }, - postViewModel.message.isNotBlank() + postViewModel.message.text.isNotBlank() ) } @@ -112,8 +128,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account OutlinedTextField( value = postViewModel.message, onValueChange = { - postViewModel.message = it - postViewModel.urlPreview = postViewModel.findUrlInMessage() + postViewModel.updateMessage(it) }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences @@ -141,30 +156,86 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account .outlinedTextFieldColors( unfocusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent - ) + ), + visualTransformation = UrlUserTagTransformation(MaterialTheme.colors.primary) ) + val userSuggestions = postViewModel.userSuggestions + if (userSuggestions.isNotEmpty()) { + LazyColumn( + contentPadding = PaddingValues( + top = 10.dp, + bottom = 10.dp + ) + ) { + itemsIndexed(userSuggestions, key = { _, item -> item.pubkeyHex }) { index, item -> + Column(modifier = Modifier.fillMaxWidth().clickable(onClick = { + postViewModel.autocompleteWithUser(item) + })) { + Row( + modifier = Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp) + ) { + + AsyncImage( + model = item.profilePicture(), + contentDescription = "Profile Image", + modifier = Modifier + .width(55.dp).height(55.dp) + .clip(shape = CircleShape) + ) + + Column(modifier = Modifier.padding(start = 10.dp).weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserDisplay(item) + } + + Text( + item.info.about?.take(100) ?: "", + color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + } + } + + Divider( + modifier = Modifier.padding(top = 10.dp), + thickness = 0.25.dp + ) + } + } + } + } + val myUrlPreview = postViewModel.urlPreview if (myUrlPreview != null) { Column(modifier = Modifier.padding(top = 5.dp)) { - val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase() - if (imageExtension.matcher(removedParamsFromUrl).matches()) { - AsyncImage( - model = myUrlPreview, - contentDescription = myUrlPreview, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .padding(top = 4.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ) - ) - } else { - UrlPreview("https://$myUrlPreview", myUrlPreview, false) + if (isValidURL(myUrlPreview)) { + val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase() + if (imageExtension.matcher(removedParamsFromUrl).matches()) { + AsyncImage( + model = myUrlPreview, + contentDescription = myUrlPreview, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 4.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ) + ) + } else if (videoExtension.matcher(removedParamsFromUrl).matches()) { + VideoView(myUrlPreview) + } else { + UrlPreview(myUrlPreview, myUrlPreview) + } + } else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) { + UrlPreview("https://$myUrlPreview", myUrlPreview) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index dda4aa082..42e4086ba 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -8,12 +8,17 @@ import android.provider.MediaStore import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.decodePublicKey import com.vitorpamplona.amethyst.ui.components.isValidURL import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator +import nostr.postr.toNpub class NewPostViewModel: ViewModel() { private var account: Account? = null @@ -22,9 +27,12 @@ class NewPostViewModel: ViewModel() { var mentions by mutableStateOf?>(null) var replyTos by mutableStateOf?>(null) - var message by mutableStateOf("") + var message by mutableStateOf(TextFieldValue("")) var urlPreview by mutableStateOf(null) + var userSuggestions by mutableStateOf>(emptyList()) + var userSuggestionAnchor: TextRange? = null + fun load(account: Account, replyingTo: Note?) { originalNote = replyingTo replyingTo?.let { replyNote -> @@ -36,9 +44,53 @@ class NewPostViewModel: ViewModel() { this.account = account } + fun addUserToMentionsIfNotInAndReturnIndex(user: User): Int { + val replyToSize = replyTos?.size ?: 0 + + var myMentions = mentions + if (myMentions == null) { + mentions = listOf(user) + return replyToSize + 0 // position of the user + } + + val index = myMentions.indexOf(user) + + if (index >= 0) return replyToSize + index + + myMentions = myMentions.plus(user) + mentions = myMentions + return replyToSize + myMentions.indexOf(user) + } + fun sendPost() { - account?.sendPost(message, originalNote, mentions) - message = "" + // Moves @npub to mentions + val newMessage = message.text.split('\n').map { paragraph: String -> + paragraph.split(' ').map { word: String -> + try { + if (word.startsWith("@npub") && word.length >= 64) { + val keyB32 = word.substring(0, 64) + val restOfWord = word.substring(64) + + val key = decodePublicKey(keyB32.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key) + + val index = addUserToMentionsIfNotInAndReturnIndex(user) + + val newWord = "#[${index}]" + + newWord + restOfWord + } else { + word + } + } catch (e: Exception) { + // if it can't parse the key, don't try to change. + word + } + }.joinToString(" ") + }.joinToString("\n") + + account?.sendPost(newMessage, originalNote, mentions) + message = TextFieldValue("") urlPreview = null } @@ -51,19 +103,19 @@ class NewPostViewModel: ViewModel() { img?.let { ImageUploader.uploadImage(img) { - message = message + "\n\n" + it + message = TextFieldValue(message.text + "\n\n" + it) urlPreview = findUrlInMessage() } } } fun cancel() { - message = "" + message = TextFieldValue("") urlPreview = null } fun findUrlInMessage(): String? { - return message.split('\n').firstNotNullOfOrNull { paragraph -> + return message.text.split('\n').firstNotNullOfOrNull { paragraph -> paragraph.split(' ').firstOrNull { word: String -> isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() } @@ -73,4 +125,34 @@ class NewPostViewModel: ViewModel() { fun removeFromReplyList(it: User) { mentions = mentions?.minus(it) } + + fun updateMessage(it: TextFieldValue) { + message = it + urlPreview = findUrlInMessage() + + if (it.selection.collapsed) { + val lastWord = it.text.substring(0, it.selection.end).substringAfterLast("\n").substringAfterLast(" ") + userSuggestionAnchor = it.selection + if (lastWord.startsWith("@") && lastWord.length > 2) { + userSuggestions = LocalCache.findUsersStartingWith(lastWord.removePrefix("@")) + } else { + userSuggestions = emptyList() + } + } + } + + fun autocompleteWithUser(item: User) { + userSuggestionAnchor?.let { + val lastWord = message.text.substring(0, it.end).substringAfterLast("\n").substringAfterLast(" ") + val lastWordStart = it.end - lastWord.length + val wordToInsert = "@${item.pubkey.toNpub()} " + + message = TextFieldValue( + message.text.replaceRange(lastWordStart, it.end, wordToInsert), + TextRange(lastWordStart + wordToInsert.length, lastWordStart + wordToInsert.length) + ) + userSuggestionAnchor = null + userSuggestions = emptyList() + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt new file mode 100644 index 000000000..50abf9a91 --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -0,0 +1,143 @@ +package com.vitorpamplona.amethyst.ui.actions + +import android.util.Patterns +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +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.amethyst.model.decodePublicKey +import kotlin.math.roundToInt + +data class RangesChanges(val original: TextRange, val modified: TextRange) + +class UrlUserTagTransformation(val color: Color) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + return buildAnnotatedStringWithUrlHighlighting(text, color) + } +} + +fun buildAnnotatedStringWithUrlHighlighting(text: AnnotatedString, color: Color): TransformedText { + val substitutions = mutableListOf() + + val newText = buildAnnotatedString { + val builderBefore = StringBuilder() // important to correctly measure Tag start and end + val builderAfter = StringBuilder() // important to correctly measure Tag start and end + append( + text.split('\n').map { paragraph: String -> + paragraph.split(' ').map { word: String -> + try { + if (word.startsWith("@npub") && word.length >= 64) { + val keyB32 = word.substring(0, 64) + val restOfWord = word.substring(64) + + val startIndex = builderBefore.toString().length + + builderBefore.append("$keyB32$restOfWord ") // accounts for the \n at the end of each paragraph + + val endIndex = startIndex + keyB32.length + + val key = decodePublicKey(keyB32.removePrefix("@")) + val user = LocalCache.getOrCreateUser(key) + + val newWord = "@${user.toBestDisplayName()}" + val startNew = builderAfter.toString().length + + builderAfter.append("$newWord$restOfWord ") // accounts for the \n at the end of each paragraph + + substitutions.add( + RangesChanges( + TextRange(startIndex, endIndex), + TextRange(startNew, startNew + newWord.length) + ) + ) + newWord + restOfWord + } else { + builderBefore.append(word + " ") + builderAfter.append(word + " ") + word + } + } catch (e: Exception) { + // if it can't parse the key, don't try to change. + builderBefore.append(word + " ") + builderAfter.append(word + " ") + word + } + }.joinToString(" ") + }.joinToString("\n") + ) + + val newText = toAnnotatedString() + + newText.split("\\s+".toRegex()).filter { word -> + Patterns.WEB_URL.matcher(word).matches() + }.forEach { + val startIndex = text.indexOf(it) + val endIndex = startIndex + it.length + addStyle( + style = SpanStyle( + color = color, + textDecoration = TextDecoration.None + ), + start = startIndex, end = endIndex + ) + } + + substitutions.forEach { + addStyle( + style = SpanStyle( + color = color, + textDecoration = TextDecoration.None + ), + start = it.modified.start, end = it.modified.end + ) + } + } + + val numberOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val inInsideRange = substitutions.filter { offset > it.original.start && offset < it.original.end }.firstOrNull() + + if (inInsideRange != null) { + val percentInRange = (offset - inInsideRange.original.start) / (inInsideRange.original.length.toFloat()) + return (inInsideRange.modified.start + inInsideRange.modified.length * percentInRange).roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.original.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.modified.end + (offset - lastRangeThrough.original.end) + } else { + return offset + } + } + + override fun transformedToOriginal(offset: Int): Int { + val inInsideRange = substitutions.filter { offset > it.modified.start && offset < it.modified.end }.firstOrNull() + + if (inInsideRange != null) { + val percentInRange = (offset - inInsideRange.modified.start) / (inInsideRange.modified.length.toFloat()) + return (inInsideRange.original.start + inInsideRange.original.length * percentInRange).roundToInt() + } + + val lastRangeThrough = substitutions.lastOrNull { offset >= it.modified.end } + + if (lastRangeThrough != null) { + return lastRangeThrough.original.end + (offset - lastRangeThrough.modified.end) + } else { + return offset + } + } + } + + return TransformedText( + newText, + numberOffsetTranslator + ) + +} \ No newline at end of file