mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-09-28 07:57:05 +02:00
Tags users during message compose.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||||
|
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 @
|
||||||
|
}
|
||||||
|
}
|
@@ -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)
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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)) {
|
||||||
|
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
Reference in New Issue
Block a user