From dbf5267c5ce29f85ae069633dbee6f3f0de1fa11 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 7 Apr 2023 16:58:25 -0400 Subject: [PATCH] Moves LnInvoice, Tags, BechLinks and Previews to LaunchedEffect --- .../ui/components/ClickableWithdrawal.kt | 26 +- .../amethyst/ui/components/InvoicePreview.kt | 47 ++- .../amethyst/ui/components/RichTextViewer.kt | 270 ++++++++++-------- .../amethyst/ui/note/NoteCompose.kt | 39 +-- .../amethyst/ui/note/ReplyInformation.kt | 13 +- 5 files changed, 233 insertions(+), 162 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 418e25b45..51a335c3e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -5,10 +5,34 @@ import android.net.Uri import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle 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.text.AnnotatedString +import androidx.compose.ui.text.style.TextDirection 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(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 fun ClickableWithdrawal(withdrawalString: String) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index f94516400..d86f81c55 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -9,13 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -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.material.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.stringResource 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.sp import androidx.core.content.ContextCompat.startActivity import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.math.BigDecimal import java.text.NumberFormat @Composable -fun InvoicePreview(lnInvoice: String) { - val amount = try { - LnInvoiceUtil.getAmountInSats(lnInvoice) - } catch (e: Exception) { - e.printStackTrace() - null +fun MayBeInvoicePreview(lnbcWord: String) { + var lnInvoice by remember { mutableStateOf?>(null) } + + LaunchedEffect(key1 = lnbcWord) { + withContext(Dispatchers.IO) { + val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) + if (myInvoice != null) { + val myInvoiceAmount = try { + LnInvoiceUtil.getAmountInSats(myInvoice) + } catch (e: Exception) { + e.printStackTrace() + 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 Column( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index a77a187a8..fcdaa816a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -2,7 +2,6 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log import android.util.Patterns -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -14,7 +13,7 @@ import androidx.compose.material.Icon import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.resolveDefaults 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.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.service.lnurl.LnWithdrawalUtil import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL @@ -114,7 +116,7 @@ fun RichTextViewer( ) ) - Column(modifier = modifier.animateContentSize()) { + Column(modifier = modifier) { if (content.startsWith("# ") || content.contains("##") || content.contains("**") || @@ -164,25 +166,9 @@ fun RichTextViewer( UrlPreview(word, "$word ") } } else if (word.startsWith("lnbc", true)) { - val lnInvoice = LnInvoiceUtil.findInvoice(word) - if (lnInvoice != null) { - InvoicePreview(lnInvoice) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + MayBeInvoicePreview(word) } else if (word.startsWith("lnurl", true)) { - val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) - if (lnWithdrawal != null) { - ClickableWithdrawal(withdrawalString = lnWithdrawal) - } else { - Text( - text = "$word ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + MayBeWithdrawal(word) } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { ClickableEmail(word) } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { @@ -304,67 +290,90 @@ fun isBechLink(word: String): Boolean { @Composable fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { - val nip19Route = Nip19.uriToRoute(word) + var nip19Route by remember { mutableStateOf(null) } + var baseNotePair by remember { mutableStateOf?>(null) } - if (nip19Route == null) { - Text(text = "$word ") - } else { - if (nip19Route.type == Nip19.Type.NOTE || nip19Route.type == Nip19.Type.EVENT || nip19Route.type == Nip19.Type.ADDRESS) { - val note = LocalCache.checkGetOrCreateNote(nip19Route.hex) - if (note != null) { - if (canPreview) { - NoteCompose( - baseNote = note, - accountViewModel = accountViewModel, - modifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ), - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) - .compositeOver(backgroundColor), - isQuotedNote = true, - navController = navController - ) + LaunchedEffect(key1 = 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 { - ClickableRoute(nip19Route, navController) + nip19Route = nip19Route } - } else { - ClickableRoute(nip19Route, navController) } - } else { - ClickableRoute(nip19Route, navController) } } + + if (canPreview) { + baseNotePair?.let { + NoteCompose( + baseNote = it.first, + accountViewModel = accountViewModel, + modifier = Modifier + .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ), + parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) + .compositeOver(backgroundColor), + isQuotedNote = true, + navController = navController + ) + Text( + "${it.second} " + ) + } ?: nip19Route?.let { + ClickableRoute(it, navController) + } + ?: Text(text = "$word ") + } else { + nip19Route?.let { + ClickableRoute(it, navController) + } + ?: Text(text = "$word ") + } } @Composable fun HashTag(word: String, accountViewModel: AccountViewModel, navController: NavController) { - val hashtagMatcher = hashTagsPattern.matcher(word) + var tagSuffixPair by remember { mutableStateOf?>(null) } - val (tag, suffix) = try { - hashtagMatcher.find() - Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) - } catch (e: Exception) { - Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) - Pair(null, null) + LaunchedEffect(key1 = word) { + withContext(Dispatchers.IO) { + val hashtagMatcher = hashTagsPattern.matcher(word) + + val (myTag, mySuffix) = try { + hashtagMatcher.find() + Pair(hashtagMatcher.group(1), hashtagMatcher.group(2)) + } catch (e: Exception) { + Log.e("Hashtag Parser", "Couldn't link hashtag $word", e) + Pair(null, null) + } + + if (myTag != null) { + tagSuffixPair = Pair(myTag, mySuffix) + } + } } - if (tag != null) { - val hashtagIcon = checkForHashtagWithIcon(tag) + tagSuffixPair?.let { tagPair -> + val hashtagIcon = checkForHashtagWithIcon(tagPair.first) ClickableText( text = buildAnnotatedString { withStyle( 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) { @@ -387,14 +396,12 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav placeholderVerticalAlign = PlaceholderVerticalAlign.Center ) ) { - if (hashtagIcon != null) { - Icon( - painter = painterResource(hashtagIcon.icon), - contentDescription = hashtagIcon.description, - tint = hashtagIcon.color, - modifier = hashtagIcon.modifier - ) - } + Icon( + painter = painterResource(hashtagIcon.icon), + contentDescription = hashtagIcon.description, + tint = hashtagIcon.color, + modifier = hashtagIcon.modifier + ) } ) ) @@ -405,69 +412,80 @@ fun HashTag(word: String, accountViewModel: AccountViewModel, navController: Nav inlineContent = inlineContent ) } - Text(text = "$suffix ") - } else { - Text(text = "$word ") - } + tagPair.second?.ifBlank { null }?.let { + Text(text = "$it ") + } + } ?: Text(text = "$word ") } @Composable fun TagLink(word: String, tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, navController: NavController) { - val matcher = tagIndex.matcher(word) + var baseUserPair by remember { mutableStateOf?>(null) } + var baseNotePair by remember { mutableStateOf?>(null) } - val (index, extraCharacters) = try { - matcher.find() - Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") - } catch (e: Exception) { - Log.w("Tag Parser", "Couldn't link tag $word", e) - Pair(null, null) - } - - if (index == null) { - return Text(text = "$word ") - } - - if (index >= 0 && index < tags.size) { - if (tags[index][0] == "p") { - val baseUser = LocalCache.checkGetOrCreateUser(tags[index][1]) - if (baseUser != null) { - ClickableUserTag(baseUser, navController) - Text(text = "$extraCharacters ") - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") + LaunchedEffect(key1 = word) { + withContext(Dispatchers.IO) { + val matcher = tagIndex.matcher(word) + val (index, suffix) = try { + matcher.find() + Pair(matcher.group(1)?.toInt(), matcher.group(2) ?: "") + } catch (e: Exception) { + Log.w("Tag Parser", "Couldn't link tag $word", e) + Pair(null, null) } - } else if (tags[index][0] == "e") { - val note = LocalCache.checkGetOrCreateNote(tags[index][1]) - if (note != null) { - if (canPreview) { - NoteCompose( - baseNote = note, - accountViewModel = accountViewModel, - modifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ), - parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) - .compositeOver(backgroundColor), - isQuotedNote = true, - navController = navController - ) - } else { - ClickableNoteTag(note, navController) - Text(text = "$extraCharacters ") + + if (index != null && index >= 0 && index < tags.size) { + 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) + } + } } - } else { - // if here the tag is not a valid Nostr Hex - Text(text = "$word ") } - } else { - Text(text = "$word ") } } + + baseUserPair?.let { + ClickableUserTag(it.first, navController) + Text(text = "${it.second} ") + } + + baseNotePair?.let { + if (canPreview) { + NoteCompose( + baseNote = it.first, + accountViewModel = accountViewModel, + modifier = Modifier + .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + MaterialTheme.colors.onSurface.copy(alpha = 0.12f), + RoundedCornerShape(15.dp) + ), + parentBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.05f) + .compositeOver(backgroundColor), + isQuotedNote = true, + navController = navController + ) + it.second?.ifBlank { null }?.let { + Text(text = "$it ") + } + } else { + ClickableNoteTag(it.first, navController) + Text(text = "${it.second} ") + } + } + + if (baseNotePair == null && baseUserPair == null) { + Text(text = "$word ") + } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 7d299b827..8aef73300 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -65,7 +65,7 @@ import kotlin.math.ceil import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue -@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) +@OptIn(ExperimentalTime::class) @Composable fun NoteCompose( baseNote: Note, @@ -130,6 +130,19 @@ fun NoteComposeInner( 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 baseChannel = note?.channel() @@ -141,7 +154,7 @@ fun NoteComposeInner( ), isBoostedNote ) - } else if (!account.isAcceptable(noteForReports) && !showHiddenNote) { + } else if (!isAcceptable && !showHiddenNote) { if (!account.isHidden(noteForReports.author!!)) { HiddenNote( account.getRelevantReports(noteForReports), @@ -343,11 +356,6 @@ fun NoteComposeInner( Spacer(modifier = Modifier.height(3.dp)) 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() if (replyingDirectlyTo != null && unPackReply) { NoteCompose( @@ -369,7 +377,7 @@ fun NoteComposeInner( navController = navController ) } else { - ReplyInformation(note.replyTo, sortedMentions, account, navController) + ReplyInformation(note.replyTo, noteEvent.mentions(), account, navController) } Spacer(modifier = Modifier.height(5.dp)) } else if (!makeItShort && noteEvent is ChannelMessageEvent && (note.replyTo != null || noteEvent.mentions().isNotEmpty())) { @@ -501,10 +509,6 @@ fun NoteComposeInner( } else { 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 (makeItShort && note.author == account.userProfile()) { Text( @@ -524,7 +528,7 @@ fun NoteComposeInner( navController ) - DisplayUncitedHashtags(noteEvent, eventContent, navController) + DisplayUncitedHashtags(noteEvent.hashtags(), eventContent, navController) } if (noteEvent is PollNoteEvent) { @@ -582,11 +586,10 @@ fun DisplayFollowingHashtagsInPost( @Composable fun DisplayUncitedHashtags( - noteEvent: EventInterface, + hashtags: List, eventContent: String, navController: NavController ) { - val hashtags = noteEvent.hashtags() if (hashtags.isNotEmpty()) { FlowRow( modifier = Modifier.padding(top = 5.dp) @@ -868,7 +871,11 @@ private fun RelayBadges(baseNote: Note) { items(relaysToDisplay.size) { 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( robot = "https://$url/favicon.ico", model = "https://$url/favicon.ico", diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 72c92184e..e755a8df1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -16,14 +16,15 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Channel -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.* @Composable -fun ReplyInformation(replyTo: List?, mentions: List?, account: Account, navController: NavController) { - ReplyInformation(replyTo, mentions, account) { +fun ReplyInformation(replyTo: List?, mentions: List, account: Account, navController: NavController) { + val sortedMentions = mentions.mapNotNull { LocalCache.checkGetOrCreateUser(it) } + .toSet() + .sortedBy { account.userProfile().isFollowingCached(it) } + + ReplyInformation(replyTo, sortedMentions, account) { navController.navigate("User/${it.pubkeyHex}") } }