Improves the async rendering of Base64 content

This commit is contained in:
Vitor Pamplona 2024-08-07 18:24:14 -04:00
parent b026bffe4a
commit f731c654b0
5 changed files with 180 additions and 204 deletions

View File

@ -30,6 +30,7 @@ import coil.decode.SvgDecoder
import coil.size.Precision import coil.size.Precision
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.Base64Fetcher
import com.vitorpamplona.amethyst.service.NostrAccountDataSource import com.vitorpamplona.amethyst.service.NostrAccountDataSource
import com.vitorpamplona.amethyst.service.NostrChannelDataSource import com.vitorpamplona.amethyst.service.NostrChannelDataSource
import com.vitorpamplona.amethyst.service.NostrChatroomDataSource import com.vitorpamplona.amethyst.service.NostrChatroomDataSource
@ -98,6 +99,7 @@ class ServiceManager {
add(GifDecoder.Factory()) add(GifDecoder.Factory())
} }
add(SvgDecoder.Factory()) add(SvgDecoder.Factory())
add(Base64Fetcher.Factory)
} // .logger(DebugLogger()) } // .logger(DebugLogger())
.okHttpClient { HttpClientManager.getHttpClient() } .okHttpClient { HttpClientManager.getHttpClient() }
.precision(Precision.INEXACT) .precision(Precision.INEXACT)

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.runtime.Stable
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.base64contentPattern
import java.util.Base64
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val matcher = base64contentPattern.matcher(data.toString())
if (matcher.find()) {
val base64String = matcher.group(2)
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) ?: throw Exception("Unable to load base64 $base64String")
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
} else {
throw Exception("Unable to load base64 $data")
}
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher? {
return if (base64contentPattern.matcher(data.toString()).find()) {
return Base64Fetcher(options, data)
} else {
null
}
}
}
}
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}

View File

@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.util.Base64
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -29,7 +28,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
@ -49,8 +47,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
@ -65,10 +61,6 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.lifecycle.viewmodel.compose.viewModel 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.compose.produceCachedState
import com.vitorpamplona.amethyst.commons.richtext.Base64Segment import com.vitorpamplona.amethyst.commons.richtext.Base64Segment
import com.vitorpamplona.amethyst.commons.richtext.BechSegment import com.vitorpamplona.amethyst.commons.richtext.BechSegment
@ -83,7 +75,6 @@ import com.vitorpamplona.amethyst.commons.richtext.InvoiceSegment
import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.LinkSegment
import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment
import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment 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.RichTextViewerState
import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment
import com.vitorpamplona.amethyst.commons.richtext.Segment import com.vitorpamplona.amethyst.commons.richtext.Segment
@ -96,7 +87,6 @@ import com.vitorpamplona.amethyst.model.checkForHashtagWithIcon
import com.vitorpamplona.amethyst.service.CachedRichTextParser import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown 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.LoadUser
import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.note.toShortenHex
@ -112,7 +102,6 @@ import fr.acinq.secp256k1.Hex
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.withContext
fun isMarkdown(content: String): Boolean = fun isMarkdown(content: String): Boolean =
content.startsWith("> ") || content.startsWith("> ") ||
@ -445,47 +434,7 @@ private fun RenderWordWithPreview(
is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav) is HashIndexEventSegment -> TagLink(word, true, quotesLeft, backgroundColor, accountViewModel, nav)
is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is SchemelessUrlSegment -> NoProtocolUrlRenderer(word)
is RegularTextSegment -> Text(word.segmentText) is RegularTextSegment -> Text(word.segmentText)
is Base64Segment -> ImageFromBase64(word.segmentText) is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel)
}
}
@Composable
fun ImageFromBase64(base64String: String) {
val context = LocalContext.current
var imageBytes by remember { mutableStateOf<ByteArray?>(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()
}
}
} }
} }
@ -495,8 +444,10 @@ private fun ZoomableContentView(
state: RichTextViewerState, state: RichTextViewerState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
println("AABBCC ZoomableContentView -- ${state.imagesForPager.keys.joinToString("..") { it }}")
state.imagesForPager[word]?.let { state.imagesForPager[word]?.let {
Box(modifier = HalfVertPadding) { Box(modifier = HalfVertPadding) {
println("AABBCC ZoomableContentView inside $word")
ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel) ZoomableContentView(it, state.imageList, roundedCorner = true, isFiniteHeight = false, accountViewModel)
} }
} }

