diff --git a/app/build.gradle b/app/build.gradle index cf059f08f..bf5220140 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,8 +154,6 @@ dependencies { // create blurhash implementation group: 'io.trbl', name: 'blurhash', version: '1.0.0' - // Rendering clickable text - implementation "com.google.accompanist:accompanist-flowlayout:$accompanist_version" // Permission to upload pictures: implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" diff --git a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index a8cb1e8d9..129f41dd5 100644 --- a/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -10,7 +10,7 @@ fun TranslatableRichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 3ebe69dbb..686055eef 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -24,7 +24,6 @@ import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.outlined.ArrowForwardIos import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.rounded.Warning @@ -291,7 +290,7 @@ fun NewPostView(onClose: () -> Unit, baseReplyTo: Note? = null, quote: Note? = n } else { UrlPreview(myUrlPreview, myUrlPreview) } - } else if (isBechLink(myUrlPreview)) { + } else if (startsWithNIP19Scheme(myUrlPreview)) { BechLink( myUrlPreview, true, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index fe49bce6c..b9fd830e5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -47,6 +47,9 @@ import com.vitorpamplona.amethyst.service.model.ChannelCreateEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.LoadChannel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -311,11 +314,51 @@ fun CreateTextWithEmoji( overflow: TextOverflow = TextOverflow.Clip, modifier: Modifier = Modifier ) { - val emojis = remember { - tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + + LaunchedEffect(key1 = text) { + launch(Dispatchers.Default) { + val emojis = + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } + } } - CreateTextWithEmoji(text, emojis, color, textAlign, fontWeight, fontSize, maxLines, overflow, modifier) + val textColor = color.takeOrElse { + LocalTextStyle.current.color.takeOrElse { + LocalContentColor.current.copy(alpha = LocalContentAlpha.current) + } + } + + if (emojiList.isEmpty()) { + Text( + text = text, + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize, + maxLines = maxLines, + overflow = overflow, + modifier = modifier + ) + } else { + val style = LocalTextStyle.current.merge( + TextStyle( + color = textColor, + textAlign = textAlign, + fontWeight = fontWeight, + fontSize = fontSize + ) + ).toSpanStyle() + + InLineIconRenderer(emojiList, style, maxLines, overflow, modifier) + } } @Composable @@ -330,13 +373,26 @@ fun CreateTextWithEmoji( overflow: TextOverflow = TextOverflow.Clip, modifier: Modifier = Modifier ) { + var emojiList by remember(text) { mutableStateOf>(persistentListOf()) } + + LaunchedEffect(key1 = text) { + if (emojis.isNotEmpty()) { + launch(Dispatchers.Default) { + val newEmojiList = assembleAnnotatedList(text, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } + } + } + val textColor = color.takeOrElse { LocalTextStyle.current.color.takeOrElse { LocalContentColor.current.copy(alpha = LocalContentAlpha.current) } } - if (emojis.isEmpty()) { + if (emojiList.isEmpty()) { Text( text = text, color = textColor, @@ -348,10 +404,6 @@ fun CreateTextWithEmoji( modifier = modifier ) } else { - val myList = remember { - assembleAnnotatedList(text, emojis) - } - val style = LocalTextStyle.current.merge( TextStyle( color = textColor, @@ -361,7 +413,7 @@ fun CreateTextWithEmoji( ) ).toSpanStyle() - InLineIconRenderer(myList, style, maxLines, overflow, modifier) + InLineIconRenderer(emojiList, style, maxLines, overflow, modifier) } } @@ -372,22 +424,30 @@ fun CreateClickableTextWithEmoji( style: TextStyle, onClick: (Int) -> Unit ) { - val emojis = remember(tags) { - tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + var emojiList by remember(clickablePart) { mutableStateOf>(persistentListOf()) } + + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + + if (emojis.isNotEmpty()) { + val newEmojiList = assembleAnnotatedList(clickablePart, emojis) + if (newEmojiList.isNotEmpty()) { + emojiList = newEmojiList.toImmutableList() + } + } + } } - if (emojis.isEmpty()) { + if (emojiList.isEmpty()) { ClickableText( AnnotatedString(clickablePart), style = style, onClick = onClick ) } else { - val myList = remember { - assembleAnnotatedList(clickablePart, emojis) - } - - ClickableInLineIconRenderer(myList, style.toSpanStyle()) { + ClickableInLineIconRenderer(emojiList, style.toSpanStyle()) { onClick(it) } } @@ -403,30 +463,38 @@ fun CreateClickableTextWithEmoji( route: String, nav: (String) -> Unit ) { - val emojis = remember(tags) { - tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + var emojiLists by remember(clickablePart) { + mutableStateOf, ImmutableList>?>(null) } - if (emojis.isEmpty()) { + LaunchedEffect(key1 = clickablePart) { + launch(Dispatchers.Default) { + val emojis = + tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() + + if (emojis.isNotEmpty()) { + val newEmojiList1 = assembleAnnotatedList(clickablePart, emojis) + val newEmojiList2 = assembleAnnotatedList(suffix, emojis) + + if (newEmojiList1.isNotEmpty() || newEmojiList2.isNotEmpty()) { + emojiLists = Pair(newEmojiList1.toImmutableList(), newEmojiList2.toImmutableList()) + } + } + } + } + + if (emojiLists == null) { CreateClickableText(clickablePart, suffix, overrideColor, fontWeight, route, nav) } else { - val myList = remember { - assembleAnnotatedList(clickablePart, emojis) - } - - ClickableInLineIconRenderer(myList, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) { + ClickableInLineIconRenderer(emojiLists!!.first, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.primary, fontWeight = fontWeight).toSpanStyle()) { nav(route) } - val myList2 = remember { - assembleAnnotatedList(suffix, emojis) - } - - InLineIconRenderer(myList2, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()) + InLineIconRenderer(emojiLists!!.second, LocalTextStyle.current.copy(color = overrideColor ?: MaterialTheme.colors.onBackground, fontWeight = fontWeight).toSpanStyle()) } } -fun assembleAnnotatedList(text: String, emojis: Map): List { +suspend fun assembleAnnotatedList(text: String, emojis: Map): List { return NIP30Parser().buildArray(text).map { val url = emojis[it] if (url != null) { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt index b0f948fb3..e4fa8eb88 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ExpandableRichTextViewer.kt @@ -35,7 +35,7 @@ fun ExpandableRichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit 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 3a194b321..fe1ea9517 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 @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.components import android.util.Log +import android.util.LruCache import android.util.Patterns import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -26,7 +27,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.accompanist.flowlayout.FlowRow import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions import com.halilibo.richtext.ui.RichTextStyle @@ -38,7 +38,6 @@ 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.LnWithdrawalUtil import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -58,6 +57,7 @@ import java.net.MalformedURLException import java.net.URISyntaxException import java.net.URL import java.util.regex.Pattern +import kotlin.time.ExperimentalTime val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3") @@ -98,15 +98,13 @@ fun RichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val isMarkdown = remember(content) { isMarkdown(content) } - Column(modifier = modifier) { - if (isMarkdown) { + if (remember(content) { isMarkdown(content) }) { RenderContentAsMarkdown(content, backgroundColor, tags, nav) } else { RenderRegular(content, tags, canPreview, backgroundColor, accountViewModel, nav) @@ -114,63 +112,33 @@ fun RichTextViewer( } } -@Stable -class RichTextViewerState( - val content: String, +val urlSetCache = LruCache(200) + +@Immutable +data class RichTextViewerState( val urlSet: ImmutableSet, val imagesForPager: ImmutableMap, val imageList: ImmutableList, val customEmoji: ImmutableMap ) +@OptIn(ExperimentalTime::class, ExperimentalLayoutApi::class) @Composable private fun RenderRegular( content: String, - tags: List>?, + tags: List>, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - var state by remember(content) { - mutableStateOf( - RichTextViewerState( - content, - persistentSetOf(), - persistentMapOf(), - persistentListOf(), - persistentMapOf() - ) - ) - } - - LaunchedEffect(key1 = content) { - launch(Dispatchers.IO) { - val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() - val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } - val imagesForPager = urlSet.mapNotNull { fullUrl -> - val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() - if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableUrlImage(fullUrl) - } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { - ZoomableUrlVideo(fullUrl) - } else { - null - } - }.associateBy { it.url } - val imageList = imagesForPager.values.toList() - - val emojiMap = tags?.filter { it.size > 2 && it[0] == "emoji" }?.associate { ":${it[1]}:" to it[2] } ?: emptyMap() - - if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) { - state = RichTextViewerState( - content, - urlSet.toImmutableSet(), - imagesForPager.toImmutableMap(), - imageList.toImmutableList(), - emojiMap.toImmutableMap() - ) - } + val state by remember(content) { + if (urlSetCache[content] != null) { + mutableStateOf(urlSetCache[content]) + } else { + val newUrls = parseUrls(content, tags) + urlSetCache.put(content, newUrls) + mutableStateOf(newUrls) } } @@ -185,12 +153,62 @@ private fun RenderRegular( } } s.forEach { word: String -> - RenderWord(word, state, canPreview, backgroundColor, accountViewModel, nav, tags) + RenderWord( + word, + state, + canPreview, + backgroundColor, + accountViewModel, + nav, + tags + ) } } } } +private fun parseUrls( + content: String, + tags: List> +): RichTextViewerState { + val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() + val urlSet = urls.mapTo(LinkedHashSet(urls.size)) { it.originalUrl } + val imagesForPager = urlSet.mapNotNull { fullUrl -> + val removedParamsFromUrl = fullUrl.split("?")[0].lowercase() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableUrlImage(fullUrl) + } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { + ZoomableUrlVideo(fullUrl) + } else { + null + } + }.associateBy { it.url } + val imageList = imagesForPager.values.toList() + + val emojiMap = + tags.filter { it.size > 2 && it[0] == "emoji" }.associate { ":${it[1]}:" to it[2] } + + return if (urlSet.isNotEmpty() || emojiMap.isNotEmpty()) { + RichTextViewerState( + urlSet.toImmutableSet(), + imagesForPager.toImmutableMap(), + imageList.toImmutableList(), + emojiMap.toImmutableMap() + ) + } else { + RichTextViewerState( + persistentSetOf(), + persistentMapOf(), + persistentListOf(), + persistentMapOf() + ) + } +} + +enum class WordType { + IMAGE, LINK, EMOJI, INVOICE, WITHDRAW, EMAIL, PHONE, BECH, HASH_INDEX, HASHTAG, SCHEMELESS_URL, OTHER +} + @Composable private fun RenderWord( word: String, @@ -199,150 +217,167 @@ private fun RenderWord( backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit, - tags: List>? + tags: List> +) { + val type = remember(word) { + if (state.imagesForPager[word] != null) { + WordType.IMAGE + } else if (state.urlSet.contains(word)) { + WordType.LINK + } else if (state.customEmoji.any { word.contains(it.key) }) { + WordType.EMOJI + } else if (word.startsWith("lnbc", true)) { + WordType.INVOICE + } else if (word.startsWith("lnurl", true)) { + WordType.WITHDRAW + } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { + WordType.EMAIL + } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { + WordType.PHONE + } else if (startsWithNIP19Scheme(word)) { + WordType.BECH + } else if (word.startsWith("#")) { + if (tagIndex.matcher(word).matches()) { + if (tags.isNotEmpty()) { + WordType.HASH_INDEX + } else { + WordType.OTHER + } + } else if (hashTagsPattern.matcher(word).matches()) { + WordType.HASHTAG + } else { + WordType.OTHER + } + } else if (noProtocolUrlValidator.matcher(word).matches()) { + WordType.SCHEMELESS_URL + } else { + WordType.OTHER + } + } + + if (canPreview) { + RenderWordWithPreview(type, word, state, tags, backgroundColor, accountViewModel, nav) + } else { + RenderWordWithoutPreview(type, word, state, tags, backgroundColor, accountViewModel, nav) + } +} + +@Composable +private fun RenderWordWithoutPreview( + type: WordType, + word: String, + state: RichTextViewerState, + tags: List>, + backgroundColor: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit ) { val wordSpace = remember(word) { "$word " } - if (canPreview) { - // Explicit URL - val img = state.imagesForPager[word] - if (img != null) { - ZoomableContentView(img, state.imageList) - } else if (state.urlSet.contains(word)) { - UrlPreview(word, wordSpace) - } else if (state.customEmoji.any { word.contains(it.key) }) { - RenderCustomEmoji(word, state.customEmoji) - } else if (word.startsWith("lnbc", true)) { - MayBeInvoicePreview(word) - } else if (word.startsWith("lnurl", true)) { - MayBeWithdrawal(word) - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (word.length > 6 && Patterns.PHONE.matcher(word).matches()) { - ClickablePhone(word) - } else if (isBechLink(word)) { - BechLink( - word, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - TagLink( - word, - tags, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, nav) - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (noProtocolUrlValidator.matcher(word).matches()) { - val matcher = noProtocolUrlValidator.matcher(word) - matcher.find() - val url = matcher.group(1) // url - val additionalChars = matcher.group(4) ?: "" // additional chars - - if (url != null) { - ClickableUrl(url, "https://$url") - Text("$additionalChars ") - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else { - if (state.urlSet.contains(word)) { - ClickableUrl(wordSpace, word) - } else if (word.startsWith("lnurl", true)) { - val lnWithdrawal = LnWithdrawalUtil.findWithdrawal(word) - if (lnWithdrawal != null) { - ClickableWithdrawal(withdrawalString = lnWithdrawal) - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (state.customEmoji.any { word.contains(it.key) }) { - RenderCustomEmoji(word, state.customEmoji) - } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { - ClickableEmail(word) - } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { - ClickablePhone(word) - } else if (isBechLink(word)) { - BechLink( - word, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } else if (word.startsWith("#")) { - if (tagIndex.matcher(word).matches() && tags != null) { - TagLink( - word, - tags, - canPreview, - backgroundColor, - accountViewModel, - nav - ) - } else if (hashTagsPattern.matcher(word).matches()) { - HashTag(word, nav) - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else if (noProtocolUrlValidator.matcher(word).matches()) { - val matcher = noProtocolUrlValidator.matcher(word) - matcher.find() - val url = matcher.group(1) // url - val additionalChars = matcher.group(4) ?: "" // additional chars - - if (url != null) { - ClickableUrl(url, "https://$url") - Text("$additionalChars ") - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } - } else { - Text( - text = wordSpace, - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) - } + when (type) { + // Don't preview Images + WordType.IMAGE -> UrlPreview(word, wordSpace) + WordType.LINK -> UrlPreview(word, wordSpace) + WordType.EMOJI -> RenderCustomEmoji(word, state) + // Don't offer to pay invoices + WordType.INVOICE -> NormalWord(wordSpace) + // Don't offer to withdraw + WordType.WITHDRAW -> NormalWord(wordSpace) + WordType.EMAIL -> ClickableEmail(word) + WordType.PHONE -> ClickablePhone(word) + WordType.BECH -> BechLink(word, false, backgroundColor, accountViewModel, nav) + WordType.HASHTAG -> HashTag(word, nav) + WordType.HASH_INDEX -> TagLink(word, tags, false, backgroundColor, accountViewModel, nav) + WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word) + WordType.OTHER -> NormalWord(wordSpace) } } @Composable -fun RenderCustomEmoji(word: String, customEmoji: Map) { +private fun RenderWordWithPreview( + type: WordType, + word: String, + state: RichTextViewerState, + tags: List>, + backgroundColor: Color, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + val wordSpace = remember(word) { + "$word " + } + + when (type) { + WordType.IMAGE -> ZoomableContentView(word, state) + WordType.LINK -> UrlPreview(word, wordSpace) + WordType.EMOJI -> RenderCustomEmoji(word, state) + WordType.INVOICE -> MayBeInvoicePreview(word) + WordType.WITHDRAW -> MayBeWithdrawal(word) + WordType.EMAIL -> ClickableEmail(word) + WordType.PHONE -> ClickablePhone(word) + WordType.BECH -> BechLink(word, true, backgroundColor, accountViewModel, nav) + WordType.HASHTAG -> HashTag(word, nav) + WordType.HASH_INDEX -> TagLink(word, tags, true, backgroundColor, accountViewModel, nav) + WordType.SCHEMELESS_URL -> NoProtocolUrlRenderer(word) + WordType.OTHER -> NormalWord(wordSpace) + } +} + +@Composable +private fun ZoomableContentView(word: String, state: RichTextViewerState) { + state.imagesForPager[word]?.let { + ZoomableContentView(it, state.imageList) + } +} + +@Composable +private fun NormalWord(word: String) { + Text( + text = word, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) +} + +@Composable +private fun NoProtocolUrlRenderer(word: String) { + val wordSpace = remember(word) { + "$word " + } + + var linkedUrl by remember(word) { + mutableStateOf?>(null) + } + + LaunchedEffect(key1 = word) { + launch(Dispatchers.Default) { + val matcher = noProtocolUrlValidator.matcher(word) + if (matcher.find()) { + val url = matcher.group(1) // url + val additionalChars = matcher.group(4) ?: "" // additional chars + + linkedUrl = Pair(url, additionalChars) + } + } + } + + if (linkedUrl != null) { + ClickableUrl(linkedUrl!!.first, "https://${linkedUrl!!.first}") + Text("${linkedUrl!!.second} ") + } else { + Text( + text = wordSpace, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } +} + +@Composable +fun RenderCustomEmoji(word: String, state: RichTextViewerState) { CreateTextWithEmoji( - text = "$word ", - emojis = customEmoji + text = remember { "$word " }, + emojis = state.customEmoji ) } @@ -382,19 +417,38 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag ) ) - var markdownWithSpecialContent by remember(content) { mutableStateOf(null) } - var nip19References by remember(content) { mutableStateOf>(emptyList()) } - var refresh by remember(content) { mutableStateOf(0) } - - LaunchedEffect(key1 = content) { - launch(Dispatchers.IO) { - nip19References = returnNIP19References(content, tags) - markdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags) + val uri = LocalUriHandler.current + val onClick = { link: String -> + val route = uriToRoute(link) + if (route != null) { + nav(route) + } else { + runCatching { uri.openUri(link) } } + Unit } - LaunchedEffect(key1 = refresh) { - launch(Dispatchers.IO) { + MaterialRichText( + style = myMarkDownStyle + ) { + RefreshableContent(content, tags) { + Markdown( + content = it, + markdownParseOptions = MarkdownParseOptions.Default, + onLinkClicked = onClick + ) + } + } +} + +@Composable +private fun RefreshableContent(content: String, tags: List>?, onCompose: @Composable (String) -> Unit) { + var markdownWithSpecialContent by remember(content) { mutableStateOf(content) } + + val scope = rememberCoroutineScope() + + ObserverAllNIP19References(content, tags) { + scope.launch(Dispatchers.IO) { val newMarkdownWithSpecialContent = returnMarkdownWithSpecialContent(content, tags) if (markdownWithSpecialContent != newMarkdownWithSpecialContent) { markdownWithSpecialContent = newMarkdownWithSpecialContent @@ -402,30 +456,22 @@ private fun RenderContentAsMarkdown(content: String, backgroundColor: Color, tag } } - nip19References.forEach { - ObserveNIP19(it) { - refresh++ + markdownWithSpecialContent?.let { + onCompose(it) + } +} + +@Composable +fun ObserverAllNIP19References(content: String, tags: List>?, onRefresh: () -> Unit) { + var nip19References by remember(content) { mutableStateOf>(emptyList()) } + LaunchedEffect(key1 = content) { + launch(Dispatchers.IO) { + nip19References = returnNIP19References(content, tags) } } - val uri = LocalUriHandler.current - - markdownWithSpecialContent?.let { - MaterialRichText( - style = myMarkDownStyle - ) { - Markdown( - content = it, - markdownParseOptions = MarkdownParseOptions.Default - ) { link -> - val route = uriToRoute(link) - if (route != null) { - nav(route) - } else { - runCatching { uri.openUri(link) } - } - } - } + nip19References.forEach { + ObserveNIP19(it, onRefresh) } } @@ -444,7 +490,7 @@ fun ObserveNIP19( @Composable private fun ObserveNIP19Event( it: Nip19.Return, - onRefresh: () -> Unit + onRefresh: suspend () -> Unit ) { var baseNote by remember(it) { mutableStateOf(null) } @@ -560,7 +606,7 @@ private fun returnNIP19References(content: String, tags: List>?): L val listOfReferences = mutableListOf() content.split('\n').forEach { paragraph -> paragraph.split(' ').forEach { word: String -> - if (isBechLink(word)) { + if (startsWithNIP19Scheme(word)) { val parsedNip19 = Nip19.uriToRoute(word) parsedNip19?.let { listOfReferences.add(it) @@ -587,12 +633,17 @@ private fun returnMarkdownWithSpecialContent(content: String, tags: List paragraph.split(' ').forEach { word: String -> if (isValidURL(word)) { - returnContent += "[$word]($word) " + val removedParamsFromUrl = word.split("?")[0].lowercase() + if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { + returnContent += "$word " + } else { + returnContent += "[$word]($word) " + } } else if (Patterns.EMAIL_ADDRESS.matcher(word).matches()) { returnContent += "[$word](mailto:$word) " } else if (Patterns.PHONE.matcher(word).matches() && word.length > 6) { returnContent += "[$word](tel:$word) " - } else if (isBechLink(word)) { + } else if (startsWithNIP19Scheme(word)) { val parsedNip19 = Nip19.uriToRoute(word) returnContent += if (parsedNip19 !== null) { val pair = getDisplayNameFromNip19(parsedNip19) @@ -645,7 +696,7 @@ private fun isArabic(text: String): Boolean { return text.any { it in '\u0600'..'\u06FF' || it in '\u0750'..'\u077F' } } -fun isBechLink(word: String): Boolean { +fun startsWithNIP19Scheme(word: String): Boolean { val cleaned = word.lowercase().removePrefix("@").removePrefix("nostr:").removePrefix("@") return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 42684074b..9e19315ff 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -65,7 +67,6 @@ import androidx.compose.ui.window.DialogProperties import coil.annotation.ExperimentalCoilApi import coil.compose.AsyncImage import coil.imageLoader -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.toHexKey import com.vitorpamplona.amethyst.service.BlurHashRequester @@ -208,6 +209,7 @@ fun ZoomableContentView(content: ZoomableContent, images: List } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun LocalImageView( content: ZoomableLocalImage, @@ -271,6 +273,7 @@ private fun LocalImageView( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun UrlImageView( content: ZoomableUrlImage, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt index a03ac20b7..ab6fb2fee 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BlankNote.kt @@ -2,6 +2,8 @@ package com.vitorpamplona.amethyst.ui.note import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding @@ -17,7 +19,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -53,6 +54,7 @@ fun BlankNote(modifier: Modifier = Modifier, isQuote: Boolean = false, idHex: St } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun HiddenNote( reports: Set, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt index f31c2fd74..56f860a07 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/BoostSetCompose.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -25,7 +27,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.BoostSetCard @@ -35,7 +36,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun BoostSetCompose(boostSetCard: BoostSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val noteState by boostSetCard.note.live().metadata.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 05677006a..345be78f2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -7,6 +7,8 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -48,7 +50,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note @@ -364,12 +365,13 @@ private fun RenderRegularTextNote( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - val tags = remember { note.event?.tags() } + val tags = remember { note.event?.tags() ?: emptyList() } val eventContent = remember { accountViewModel.decrypt(note) } val modifier = remember { Modifier.padding(top = 5.dp) } if (eventContent != null) { val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false } + SensitivityWarning( hasSensitiveContent = hasSensitiveContent, accountViewModel = accountViewModel @@ -495,6 +497,7 @@ data class RelayBadgesState( val noteRelaysSimple: List ) +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt index b717846ef..38cd514ed 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/LikeSetCompose.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -25,7 +27,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.LikeSetCard @@ -35,7 +36,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun LikeSetCompose(likeSetCard: LikeSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val noteState by likeSetCard.note.live().metadata.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 184efd244..f7ee1e528 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -38,7 +40,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache @@ -249,6 +250,7 @@ private fun RenderBoostGallery( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGalleryZaps( authorNotes: ImmutableMap, @@ -366,7 +368,7 @@ private fun AuthorPictureAndComment( TranslatableRichTextViewer( content = it, canPreview = true, - tags = null, + tags = remember { emptyList() }, modifier = remember { Modifier.fillMaxWidth() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, @@ -376,6 +378,7 @@ private fun AuthorPictureAndComment( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun AuthorGallery( authorNotes: ImmutableList, 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 af8311672..2dcbfd00b 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 @@ -12,6 +12,8 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -80,7 +82,6 @@ import androidx.core.graphics.get import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.request.SuccessResult -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Channel @@ -139,7 +140,6 @@ import java.math.BigDecimal import java.net.URL import java.util.Locale import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue @OptIn(ExperimentalFoundationApi::class) @Composable @@ -156,12 +156,10 @@ fun NoteCompose( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - println("NormalNote START NoteCompose") val noteState by baseNote.live().metadata.observeAsState() val noteEvent = remember(noteState) { noteState?.note?.event } if (noteEvent == null) { - println("NormalNote Rendering Blank") var popupExpanded by remember { mutableStateOf(false) } BlankNote( @@ -190,7 +188,6 @@ fun NoteCompose( nav ) } - println("NormalNote END NoteCompose") } @Composable @@ -371,8 +368,6 @@ fun NormalNote( } else if (noteEvent is FileStorageHeaderEvent) { FileStorageHeaderDisplay(baseNote) } else { - println("NormalNote LoadedNoteCompose") - var isNew by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -395,8 +390,6 @@ fun NormalNote( } } - println("NormalNote LoadedNoteCompose Launched Effect") - val primaryColor = MaterialTheme.colors.newItemBackgroundColor val defaultBackgroundColor = MaterialTheme.colors.background @@ -412,8 +405,6 @@ fun NormalNote( } } - println("NormalNote LoadedNoteCompose Background Color") - val columnModifier = remember(backgroundColor) { modifier .combinedClickable( @@ -429,8 +420,6 @@ fun NormalNote( .background(backgroundColor) } - println("NormalNote LoadedNoteCompose Modifier") - Column(modifier = columnModifier) { Row( modifier = remember { @@ -442,14 +431,9 @@ fun NormalNote( ) } ) { - println("NormalNote Before FirstUserInfo") - val (value, elapsed) = measureTimedValue { - if (!isBoostedNote && !isQuotedNote) { - DrawAuthorImages(baseNote, accountViewModel, nav) - } + if (!isBoostedNote && !isQuotedNote) { + DrawAuthorImages(baseNote, accountViewModel, nav) } - println("AAA $elapsed DrawAuthorImages") - println("NormalNote FirstUserInfo") Column( modifier = remember { @@ -471,7 +455,6 @@ fun NormalNote( nav ) } - println("NormalNote SecondUserInfo") Spacer(modifier = Modifier.height(2.dp)) @@ -601,7 +584,7 @@ private fun RenderTextEvent( accountViewModel = accountViewModel ) { val modifier = remember(note.event) { Modifier.fillMaxWidth() } - val tags = remember(note.event) { note.event?.tags() } + val tags = remember(note.event) { note.event?.tags() ?: emptyList() } TranslatableRichTextViewer( content = eventContent, @@ -737,7 +720,7 @@ private fun RenderPrivateMessage( val hashtags = remember(note.event?.id()) { note.event?.hashtags() ?: emptyList() } val modifier = remember(note.event?.id()) { Modifier.fillMaxWidth() } val isAuthorTheLoggedUser = remember(note.event?.id()) { accountViewModel.isLoggedUser(note.author) } - val tags = remember(note.event?.id()) { note.event?.tags() } + val tags = remember(note.event?.id()) { note.event?.tags() ?: emptyList() } if (eventContent != null) { if (makeItShort && isAuthorTheLoggedUser) { @@ -812,6 +795,7 @@ fun RenderPeopleList( ) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayPeopleList( baseNote: Note, @@ -900,6 +884,7 @@ fun DisplayPeopleList( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RenderBadgeAward( note: Note, @@ -1039,6 +1024,7 @@ private fun RenderPinListEvent( ) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun PinListHeader( baseNote: Note, @@ -1087,7 +1073,7 @@ fun PinListHeader( TranslatableRichTextViewer( content = pin, canPreview = true, - tags = emptyList(), + tags = remember { emptyList() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav @@ -1197,7 +1183,7 @@ private fun RenderReport( content = content, canPreview = true, modifier = remember { Modifier }, - tags = emptyList(), + tags = remember { emptyList() }, backgroundColor = backgroundColor, accountViewModel = accountViewModel, nav = nav @@ -1311,7 +1297,6 @@ private fun FirstUserInfoRow( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { - var moreActionsExpanded by remember { mutableStateOf(false) } val eventNote = remember { baseNote.event } ?: return val time = remember { baseNote.createdAt() } ?: return val padding = remember { @@ -1339,24 +1324,34 @@ private fun FirstUserInfoRow( TimeAgo(time) - IconButton( - modifier = Modifier.size(24.dp), - onClick = { moreActionsExpanded = true } - ) { - Icon( - imageVector = Icons.Default.MoreVert, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) - ) + MoreOptionsButton(baseNote, accountViewModel) + } +} - NoteDropDownMenu( - baseNote, - moreActionsExpanded, - { moreActionsExpanded = false }, - accountViewModel - ) - } +@Composable +private fun MoreOptionsButton( + baseNote: Note, + accountViewModel: AccountViewModel +) { + var moreActionsExpanded by remember { mutableStateOf(false) } + + IconButton( + modifier = Modifier.size(24.dp), + onClick = { moreActionsExpanded = true } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colors.onSurface.copy(alpha = 0.32f) + ) + + NoteDropDownMenu( + baseNote, + moreActionsExpanded, + { moreActionsExpanded = false }, + accountViewModel + ) } } @@ -1390,27 +1385,17 @@ private fun DrawAuthorImages(baseNote: Note, accountViewModel: AccountViewModel, Column(modifier) { // Draws the boosted picture outside the boosted card. Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) { - val (value1, elapsed1) = measureTimedValue { - NoteAuthorPicture(baseNote, nav, accountViewModel, 55.dp) + NoteAuthorPicture(baseNote, nav, accountViewModel, 55.dp) + + if (baseNote.event is RepostEvent) { + RepostNoteAuthorPicture(baseNote, accountViewModel, nav) } - println("AAA $elapsed1 NoteAuthorPicture") - - val (value2, elapsed2) = measureTimedValue { - if (baseNote.event is RepostEvent) { - RepostNoteAuthorPicture(baseNote, accountViewModel, nav) + if (baseNote.event is ChannelMessageEvent && baseChannelHex != null) { + LoadChannel(baseChannelHex) { channel -> + ChannelNotePicture(channel) } } - println("AAA $elapsed2 RepostNoteAuthorPicture") - - val (value3, elapsed3) = measureTimedValue { - if (baseNote.event is ChannelMessageEvent && baseChannelHex != null) { - LoadChannel(baseChannelHex) { channel -> - ChannelNotePicture(channel) - } - } - } - println("AAA $elapsed3 ChannelNotePicture") } if (baseNote.event is RepostEvent) { @@ -1492,9 +1477,7 @@ private fun RepostNoteAuthorPicture( val baseRepost = remember { baseNote.replyTo?.lastOrNull() } val modifier = remember { - Modifier - .width(30.dp) - .height(30.dp) + Modifier.size(30.dp) } baseRepost?.let { @@ -1503,7 +1486,7 @@ private fun RepostNoteAuthorPicture( baseNote = it, nav = nav, accountViewModel = accountViewModel, - size = 35.dp, + size = 30.dp, pictureModifier = Modifier.border( 2.dp, MaterialTheme.colors.background, @@ -1514,6 +1497,7 @@ private fun RepostNoteAuthorPicture( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayHighlight( highlight: String, @@ -1598,15 +1582,16 @@ fun DisplayFollowingHashtagsInPost( nav: (String) -> Unit ) { val accountState by accountViewModel.accountLiveData.observeAsState() - val followingTags = remember(accountState) { - accountState?.account?.followingTagSet() ?: emptySet() - } - var firstTag by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteEvent.id(), followingTags) { - withContext(Dispatchers.IO) { - firstTag = noteEvent.firstIsTaggedHashes(followingTags) + LaunchedEffect(key1 = accountState) { + launch(Dispatchers.Default) { + val followingTags = accountState?.account?.followingTagSet() ?: emptySet() + val newFirstTag = noteEvent.firstIsTaggedHashes(followingTags) + + if (firstTag != newFirstTag) { + firstTag = newFirstTag + } } } @@ -1630,6 +1615,7 @@ fun DisplayFollowingHashtagsInPost( } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun DisplayUncitedHashtags( hashtags: List, @@ -2159,6 +2145,7 @@ private fun RelayBadges(baseNote: Note) { } } +@OptIn(ExperimentalLayoutApi::class) @Composable @Stable private fun VerticalRelayPanelWithFlow( @@ -2229,30 +2216,19 @@ fun NoteAuthorPicture( } } - val boxModifier = remember { - Modifier - .width(size) - .height(size) - } - - val nullModifier = remember { - modifier - .width(size) - .height(size) - .clip(shape = CircleShape) - } - - Box(boxModifier) { - if (author == null) { - RobohashAsyncImage( - robot = "authornotfound", - robotSize = size, - contentDescription = stringResource(R.string.unknown_author), - modifier = nullModifier.background(MaterialTheme.colors.background) - ) - } else { - UserPicture(author!!, size, accountViewModel, modifier, onClick) + if (author == null) { + val nullModifier = remember { + modifier.size(size).clip(shape = CircleShape) } + + RobohashAsyncImage( + robot = "authornotfound", + robotSize = size, + contentDescription = stringResource(R.string.unknown_author), + modifier = nullModifier.background(MaterialTheme.colors.background) + ) + } else { + UserPicture(author!!, size, accountViewModel, modifier, onClick) } } @@ -2316,6 +2292,7 @@ fun UserPicture( } } +@OptIn(ExperimentalTime::class) @Composable fun UserPicture( userHex: String, @@ -2401,6 +2378,7 @@ private fun FollowingIcon(iconSize: Dp) { } } +@Immutable data class DropDownParams( val isFollowingAuthor: Boolean, val isPrivateBookmarkNote: Boolean, @@ -2417,32 +2395,29 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, val actContext = LocalContext.current var reportDialogShowing by remember { mutableStateOf(false) } - val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() - val accountState by accountViewModel.accountLiveData.observeAsState() - var state by remember { mutableStateOf( - DropDownParams(false, false, false, false, false, null) - ) - } - - LaunchedEffect(key1 = note, key2 = bookmarkState, key3 = accountState) { - withContext(Dispatchers.IO) { - state = DropDownParams( - isFollowingAuthor = accountViewModel.isFollowing(note.author), - isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note), - isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), - isLoggedUser = accountViewModel.isLoggedUser(note.author), - isSensitive = note.event?.isSensitive() ?: false, - showSensitiveContent = accountState?.account?.showSensitiveContent + DropDownParams( + isFollowingAuthor = false, + isPrivateBookmarkNote = false, + isPublicBookmarkNote = false, + isLoggedUser = false, + isSensitive = false, + showSensitiveContent = null ) - } + ) } DropdownMenu( expanded = popupExpanded, onDismissRequest = onDismiss ) { + WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState -> + if (state != newState) { + state = newState + } + } + if (!state.isFollowingAuthor) { DropdownMenuItem(onClick = { accountViewModel.follow( @@ -2512,7 +2487,6 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text("Block / Report") } } - // if (state.isSensitive) { Divider() if (state.showSensitiveContent == null || state.showSensitiveContent == true) { DropdownMenuItem(onClick = { accountViewModel.hideSensitiveContent(); onDismiss() }) { @@ -2529,7 +2503,6 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, Text(stringResource(R.string.content_warning_see_warnings)) } } - // } } if (reportDialogShowing) { @@ -2539,3 +2512,25 @@ fun NoteDropDownMenu(note: Note, popupExpanded: Boolean, onDismiss: () -> Unit, } } } + +@Composable +fun WatchBookmarksFollowsAndAccount(note: Note, accountViewModel: AccountViewModel, onNew: (DropDownParams) -> Unit) { + val followState by accountViewModel.userProfile().live().follows.observeAsState() + val bookmarkState by accountViewModel.userProfile().live().bookmarks.observeAsState() + val accountState by accountViewModel.accountLiveData.observeAsState() + + LaunchedEffect(key1 = followState, key2 = bookmarkState, key3 = accountState) { + launch(Dispatchers.IO) { + onNew( + DropDownParams( + isFollowingAuthor = accountViewModel.isFollowing(note.author), + isPrivateBookmarkNote = accountViewModel.isInPrivateBookmarks(note), + isPublicBookmarkNote = accountViewModel.isInPublicBookmarks(note), + isLoggedUser = accountViewModel.isLoggedUser(note.author), + isSensitive = note.event?.isSensitive() ?: false, + showSensitiveContent = accountState?.account?.showSensitiveContent + ) + ) + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt index 73b2594a2..d50b0121d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt @@ -110,7 +110,7 @@ private fun OptionNote( backgroundColor: Color, nav: (String) -> Unit ) { - val tags = remember { baseNote.event?.tags() } + val tags = remember { baseNote.event?.tags() ?: emptyList() } Row( verticalAlignment = Alignment.CenterVertically, @@ -164,7 +164,7 @@ private fun RenderOptionAfterVote( totalRatio: Float, color: Color, canPreview: Boolean, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -231,7 +231,7 @@ private fun RenderOptionAfterVote( private fun RenderOptionBeforeVote( description: String, canPreview: Boolean, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit 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 7997a232c..39c97f7cb 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 @@ -1,5 +1,7 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme @@ -14,7 +16,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.sp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji @@ -45,6 +46,7 @@ fun ReplyInformation( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ReplyInformation( replyTo: List?, @@ -166,6 +168,7 @@ fun ReplyInformationChannel(replyTo: List?, mentions: List?, channel ) } +@OptIn(ExperimentalLayoutApi::class) @Composable fun ReplyInformationChannel( replyTo: List?, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index a6a59f32b..000e6fc40 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -16,7 +16,9 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @Composable fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { val noteState by baseNote.live().metadata.observeAsState() - val author = remember(noteState) { noteState?.note?.author } ?: return + val author = remember(noteState) { + noteState?.note?.author + } ?: return UsernameDisplay(author, weight) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt index a1f8c282c..6cdaed920 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapSetCompose.kt @@ -5,6 +5,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -26,7 +28,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.ZapSetCard @@ -37,7 +38,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class) @Composable fun ZapSetCompose(zapSetCard: ZapSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val noteState by zapSetCard.note.live().metadata.observeAsState() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt index 88acb7a45..e86ab44d1 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapUserSetCompose.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -23,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.vitorpamplona.amethyst.NotificationCache import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.screen.ZapUserSetCard @@ -33,6 +34,7 @@ import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +@OptIn(ExperimentalLayoutApi::class) @Composable fun ZapUserSetCompose(zapSetCard: ZapUserSetCard, isInnerNote: Boolean = false, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { var isNew by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt index 6f717972b..e9538e151 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedView.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import kotlin.time.ExperimentalTime -import kotlin.time.measureTimedValue @OptIn(ExperimentalMaterialApi::class) @Composable @@ -166,17 +165,14 @@ private fun FeedLoaded( state = listState ) { itemsIndexed(state.feed.value, key = { _, item -> item.idHex }) { _, item -> - val (value, elapsed) = measureTimedValue { - NoteCompose( - item, - routeForLastRead = routeForLastRead, - modifier = baseModifier, - isBoostedNote = false, - accountViewModel = accountViewModel, - nav = nav - ) - } - println("AAA NoteCompose $elapsed ${item.event?.content()}") + NoteCompose( + item, + routeForLastRead = routeForLastRead, + modifier = baseModifier, + isBoostedNote = false, + accountViewModel = accountViewModel, + nav = nav + ) } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt index b4c322346..7a587f631 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ThreadFeedView.kt @@ -381,6 +381,10 @@ fun NoteMaster( if (eventContent != null) { val hasSensitiveContent = remember(note.event) { note.event?.isSensitive() ?: false } + val tags = remember(note) { + note.event?.tags() ?: emptyList() + } + SensitivityWarning( hasSensitiveContent = hasSensitiveContent, accountViewModel = accountViewModel @@ -389,7 +393,7 @@ fun NoteMaster( eventContent, canPreview, Modifier.fillMaxWidth(), - note.event?.tags(), + tags, MaterialTheme.colors.background, accountViewModel, nav diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 2fff6f261..2f430f6a3 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -699,7 +699,7 @@ private fun DrawAdditionalInfo(baseUser: User, accountViewModel: AccountViewMode TranslatableRichTextViewer( content = it, canPreview = false, - tags = null, + tags = remember { emptyList() }, backgroundColor = MaterialTheme.colors.background, accountViewModel = accountViewModel, nav = nav diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt index 890dc150f..11305e159 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/VideoScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -54,7 +56,6 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.accompanist.flowlayout.FlowRow import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState @@ -313,6 +314,7 @@ private fun RenderVideoOrPictureNote( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun RelayBadges(baseNote: Note) { val noteRelaysState by baseNote.live().relays.observeAsState() diff --git a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt index e5d99a649..11b2f4411 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/service/lang/LanguageTranslatorService.kt @@ -27,7 +27,7 @@ object LanguageTranslatorService { val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE) private val translators = - object : LruCache(10) { + object : LruCache(20) { override fun create(options: TranslatorOptions): Translator { return Translation.getClient(options) } diff --git a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt index 630c9ac15..97448087c 100644 --- a/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt +++ b/app/src/play/java/com/vitorpamplona/amethyst/ui/components/TranslatableRichTextViewer.kt @@ -43,7 +43,7 @@ fun TranslatableRichTextViewer( content: String, canPreview: Boolean, modifier: Modifier = Modifier, - tags: List>?, + tags: List>, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit @@ -55,7 +55,7 @@ fun TranslatableRichTextViewer( var showOriginal by remember { mutableStateOf(false) } var langSettingsPopupExpanded by remember { mutableStateOf(false) } - WatchLanguageChanges(content, accountViewModel) { result, newShowOriginal -> + TranslateAndWatchLanguageChanges(content, accountViewModel) { result, newShowOriginal -> if (translatedTextState.result != result.result || translatedTextState.sourceLang != result.sourceLang || translatedTextState.targetLang != result.targetLang @@ -227,7 +227,7 @@ fun TranslatableRichTextViewer( } @Composable -fun WatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (ResultOrError, Boolean) -> Unit) { +fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (ResultOrError, Boolean) -> Unit) { val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() val account = remember(accountState) { accountState?.account } ?: return