Tags users during message compose.

This commit is contained in:
Vitor Pamplona
2023-01-18 08:36:42 -05:00
parent 8f45293be9
commit 3ee39887a8
9 changed files with 434 additions and 54 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
/.idea/androidTestResultsUserPreferences.xml
.DS_Store .DS_Store
/build /build
/captures /captures

View File

@@ -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)
}
}

View File

@@ -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 <space>
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 <space>
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 <space>
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 <space>
assertEquals(77, transformedText.offsetMapping.transformedToOriginal(28)) // Before @
}
}

View File

@@ -98,7 +98,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet() val modifiedMentionsHex = modifiedMentions?.map { it.pubkeyHex }?.toSet() ?: emptySet()
val repliesTo = replyToEvent.replyTos.plus(replyToEvent.id.toHex()) 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 it in modifiedMentionsHex
} }
@@ -114,7 +114,7 @@ class Account(val loggedIn: Persona, val followingChannels: MutableSet<String> =
val signedEvent = TextNoteEvent.create( val signedEvent = TextNoteEvent.create(
msg = message, msg = message,
replyTos = null, replyTos = null,
mentions = null, mentions = modifiedMentions?.map { it.pubkeyHex } ?: emptyList(),
privateKey = loggedIn.privKey!! privateKey = loggedIn.privKey!!
) )
Client.send(signedEvent) Client.send(signedEvent)

View File

@@ -132,7 +132,7 @@ object LocalCache {
val user = getOrCreateUser(event.pubKey) val user = getOrCreateUser(event.pubKey)
if (event.createdAt > user.updatedFollowsAt) { 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( user.updateFollows(
event.follows.map { event.follows.map {
try { try {
@@ -312,6 +312,10 @@ object LocalCache {
} }
fun findUsersStartingWith(username: String): List<User> {
return users.values.filter { it.info.anyNameStartsWith(username) }
}
// Observers line up here. // Observers line up here.
val live: LocalCacheLiveData = LocalCacheLiveData(this) val live: LocalCacheLiveData = LocalCacheLiveData(this)

View File

@@ -124,6 +124,11 @@ class UserMetadata {
var iris: String? = null var iris: String? = null
var main_relay: String? = null var main_relay: String? = null
var twitter: 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>(UserState(user)) { class UserLiveData(val user: User): LiveData<UserState>(UserState(user)) {

View File

@@ -1,16 +1,24 @@
package com.vitorpamplona.amethyst.ui.actions package com.vitorpamplona.amethyst.ui.actions
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController 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.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.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel 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.Account
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.UrlPreview 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.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.navigation.UploadFromGallery
import com.vitorpamplona.amethyst.ui.note.ReplyInformation import com.vitorpamplona.amethyst.ui.note.ReplyInformation
import com.vitorpamplona.amethyst.ui.note.UserDisplay
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import nostr.postr.events.TextNoteEvent import nostr.postr.events.TextNoteEvent
@@ -99,7 +115,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
postViewModel.sendPost() postViewModel.sendPost()
onClose() onClose()
}, },
postViewModel.message.isNotBlank() postViewModel.message.text.isNotBlank()
) )
} }
@@ -112,8 +128,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
OutlinedTextField( OutlinedTextField(
value = postViewModel.message, value = postViewModel.message,
onValueChange = { onValueChange = {
postViewModel.message = it postViewModel.updateMessage(it)
postViewModel.urlPreview = postViewModel.findUrlInMessage()
}, },
keyboardOptions = KeyboardOptions.Default.copy( keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences capitalization = KeyboardCapitalization.Sentences
@@ -141,30 +156,86 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, account: Account
.outlinedTextFieldColors( .outlinedTextFieldColors(
unfocusedBorderColor = Color.Transparent, unfocusedBorderColor = Color.Transparent,
focusedBorderColor = 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 val myUrlPreview = postViewModel.urlPreview
if (myUrlPreview != null) { if (myUrlPreview != null) {
Column(modifier = Modifier.padding(top = 5.dp)) { Column(modifier = Modifier.padding(top = 5.dp)) {
val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase() if (isValidURL(myUrlPreview)) {
if (imageExtension.matcher(removedParamsFromUrl).matches()) { val removedParamsFromUrl = myUrlPreview.split("?")[0].toLowerCase()
AsyncImage( if (imageExtension.matcher(removedParamsFromUrl).matches()) {
model = myUrlPreview, AsyncImage(
contentDescription = myUrlPreview, model = myUrlPreview,
contentScale = ContentScale.FillWidth, contentDescription = myUrlPreview,
modifier = Modifier contentScale = ContentScale.FillWidth,
.padding(top = 4.dp) modifier = Modifier
.fillMaxWidth() .padding(top = 4.dp)
.clip(shape = RoundedCornerShape(15.dp)) .fillMaxWidth()
.border( .clip(shape = RoundedCornerShape(15.dp))
1.dp, .border(
MaterialTheme.colors.onSurface.copy(alpha = 0.12f), 1.dp,
RoundedCornerShape(15.dp) MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
) RoundedCornerShape(15.dp)
) )
} else { )
UrlPreview("https://$myUrlPreview", myUrlPreview, false) } else if (videoExtension.matcher(removedParamsFromUrl).matches()) {
VideoView(myUrlPreview)
} else {
UrlPreview(myUrlPreview, myUrlPreview)
}
} else if (noProtocolUrlValidator.matcher(myUrlPreview).matches()) {
UrlPreview("https://$myUrlPreview", myUrlPreview)
} }
} }
} }

View File

@@ -8,12 +8,17 @@ import android.provider.MediaStore
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User 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.isValidURL
import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator import com.vitorpamplona.amethyst.ui.components.noProtocolUrlValidator
import nostr.postr.toNpub
class NewPostViewModel: ViewModel() { class NewPostViewModel: ViewModel() {
private var account: Account? = null private var account: Account? = null
@@ -22,9 +27,12 @@ class NewPostViewModel: ViewModel() {
var mentions by mutableStateOf<List<User>?>(null) var mentions by mutableStateOf<List<User>?>(null)
var replyTos by mutableStateOf<MutableList<Note>?>(null) var replyTos by mutableStateOf<MutableList<Note>?>(null)
var message by mutableStateOf("") var message by mutableStateOf(TextFieldValue(""))
var urlPreview by mutableStateOf<String?>(null) var urlPreview by mutableStateOf<String?>(null)
var userSuggestions by mutableStateOf<List<User>>(emptyList())
var userSuggestionAnchor: TextRange? = null
fun load(account: Account, replyingTo: Note?) { fun load(account: Account, replyingTo: Note?) {
originalNote = replyingTo originalNote = replyingTo
replyingTo?.let { replyNote -> replyingTo?.let { replyNote ->
@@ -36,9 +44,53 @@ class NewPostViewModel: ViewModel() {
this.account = account 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() { fun sendPost() {
account?.sendPost(message, originalNote, mentions) // Moves @npub to mentions
message = "" 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 urlPreview = null
} }
@@ -51,19 +103,19 @@ class NewPostViewModel: ViewModel() {
img?.let { img?.let {
ImageUploader.uploadImage(img) { ImageUploader.uploadImage(img) {
message = message + "\n\n" + it message = TextFieldValue(message.text + "\n\n" + it)
urlPreview = findUrlInMessage() urlPreview = findUrlInMessage()
} }
} }
} }
fun cancel() { fun cancel() {
message = "" message = TextFieldValue("")
urlPreview = null urlPreview = null
} }
fun findUrlInMessage(): String? { fun findUrlInMessage(): String? {
return message.split('\n').firstNotNullOfOrNull { paragraph -> return message.text.split('\n').firstNotNullOfOrNull { paragraph ->
paragraph.split(' ').firstOrNull { word: String -> paragraph.split(' ').firstOrNull { word: String ->
isValidURL(word) || noProtocolUrlValidator.matcher(word).matches() isValidURL(word) || noProtocolUrlValidator.matcher(word).matches()
} }
@@ -73,4 +125,34 @@ class NewPostViewModel: ViewModel() {
fun removeFromReplyList(it: User) { fun removeFromReplyList(it: User) {
mentions = mentions?.minus(it) 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()
}
}
} }

View File

@@ -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<RangesChanges>()
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
)
}