View File

@ -20,15 +20,11 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Face import androidx.compose.material.icons.filled.Face
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
@ -37,22 +33,10 @@ import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.decode.DataSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.request.ImageRequest
import coil.request.Options
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import java.util.Base64
@Composable @Composable
fun RobohashAsyncImage( fun RobohashAsyncImage(
@ -92,53 +76,34 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean, loadRobohash: Boolean,
) { ) {
if (model != null && loadProfilePicture) { if (model != null && loadProfilePicture) {
val isBase64 = model.startsWith("data:image/jpeg;base64,") val painter =
if (loadRobohash) {
if (isBase64) { rememberVectorPainter(
val context = LocalContext.current image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight),
val base64Painter =
rememberAsyncImagePainter(
model = Base64Requester.imageRequest(context, model),
) )
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
Image( AsyncImage(
painter = base64Painter, model = model,
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
alignment = alignment, placeholder = painter,
contentScale = contentScale, fallback = painter,
colorFilter = colorFilter, error = painter,
) alignment = alignment,
} else { contentScale = contentScale,
val painter = alpha = alpha,
if (loadRobohash) { colorFilter = colorFilter,
rememberVectorPainter( filterQuality = filterQuality,
image = CachedRobohash.get(robot, MaterialTheme.colorScheme.isLight), )
)
} else {
forwardingPainter(
painter =
rememberVectorPainter(
image = Icons.Default.Face,
),
colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter,
)
}
AsyncImage(
model = model,
contentDescription = contentDescription,
modifier = modifier,
placeholder = painter,
fallback = painter,
error = painter,
alignment = alignment,
contentScale = contentScale,
alpha = alpha,
colorFilter = colorFilter,
filterQuality = filterQuality,
)
}
} else { } else {
if (loadRobohash) { if (loadRobohash) {
Image( Image(
@ -161,48 +126,3 @@ fun RobohashFallbackAsyncImage(
} }
} }
} }
object Base64Requester {
fun imageRequest(
context: Context,
message: String,
): ImageRequest =
ImageRequest
.Builder(context)
.data(message)
.fetcherFactory(Base64Fetcher.Factory)
.build()
}
@Stable
class Base64Fetcher(
private val options: Options,
private val data: Uri,
) : Fetcher {
override suspend fun fetch(): FetchResult {
checkNotInMainThread()
val base64String = data.toString().removePrefix("data:image/jpeg;base64,")
val byteArray = Base64.getDecoder().decode(base64String)
val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
if (bitmap == null) {
throw Exception("Unable to load base64 $base64String")
}
return DrawableResult(
drawable = bitmap.toDrawable(options.context.resources),
isSampled = false,
dataSource = DataSource.MEMORY,
)
}
object Factory : Fetcher.Factory<Uri> {
override fun create(
data: Uri,
options: Options,
imageLoader: ImageLoader,
): Fetcher = Base64Fetcher(options, data)
}
}

View File

@ -42,6 +42,47 @@ import java.util.regex.Pattern
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class RichTextParser { class RichTextParser {
fun createImageContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlImage {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun createVideoContent(
fullUrl: String,
eventTags: ImmutableListOfLists<String>,
description: String?,
callbackUri: String? = null,
): MediaUrlVideo {
val frags = Nip54InlineMetadata().parse(fullUrl)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
return MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
}
fun parseMediaUrl( fun parseMediaUrl(
fullUrl: String, fullUrl: String,
eventTags: ImmutableListOfLists<String>, eventTags: ImmutableListOfLists<String>,
@ -50,50 +91,17 @@ class RichTextParser {
): MediaUrlContent? { ): MediaUrlContent? {
val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl)
return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) { return if (imageExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54InlineMetadata().parse(fullUrl) createImageContent(fullUrl, eventTags, description, callbackUri)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
MediaUrlImage(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
} else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) { } else if (videoExtensions.any { removedParamsFromUrl.endsWith(it) }) {
val frags = Nip54InlineMetadata().parse(fullUrl) createVideoContent(fullUrl, eventTags, description, callbackUri)
val tags = Nip92MediaAttachments().parse(fullUrl, eventTags.lists)
MediaUrlVideo(
url = fullUrl,
description = description ?: frags[FileHeaderEvent.ALT] ?: tags[FileHeaderEvent.ALT],
hash = frags[FileHeaderEvent.HASH] ?: tags[FileHeaderEvent.HASH],
blurhash = frags[FileHeaderEvent.BLUR_HASH] ?: tags[FileHeaderEvent.BLUR_HASH],
dim = frags[FileHeaderEvent.DIMENSION] ?: tags[FileHeaderEvent.DIMENSION],
contentWarning = frags["content-warning"] ?: tags["content-warning"],
uri = callbackUri,
mimeType = frags[FileHeaderEvent.MIME_TYPE] ?: tags[FileHeaderEvent.MIME_TYPE],
)
} else { } else {
null null
} }
} }
private fun parseBase64Images(content: String): LinkedHashSet<String> { private fun checkBase64(content: String): Boolean {
val regex = "data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,[a-zA-Z0-9+/]+={0,2}" val matcher = base64contentPattern.matcher(content)
val pattern = Pattern.compile(regex) return matcher.find()
val matcher = pattern.matcher(content)
val base64Images = mutableListOf<String>()
// 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<String> { fun parseValidUrls(content: String): LinkedHashSet<String> {
@ -129,16 +137,23 @@ class RichTextParser {
val imagesForPager = val imagesForPager =
urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url } urlSet.mapNotNull { fullUrl -> parseMediaUrl(fullUrl, tags, content, callbackUri) }.associateBy { it.url }
val imageList = imagesForPager.values.toList()
val emojiMap = Nip30CustomEmoji.createEmojiMap(tags) val emojiMap = Nip30CustomEmoji.createEmojiMap(tags)
val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags)
val base64Images = segments.map { it.words.filterIsInstance<Base64Segment>() }.flatten()
val imagesForPagerWithBase64 =
imagesForPager +
base64Images
.map { createImageContent(it.segmentText, tags, content, callbackUri) }
.associateBy { it.url }
return RichTextViewerState( return RichTextViewerState(
urlSet.toImmutableSet(), urlSet.toImmutableSet(),
imagesForPager.toImmutableMap(), imagesForPagerWithBase64.toImmutableMap(),
imageList.toImmutableList(), imagesForPagerWithBase64.values.toImmutableList(),
emojiMap.toImmutableMap(), emojiMap.toImmutableMap(),
segments, segments,
) )
@ -218,11 +233,8 @@ class RichTextParser {
): Segment { ): Segment {
if (word.isEmpty()) return RegularTextSegment(word) if (word.isEmpty()) return RegularTextSegment(word)
if (word.startsWith("data:image")) { if (word.startsWith("data:image/")) {
val base64Images = parseBase64Images(word) if (checkBase64(word)) return Base64Segment(word)
if (base64Images.isNotEmpty()) {
return Base64Segment(word)
}
} }
if (images.contains(word)) return ImageSegment(word) if (images.contains(word)) return ImageSegment(word)
@ -338,6 +350,8 @@ class RichTextParser {
val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") val imageExtensions = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif")
val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") val videoExtensions = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8")
val base64contentPattern = Pattern.compile("data:image/(${imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})")
val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)") val tagIndex = Pattern.compile("\\#\\[([0-9]+)\\](.*)")
val hashTagsPattern: Pattern = val hashTagsPattern: Pattern =
Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE) Pattern.compile("#([^\\s!@#\$%^&*()=+./,\\[{\\]};:'\"?><]+)(.*)", Pattern.CASE_INSENSITIVE)