Caches the processing of preview urls for the new post screen

Fixes a bug on saving MediaType on Android's stack
This commit is contained in:
Vitor Pamplona
2025-04-26 10:58:45 -04:00
parent 99cef643e1
commit 4c7ef8ecff
8 changed files with 49 additions and 43 deletions
@@ -63,3 +63,23 @@ object CachedRichTextParser {
}
}
}
object CachedUrlParser {
private val parsedUrlsCache = LruCache<Int, List<String>>(10)
fun cachedParseValidUrls(content: String): List<String> = parsedUrlsCache[content.hashCode()]
fun parseValidUrls(content: String): List<String> {
if (content.isEmpty()) return emptyList()
val key = content.hashCode()
val cached = parsedUrlsCache[key]
return if (cached != null) {
cached
} else {
val newUrls = RichTextParser().parseValidUrls(content).toList()
parsedUrlsCache.put(key, newUrls)
newUrls
}
}
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.previews
import androidx.compose.runtime.Immutable
import okhttp3.MediaType
import java.net.URL
@Immutable
@@ -30,7 +29,7 @@ class UrlInfoItem(
val title: String = "",
val description: String = "",
val image: String = "",
val mimeType: MediaType,
val mimeType: String,
) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath =
@@ -60,11 +60,11 @@ class UrlPreview {
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}")
if (mimeType.type == "text" && mimeType.subtype == "html") {
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType))
UrlInfoItem(url, data.title, data.description, data.image, mimeType)
UrlInfoItem(url, data.title, data.description, data.image, mimeType.toString())
} else if (mimeType.type == "image") {
UrlInfoItem(url, image = url, mimeType = mimeType)
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else if (mimeType.type == "video") {
UrlInfoItem(url, image = url, mimeType = mimeType)
UrlInfoItem(url, image = url, mimeType = mimeType.toString())
} else {
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.actions
import androidx.collection.mutableScatterMapOf
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Transition
@@ -82,10 +83,7 @@ fun <T> Transition<T>.MyCrossfade(
content: @Composable (targetState: T) -> Unit,
) {
val currentlyVisible = remember { mutableStateListOf<T>().apply { add(currentState) } }
val contentMap =
remember {
mutableMapOf<T, @Composable () -> Unit>()
}
val contentMap = remember { mutableScatterMapOf<T, @Composable () -> Unit>() }
if (currentState == targetState) {
// If not animating, just display the current state
if (currentlyVisible.size != 1 || currentlyVisible[0] != targetState) {
@@ -94,12 +92,11 @@ fun <T> Transition<T>.MyCrossfade(
contentMap.clear()
}
}
if (!contentMap.contains(targetState)) {
if (targetState !in contentMap) {
// Replace target with the same key if any
val replacementId =
currentlyVisible.indexOfFirst {
contentKey(it) == contentKey(targetState)
}
currentlyVisible.indexOfFirst { contentKey(it) == contentKey(targetState) }
if (replacementId == -1) {
currentlyVisible.add(targetState)
} else {
@@ -108,21 +105,16 @@ fun <T> Transition<T>.MyCrossfade(
contentMap.clear()
currentlyVisible.fastForEach { stateForContent ->
contentMap[stateForContent] = {
val alpha by animateFloat(
transitionSpec = { animationSpec },
) { if (it == stateForContent) 1f else 0f }
Box(Modifier.graphicsLayer { this.alpha = alpha }, contentAlignment) {
content(stateForContent)
}
val alpha by
animateFloat(transitionSpec = { animationSpec }) {
if (it == stateForContent) 1f else 0f
}
Box(Modifier.graphicsLayer { this.alpha = alpha }) { content(stateForContent) }
}
}
}
Box(modifier, contentAlignment) {
currentlyVisible.fastForEach {
key(contentKey(it)) {
contentMap[it]?.invoke()
}
}
currentlyVisible.fastForEach { key(contentKey(it)) { contentMap[it]?.invoke() } }
}
}
@@ -92,7 +92,7 @@ fun RenderLoaded(
callbackUri: String? = null,
accountViewModel: AccountViewModel,
) {
if (state.previewInfo.mimeType.type == "image") {
if (state.previewInfo.mimeType.startsWith("image")) {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlImage(url, uri = callbackUri),
@@ -101,7 +101,7 @@ fun RenderLoaded(
accountViewModel = accountViewModel,
)
}
} else if (state.previewInfo.mimeType.type == "video") {
} else if (state.previewInfo.mimeType.startsWith("video")) {
Box(modifier = HalfVertPadding) {
ZoomableContentView(
content = MediaUrlVideo(url, uri = callbackUri),
@@ -57,7 +57,6 @@ import com.vitorpamplona.quartz.nip92IMeta.imetasByUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
@Composable
fun RenderContentAsMarkdown(
@@ -375,7 +374,7 @@ fun RenderContentAsMarkdownUserPreview() {
image = "https://duckduckgo.com/assets/logo_social-media.png",
title = "DuckDuckGo — Privacy, simplified.",
description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.",
mimeType = "text/html".toMediaType(),
mimeType = "text/html",
),
),
)
@@ -21,7 +21,7 @@
package com.vitorpamplona.amethyst.ui.note.creators.previews
import androidx.compose.ui.text.input.TextFieldValue
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.CachedUrlParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,15 +36,10 @@ class PreviewState {
@OptIn(FlowPreview::class)
val results =
source
.debounce(200)
.debounce(500)
.map { CachedUrlParser.parseValidUrls(it.text) }
.distinctUntilChanged()
.map {
if (it.text.isNotEmpty()) {
RichTextParser().parseValidUrls(it.text).toList()
} else {
emptyList()
}
}.flowOn(Dispatchers.Default)
.flowOn(Dispatchers.Default)
fun reset() {
source.tryEmit(TextFieldValue(""))
@@ -53,6 +53,7 @@ import com.vitorpamplona.amethyst.ui.components.WaitAndDisplay
import com.vitorpamplona.amethyst.ui.navigation.INav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlin.text.startsWith
@Composable
fun PreviewUrl(
@@ -201,17 +202,17 @@ private fun MyLoadUrlPreviewDirect(
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
if (state.previewInfo.mimeType.type == "image") {
if (state.previewInfo.mimeType.startsWith("image")) {
AsyncImage(
model = state.previewInfo.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxHeight().aspectRatio(1f),
)
} else if (state.previewInfo.mimeType.type == "video") {
} else if (state.previewInfo.mimeType.startsWith("video")) {
VideoView(
state.previewInfo.url,
mimeType = state.previewInfo.mimeType.toString(),
mimeType = state.previewInfo.mimeType,
roundedCorner = false,
gallery = false,
contentScale = ContentScale.Crop,
@@ -268,17 +269,17 @@ private fun MyLoadUrlPreviewDirectFillWidth(
) { state ->
when (state) {
is UrlPreviewState.Loaded -> {
if (state.previewInfo.mimeType.type == "image") {
if (state.previewInfo.mimeType.startsWith("image")) {
AsyncImage(
model = state.previewInfo.url,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
)
} else if (state.previewInfo.mimeType.type == "video") {
} else if (state.previewInfo.mimeType.startsWith("video")) {
VideoView(
state.previewInfo.url,
mimeType = state.previewInfo.mimeType.toString(),
mimeType = state.previewInfo.mimeType,
roundedCorner = false,
gallery = false,
contentScale = ContentScale.FillWidth,