Moves LnInvoice, Tags, BechLinks and Previews to LaunchedEffect

This commit is contained in:
Vitor Pamplona
2023-04-07 16:58:25 -04:00
parent e1ce638d7f
commit dbf5267c5c
5 changed files with 233 additions and 162 deletions
@@ -5,10 +5,34 @@ import android.net.Uri
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.LocalTextStyle import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.material.Text
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextDirection
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun MayBeWithdrawal(lnurlWord: String) {
var lnWithdrawal by remember { mutableStateOf<String?>(null) }
LaunchedEffect(key1 = lnurlWord) {
withContext(Dispatchers.IO) {
lnWithdrawal = LnWithdrawalUtil.findWithdrawal(lnurlWord)
}
}
lnWithdrawal?.let {
ClickableWithdrawal(withdrawalString = it)
}
?: Text(
text = "$lnurlWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable @Composable
fun ClickableWithdrawal(withdrawalString: String) { fun ClickableWithdrawal(withdrawalString: String) {
@@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button import androidx.compose.material.*
import androidx.compose.material.ButtonDefaults import androidx.compose.runtime.*
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -24,22 +19,48 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.text.NumberFormat import java.text.NumberFormat
@Composable @Composable
fun InvoicePreview(lnInvoice: String) { fun MayBeInvoicePreview(lnbcWord: String) {
val amount = try { var lnInvoice by remember { mutableStateOf<Pair<String, BigDecimal?>?>(null) }
LnInvoiceUtil.getAmountInSats(lnInvoice)
LaunchedEffect(key1 = lnbcWord) {
withContext(Dispatchers.IO) {
val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord)
if (myInvoice != null) {
val myInvoiceAmount = try {
LnInvoiceUtil.getAmountInSats(myInvoice)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
null null
} }
lnInvoice = Pair(myInvoice, myInvoiceAmount)
}
}
}
lnInvoice?.let {
InvoicePreview(it.first, it.second)
}
?: Text(
text = "$lnbcWord ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
@Composable
fun InvoicePreview(lnInvoice: String, amount: BigDecimal?) {
val context = LocalContext.current val context = LocalContext.current
Column( Column(
@@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components
import android.util.Log import android.util.Log
import android.util.Patterns import android.util.Patterns
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -14,7 +13,7 @@ import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -34,12 +33,15 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material.MaterialRichText import com.halilibo.richtext.ui.material.MaterialRichText
import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil
import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil
import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.service.nip19.Nip19
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URISyntaxException import java.net.URISyntaxException
import java.net.URL import java.net.URL
@@ -114,7 +116,7 @@ fun RichTextViewer(
) )
) )
Column(modifier = modifier.animateContentSize()) { Column(modifier = modifier) {
if (content.startsWith("# ") || if (content.startsWith("# ") ||
content.contains("##") || content.contains("##") ||
content.contains("**") || content.contains("**") ||
@@ -164,25 +166,9 @@ fun RichTextViewer(
UrlPreview(word, "$word ") UrlPreview(word, "$word ")
} }
} else if (word.startsWith("lnbc", true)) { } else if (word.startsWith("lnbc", true)) {
val lnInvoice = LnInvoiceUtil.findInvoice(word) MayBeInvoicePreview(word)
if (lnInvoice != null) {
InvoicePreview(lnInvoice)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (word.startsWith("lnurl", true)) { } else if (word.startsWith("lnurl", true)) {
val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) MayBeWithdrawal(word)
if (lnWithdrawal != null) {
ClickableWithdrawal(withdrawalString = lnWithdrawal)
} else {
Text(
text = "$word ",
style = LocalTextStyle.current.copy(textDirection = TextDirection.Content)
)
}
} else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) {
ClickableEmail(word) ClickableEmail(word)
} else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) {
@@ -304,17 +290,27 @@ fun isBechLink(word: String): Boolean {
@Composable @Composable
fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val nip19Route = Nip19.uriToRoute(word) var nip19Route by remember { mutableStateOf<Nip19.Return?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
if (nip19Route == null) { LaunchedEffect(key1 = word) {
Text(text = "$word ") withContext(Dispatchers.IO) {
Nip19.uriToRoute(word)?.let {
if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) {
LocalCache.checkGetOrCreateNote(it.hex)?.let { note ->
baseNotePair = Pair(note, it.additionalChars)
}
} else { } else {
if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) { nip19Route = nip19Route
val note = LocalCache.checkGetOrCreateNote(nip19Route.hex) }
if (note != null) { }
}
}
if (canPreview) { if (canPreview) {
baseNotePair?.let {
NoteCompose( NoteCompose(
baseNote = note, baseNote = it.first,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
@@ -330,23 +326,30 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountV
isQuotedNote = true, isQuotedNote = true,
navController = navController navController = navController
) )
} else { Text(
ClickableRoute(nip19Route, navController) "${it.second} "
)
} ?: nip19Route?.let {
ClickableRoute(it, navController)
} }
?: Text(text = "$word ")
} else { } else {
ClickableRoute(nip19Route, navController) nip19Route?.let {
} ClickableRoute(it, navController)
} else {
ClickableRoute(nip19Route, navController)
} }
?: Text(text = "$word ")
} }
} }
@Composable @Composable
fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) {
var tagSuffixPair by remember { mutableStateOf<Pair<String, String?>?>(null) }
LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val hashtagMatcher = hashTagsPattern.matcher(word) val hashtagMatcher = hashTagsPattern.matcher(word)
val (tag, suffix) = try { val (myTag, mySuffix) = try {
hashtagMatcher.find() hashtagMatcher.find()
Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) Pair(hashtagMatcher.group(1), hashtagMatcher.group(2))
} catch (e: Exception) { } catch (e: Exception) {
@@ -354,17 +357,23 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
Pair(null, null) Pair(null, null)
} }
if (tag != null) { if (myTag != null) {
val hashtagIcon = checkForHashtagWithIcon(tag) tagSuffixPair = Pair(myTag, mySuffix)
}
}
}
tagSuffixPair?.let { tagPair ->
val hashtagIcon = checkForHashtagWithIcon(tagPair.first)
ClickableText( ClickableText(
text = buildAnnotatedString { text = buildAnnotatedString {
withStyle( withStyle(
LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle()
) { ) {
append("#$tag") append("#${tagPair.first}")
} }
}, },
onClick = { navController.navigate("Hashtag/$tag") } onClick = { navController.navigate("Hashtag/${tagPair.first}") }
) )
if (hashtagIcon != null) { if (hashtagIcon != null) {
@@ -387,7 +396,6 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
placeholderVerticalAlign = PlaceholderVerticalAlign.Center placeholderVerticalAlign = PlaceholderVerticalAlign.Center
) )
) { ) {
if (hashtagIcon != null) {
Icon( Icon(
painter = painterResource(hashtagIcon.icon), painter = painterResource(hashtagIcon.icon),
contentDescription = hashtagIcon.description, contentDescription = hashtagIcon.description,
@@ -395,7 +403,6 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
modifier = hashtagIcon.modifier modifier = hashtagIcon.modifier
) )
} }
}
) )
) )
@@ -405,17 +412,21 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav
inlineContent = inlineContent inlineContent = inlineContent
) )
} }
Text(text = "$suffix ") tagPair.second?.ifBlank { null }?.let {
} else { Text(text = "$it ")
Text(text = "$word ")
} }
} ?: Text(text = "$word ")
} }
@Composable @Composable
fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) {
val matcher = tagIndex.matcher(word) var baseUserPair by remember { mutableStateOf<Pair<User, String?>?>(null) }
var baseNotePair by remember { mutableStateOf<Pair<Note, String?>?>(null) }
val (index, extraCharacters) = try { LaunchedEffect(key1 = word) {
withContext(Dispatchers.IO) {
val matcher = tagIndex.matcher(word)
val (index, suffix) = try {
matcher.find() matcher.find()
Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "")
} catch (e: Exception) { } catch (e: Exception) {
@@ -423,26 +434,33 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
Pair(null, null) Pair(null, null)
} }
if (index == null) { if (index != null && index >= 0 && index < tags.size) {
return Text(text = "$word ") val tag = tags[index]
if (tag.size > 1) {
if (tag[0] == "p") {
LocalCache.checkGetOrCreateUser(tags[index][1])?.let {
baseUserPair = Pair(it, suffix)
}
} else if (tag[0] == "e" || tag[0] == "a") {
LocalCache.checkGetOrCreateNote(tags[index][1])?.let {
baseNotePair = Pair(it, suffix)
}
}
}
}
}
} }
if (index >= 0 && index < tags.size) { baseUserPair?.let {
if (tags[index][0] == "p") { ClickableUserTag(it.first, navController)
val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1]) Text(text = "${it.second} ")
if (baseUser != null) {
ClickableUserTag(baseUser, navController)
Text(text = "$extraCharacters ")
} else {
// if here the tag is not a valid Nostr Hex
Text(text = "$word ")
} }
} else if (tags[index][0] == "e") {
val note = LocalCache.checkGetOrCreateNote(tags[index][1]) baseNotePair?.let {
if (note != null) {
if (canPreview) { if (canPreview) {
NoteCompose( NoteCompose(
baseNote = note, baseNote = it.first,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier modifier = Modifier
.padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp)
@@ -458,16 +476,16 @@ fun TagLink(word: String, tags: List<List<String>>, canPreview: Boolean, backgro
isQuotedNote = true, isQuotedNote = true,
navController = navController navController = navController
) )
} else { it.second?.ifBlank { null }?.let {
ClickableNoteTag(note, navController) Text(text = "$it ")
Text(text = "$extraCharacters ")
} }
} else { } else {
// if here the tag is not a valid Nostr Hex ClickableNoteTag(it.first, navController)
Text(text = "${it.second} ")
}
}
if (baseNotePair == null && baseUserPair == null) {
Text(text = "$word ") Text(text = "$word ")
} }
} else {
Text(text = "$word ")
}
}
} }
@@ -65,7 +65,7 @@ import kotlin.math.ceil
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
import kotlin.time.measureTimedValue import kotlin.time.measureTimedValue
@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @OptIn(ExperimentalTime::class)
@Composable @Composable
fun NoteCompose( fun NoteCompose(
baseNote: Note, baseNote: Note,
@@ -130,6 +130,19 @@ fun NoteComposeInner(
var moreActionsExpanded by remember { mutableStateOf(false) } var moreActionsExpanded by remember { mutableStateOf(false) }
var isAcceptable by remember { mutableStateOf(true) }
var canPreview by remember { mutableStateOf(true) }
LaunchedEffect(key1 = noteReportsState) {
withContext(Dispatchers.IO) {
canPreview = note?.author === account.userProfile() ||
(note?.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
isAcceptable = account.isAcceptable(noteForReports)
}
}
val noteEvent = note?.event val noteEvent = note?.event
val baseChannel = note?.channel() val baseChannel = note?.channel()
@@ -141,7 +154,7 @@ fun NoteComposeInner(
), ),
isBoostedNote isBoostedNote
) )
} else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { } else if (!isAcceptable && !showHiddenNote) {
if (!account.isHidden(noteForReports.author!!)) { if (!account.isHidden(noteForReports.author!!)) {
HiddenNote( HiddenNote(
account.getRelevantReports(noteForReports), account.getRelevantReports(noteForReports),
@@ -343,11 +356,6 @@ fun NoteComposeInner(
Spacer(modifier = Modifier.height(3.dp)) Spacer(modifier = Modifier.height(3.dp))
if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { if (!makeItShort && noteEvent is TextNoteEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
val sortedMentions = noteEvent.mentions()
.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
val replyingDirectlyTo = note.replyTo?.lastOrNull() val replyingDirectlyTo = note.replyTo?.lastOrNull()
if (replyingDirectlyTo != null && unPackReply) { if (replyingDirectlyTo != null && unPackReply) {
NoteCompose( NoteCompose(
@@ -369,7 +377,7 @@ fun NoteComposeInner(
navController = navController navController = navController
) )
} else { } else {
ReplyInformation(note.replyTo, sortedMentions, account, navController) ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController)
} }
Spacer(modifier = Modifier.height(5.dp)) Spacer(modifier = Modifier.height(5.dp))
} else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) {
@@ -501,10 +509,6 @@ fun NoteComposeInner(
} else { } else {
val eventContent = accountViewModel.decrypt(note) val eventContent = accountViewModel.decrypt(note)
val canPreview = note.author == account.userProfile() ||
(note.author?.let { account.userProfile().isFollowingCached(it) } ?: true) ||
!noteForReports.hasAnyReports()
if (eventContent != null) { if (eventContent != null) {
if (makeItShort && note.author == account.userProfile()) { if (makeItShort && note.author == account.userProfile()) {
Text( Text(
@@ -524,7 +528,7 @@ fun NoteComposeInner(
navController navController
) )
DisplayUncitedHashtags(noteEvent, eventContent, navController) DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController)
} }
if (noteEvent is PollNoteEvent) { if (noteEvent is PollNoteEvent) {
@@ -582,11 +586,10 @@ fun DisplayFollowingHashtagsInPost(
@Composable @Composable
fun DisplayUncitedHashtags( fun DisplayUncitedHashtags(
noteEvent: EventInterface, hashtags: List<String>,
eventContent: String, eventContent: String,
navController: NavController navController: NavController
) { ) {
val hashtags = noteEvent.hashtags()
if (hashtags.isNotEmpty()) { if (hashtags.isNotEmpty()) {
FlowRow( FlowRow(
modifier = Modifier.padding(top = 5.dp) modifier = Modifier.padding(top = 5.dp)
@@ -868,7 +871,11 @@ private fun RelayBadges(baseNote: Note) {
items(relaysToDisplay.size) { items(relaysToDisplay.size) {
val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://") val url = relaysToDisplay[it].removePrefix("wss://").removePrefix("ws://")
Box(Modifier.padding(1.dp).size(15.dp)) { Box(
Modifier
.padding(1.dp)
.size(15.dp)
) {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = "https://$url/favicon.ico", robot = "https://$url/favicon.ico",
model = "https://$url/favicon.ico", model = "https://$url/favicon.ico",
@@ -16,14 +16,15 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.flowlayout.FlowRow
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.*
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@Composable @Composable
fun ReplyInformation(replyTo: List<Note>?, mentions: List<User>?, account: Account, navController: NavController) { fun ReplyInformation(replyTo: List<Note>?, mentions: List<String>, account: Account, navController: NavController) {
ReplyInformation(replyTo, mentions, account) { val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) }
.toSet()
.sortedBy { account.userProfile().isFollowingCached(it) }
ReplyInformation(replyTo, sortedMentions, account) {
navController.navigate("User/${it.pubkeyHex}") navController.navigate("User/${it.pubkeyHex}")
} }
} }