diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 66daf83e2..325628c8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.util.Base64 import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -28,6 +29,7 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent @@ -47,6 +49,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection @@ -61,7 +65,12 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImagePainter +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import coil.request.ImageRequest import com.vitorpamplona.amethyst.commons.compose.produceCachedState +import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.CashuSegment import com.vitorpamplona.amethyst.commons.richtext.EmailSegment @@ -74,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.Segment @@ -86,6 +96,7 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown +import com.vitorpamplona.amethyst.ui.note.BlankNote import com.vitorpamplona.amethyst.ui.note.LoadUser import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.toShortenHex @@ -101,6 +112,7 @@ import fr.acinq.secp256k1.Hex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -433,6 +445,47 @@ private fun RenderWordWithPreview( is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) + is Base64Segment -> ImageFromBase64(word.segmentText) + } +} + +@Composable +fun ImageFromBase64(base64String: String) { + val context = LocalContext.current + var imageBytes by remember { mutableStateOf(null) } + LaunchedEffect(base64String) { + imageBytes = + withContext(Dispatchers.IO) { + var base64String2 = base64String.removePrefix("data:image/jpeg;base64,") + RichTextParser.imageExtensions.forEach { + base64String2 = base64String2.removePrefix("data:image/$it;base64,") + } + runCatching { Base64.decode(base64String2, Base64.DEFAULT) }.getOrNull() + } + } + + if (imageBytes == null) { + BlankNote() + } else { + val request = + ImageRequest + .Builder(context) + .data(imageBytes) + .build() + + SubcomposeAsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth(), + ) { + when (painter.state) { + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent() + } + else -> BlankNote() + } + } } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt index 8cabd98fd..e537ce840 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt @@ -24,7 +24,7 @@ class ExpandableTextCutOffCalculator { companion object { private const val SHORT_TEXT_LENGTH = 350 private const val SHORTEN_AFTER_LINES = 10 - private const val TOO_FAR_SEACH_THE_OTHER_WAY = 450 + private const val TOO_FAR_SEARCH_THE_OTHER_WAY = 450 fun indexToCutOff(content: String): Int { // Cuts the text in the first space or new line after SHORT_TEXT_LENGTH characters @@ -37,7 +37,7 @@ class ExpandableTextCutOffCalculator { val min = minOf(firstSpaceAfterCut, firstNewLineAfterCut, firstLineAfterLineLimits) - if (min > TOO_FAR_SEACH_THE_OTHER_WAY) { + if (min > TOO_FAR_SEARCH_THE_OTHER_WAY) { val newString = content.take(SHORT_TEXT_LENGTH) val firstSpaceBeforeCut = newString.lastIndexOf(' ').let { if (it < 0) content.length else it } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 226263367..663d1bc6c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -81,6 +81,21 @@ class RichTextParser { } } + private fun parseBase64Images(content: String): LinkedHashSet { + val regex = "data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,[a-zA-Z0-9+/]+={0,2}" + val pattern = Pattern.compile(regex) + val matcher = pattern.matcher(content) + + val base64Images = mutableListOf() + + // Find all matches and add them to the list + while (matcher.find()) { + base64Images.add(matcher.group()) + } + + return base64Images.mapTo(LinkedHashSet(base64Images.size)) { it } + } + fun parseValidUrls(content: String): LinkedHashSet { val urls = UrlDetector(content, UrlDetectorOptions.Default).detect() @@ -203,6 +218,13 @@ class RichTextParser { ): Segment { if (word.isEmpty()) return RegularTextSegment(word) + if (word.startsWith("data:image")) { + val base64Images = parseBase64Images(word) + if (base64Images.isNotEmpty()) { + return Base64Segment(word) + } + } + if (images.contains(word)) return ImageSegment(word) if (urls.contains(word)) return LinkSegment(word) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 4b2a7495e..c4238e32a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -91,6 +91,10 @@ class BechSegment( ) : Segment(segment) @Immutable +class Base64Segment( + segment: String, +) : Segment(segment) + open class HashIndexSegment( segment: String, val hex: String,