UrlPreviewer using Main thread :(

This commit is contained in:
Vitor Pamplona
2023-05-31 20:08:30 -04:00
parent 3eb832c4e0
commit ed919ae531
14 changed files with 153 additions and 134 deletions
@@ -3,10 +3,6 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
object UrlCachedPreviewer {
var cache = mapOf<String, UrlInfoItem>()
@@ -25,22 +21,19 @@ object UrlCachedPreviewer {
return
}
val scope = CoroutineScope(Job() + Dispatchers.IO)
scope.launch {
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
}
BahaUrlPreview(
url,
object : IUrlPreviewCallback {
override fun onComplete(urlInfo: UrlInfoItem) {
cache = cache + Pair(url, urlInfo)
callback?.onComplete(urlInfo)
}
).fetchUrlPreview()
}
override fun onFailed(throwable: Throwable) {
failures = failures + Pair(url, throwable)
callback?.onFailed(throwable)
}
}
).fetchUrlPreview()
}
}
@@ -5,7 +5,7 @@ import kotlinx.coroutines.*
import java.util.*
class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
val scope = CoroutineScope(Job() + Dispatchers.Main)
val scope = CoroutineScope(Job() + Dispatchers.IO)
private val imageExtensionArray = arrayOf(".gif", ".png", ".jpg", ".jpeg", ".bmp", ".webp")
fun fetchUrlPreview(timeOut: Int = 30000) {
@@ -23,8 +23,7 @@ class BahaUrlPreview(val url: String, var callback: IUrlPreviewCallback?) {
urlInfoItem = UrlInfoItem(url = url, image = url)
} else {
val document = getDocument(url, timeOut)
urlInfoItem = parseHtml(document)
urlInfoItem.url = url
urlInfoItem = parseHtml(url, document)
}
callback?.onComplete(urlInfoItem)
}
@@ -1,11 +1,23 @@
package com.vitorpamplona.amethyst.service.previews
import androidx.compose.runtime.Immutable
import java.net.URL
@Immutable
data class UrlInfoItem(
var url: String = "",
var title: String = "",
var description: String = "",
var image: String = ""
val url: String = "",
val title: String = "",
val description: String = "",
val image: String = ""
) {
val verifiedUrl = kotlin.runCatching { URL(url) }.getOrNull()
val imageUrlFullPath =
if (image.startsWith("/")) {
URL(verifiedUrl, image).toString()
} else {
image
}
fun allFetchComplete(): Boolean {
return title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()
}
@@ -67,50 +67,47 @@ suspend fun getDocument(url: String, timeOut: Int = 30000): Document =
.get()
}
suspend fun parseHtml(document: Document): UrlInfoItem =
suspend fun parseHtml(url: String, document: Document): UrlInfoItem =
withContext(Dispatchers.IO) {
val metaTags = document.getElementsByTag(ELEMENT_TAG_META)
val urlInfo = UrlInfoItem()
var title: String = ""
var description: String = ""
var image: String = ""
metaTags.forEach {
val propertyTag = it.attr(ATTRIBUTE_VALUE_PROPERTY)
when (propertyTag) {
in META_OG_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
in META_OG_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_OG_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
in META_NAME_TITLE -> if (urlInfo.title.isEmpty()) urlInfo.title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(CONTENT)
in META_NAME_TITLE -> if (title.isEmpty()) title = it.attr(CONTENT)
in META_NAME_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_OG_IMAGE -> if (urlInfo.image.isEmpty()) urlInfo.image = it.attr(CONTENT)
in META_OG_IMAGE -> if (image.isEmpty()) image = it.attr(CONTENT)
}
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
in META_ITEMPROP_TITLE -> if (urlInfo.title.isEmpty()) {
urlInfo.title =
it.attr(CONTENT)
in META_ITEMPROP_TITLE -> if (title.isEmpty()) {
title = it.attr(CONTENT)
}
in META_ITEMPROP_DESCRIPTION -> if (urlInfo.description.isEmpty()) {
urlInfo.description =
it.attr(
CONTENT
)
in META_ITEMPROP_DESCRIPTION -> if (description.isEmpty()) {
description = it.attr(CONTENT)
}
in META_ITEMPROP_IMAGE -> if (urlInfo.image.isEmpty()) {
urlInfo.image = it.attr(
CONTENT
)
in META_ITEMPROP_IMAGE -> if (image.isEmpty()) {
image = it.attr(CONTENT)
}
}
if (urlInfo.allFetchComplete()) {
return@withContext urlInfo
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
return@withContext UrlInfoItem(url, title, description, image)
}
}
return@withContext urlInfo
return@withContext UrlInfoItem(url, title, description, image)
}
@@ -268,7 +268,7 @@ private fun RenderSeach(
state = listState
) {
itemsIndexed(
searchBarViewModel.searchResults.value,
searchBarViewModel.searchResultsUsers.value,
key = { _, item -> "u" + item.pubkeyHex }
) { _, item ->
UserComposeForChat(
@@ -489,15 +489,15 @@ fun InLineIconRenderer(
"inlineContent$idx",
InlineTextContent(
Placeholder(
width = 17.sp,
height = 17.sp,
width = 20.sp,
height = 20.sp,
placeholderVerticalAlign = PlaceholderVerticalAlign.Center
)
) {
AsyncImage(
model = value.url,
contentDescription = null,
modifier = Modifier.fillMaxSize().padding(1.dp)
modifier = Modifier.fillMaxSize().padding(horizontal = 1.dp)
)
}
)
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.R
@@ -32,12 +31,11 @@ fun UrlPreview(url: String, urlText: String) {
mutableStateOf(default)
}
val scope = rememberCoroutineScope()
// Doesn't use a viewModel because of viewModel reusing issues (too many UrlPreview are created).
LaunchedEffect(url) {
if (urlPreviewState == UrlPreviewState.Loading) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(
url,
object : IUrlPreviewCallback {
@@ -10,7 +10,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
@@ -22,7 +21,6 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
import java.net.URL
@Composable
fun UrlPreviewCard(
@@ -42,26 +40,15 @@ fun UrlPreviewCard(
)
) {
Column {
val validatedUrl = remember(url) { URL(previewInfo.url) }
// correctly treating relative images
val imageUrl = remember(url) {
if (previewInfo.image.startsWith("/")) {
URL(validatedUrl, previewInfo.image).toString()
} else {
previewInfo.image
}
}
AsyncImage(
model = imageUrl,
contentDescription = stringResource(R.string.preview_card_image_for, validatedUrl),
model = previewInfo.imageUrlFullPath,
contentDescription = stringResource(R.string.preview_card_image_for, previewInfo.url),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
Text(
text = validatedUrl.host,
text = previewInfo.verifiedUrl?.host ?: previewInfo.url,
style = MaterialTheme.typography.caption,
modifier = Modifier
.fillMaxWidth()
@@ -1,7 +1,9 @@
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
@Immutable
sealed class UrlPreviewState {
object Loading : UrlPreviewState()
class Loaded(val previewInfo: UrlInfoItem) : UrlPreviewState()
@@ -36,6 +36,7 @@ import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Report
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -82,11 +83,13 @@ import net.engawapg.lib.zoomable.zoomable
import java.io.File
import java.security.MessageDigest
@Immutable
abstract class ZoomableContent(
val description: String? = null,
val dim: String? = null
)
@Immutable
abstract class ZoomableUrlContent(
val url: String,
description: String? = null,
@@ -95,6 +98,7 @@ abstract class ZoomableUrlContent(
val uri: String? = null
) : ZoomableContent(description, dim)
@Immutable
class ZoomableUrlImage(
url: String,
description: String? = null,
@@ -104,6 +108,7 @@ class ZoomableUrlImage(
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
class ZoomableUrlVideo(
url: String,
description: String? = null,
@@ -112,6 +117,7 @@ class ZoomableUrlVideo(
uri: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
abstract class ZoomablePreloadedContent(
val localFile: File?,
description: String? = null,
@@ -121,6 +127,7 @@ abstract class ZoomablePreloadedContent(
val uri: String
) : ZoomableContent(description, dim)
@Immutable
class ZoomableLocalImage(
localFile: File?,
mimeType: String? = null,
@@ -131,6 +138,7 @@ class ZoomableLocalImage(
uri: String
) : ZoomablePreloadedContent(localFile, description, mimeType, isVerified, dim, uri)
@Immutable
class ZoomableLocalVideo(
localFile: File?,
mimeType: String? = null,
@@ -78,6 +78,7 @@ import androidx.core.graphics.drawable.toBitmap
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
@@ -1679,19 +1680,24 @@ fun BadgeDisplay(baseNote: Note) {
val background = MaterialTheme.colors.background
val badgeData = baseNote.event as? BadgeDefinitionEvent ?: return
val image = remember { badgeData.image() }
val image = remember { badgeData.thumb()?.ifBlank { null } ?: badgeData.image() }
val name = remember { badgeData.name() }
val description = remember { badgeData.description() }
var backgroundFromImage by remember { mutableStateOf(Pair(background, background)) }
var imageResult by remember { mutableStateOf<AsyncImagePainter.State.Success?>(null) }
var imageResult by remember { mutableStateOf<SuccessResult?>(null) }
LaunchedEffect(key1 = imageResult) {
withContext(Dispatchers.IO) {
launch(Dispatchers.IO) {
imageResult?.let {
val backgroundColor = it.result.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
val backgroundColor = it.drawable.toBitmap(200, 200).copy(Bitmap.Config.ARGB_8888, false).get(0, 199)
val colorFromImage = Color(backgroundColor)
val textBackground = if (colorFromImage.luminance() > 0.5) lightColors().onBackground else darkColors().onBackground
val textBackground = if (colorFromImage.luminance() > 0.5) {
lightColors().onBackground
} else {
darkColors().onBackground
}
backgroundFromImage = Pair(colorFromImage, textBackground)
}
}
@@ -1708,47 +1714,65 @@ fun BadgeDisplay(baseNote: Note) {
)
.background(backgroundFromImage.first)
) {
Column {
image.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.badge_award_image_for,
name ?: ""
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
onSuccess = {
imageResult = it
}
)
RenderBadge(
image,
name,
backgroundFromImage.second,
description
) {
if (imageResult == null) {
imageResult = it.result
}
}
}
}
name?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = backgroundFromImage.second
)
}
@Composable
private fun RenderBadge(
image: String?,
name: String?,
backgroundFromImage: Color,
description: String?,
onSuccess: (AsyncImagePainter.State.Success) -> Unit
) {
Column {
image.let {
AsyncImage(
model = it,
contentDescription = stringResource(
R.string.badge_award_image_for,
name ?: ""
),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth(),
onSuccess = onSuccess
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
name?.let {
Text(
text = it,
style = MaterialTheme.typography.body1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp),
color = backgroundFromImage
)
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, bottom = 10.dp),
color = Color.Gray,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -3,12 +3,10 @@ package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.RelaySetupInfo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.model.*
import kotlinx.coroutines.flow.MutableStateFlow
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.ui.screen.RelayFeedView
import com.vitorpamplona.amethyst.ui.screen.RelayFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.UserFeedView
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -736,7 +737,7 @@ private fun DisplayBadges(
if (list.isNullOrEmpty()) {
null
} else {
list
list.toImmutableList()
}
}
}
@@ -759,9 +760,8 @@ private fun LoadAndRenderBadge(badgeAwardEventHex: String, nav: (String) -> Unit
mutableStateOf<Note?>(null)
}
val scope = rememberCoroutineScope()
LaunchedEffect(key1 = badgeAwardEventHex) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
baseNote = LocalCache.getOrCreateNote(badgeAwardEventHex)
}
}
@@ -788,7 +788,7 @@ fun BadgeThumb(
pictureModifier: Modifier = Modifier
) {
BadgeThumb(note, size, pictureModifier) {
nav("Note/$it")
nav("Note/${note.idHex}")
}
}
@@ -808,7 +808,7 @@ fun BadgeThumb(
) {
val noteState by baseNote.live().metadata.observeAsState()
val event = remember(noteState) { noteState?.note?.event as? BadgeDefinitionEvent } ?: return
val image = remember(noteState) { event.thumb() ?: event.image() }
val image = remember(noteState) { event.thumb()?.ifBlank { null } ?: event.image()?.ifBlank { null } }
if (image == null) {
RobohashAsyncImage(
@@ -122,6 +122,7 @@ fun SearchScreen(
fun WatchAccountForSearchScreen(searchFeedViewModel: NostrGlobalFeedViewModel, accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
NostrGlobalDataSource.resetFilters()
NostrSearchEventOrUserDataSource.start()
searchFeedViewModel.invalidateData(true)
}
}
@@ -130,7 +131,7 @@ class SearchBarViewModel : ViewModel() {
var account: Account? = null
var searchValue by mutableStateOf("")
val searchResults = mutableStateOf<List<User>>(emptyList())
val searchResultsUsers = mutableStateOf<List<User>>(emptyList())
val searchResultsNotes = mutableStateOf<List<Note>>(emptyList())
val searchResultsChannels = mutableStateOf<List<Channel>>(emptyList())
val hashtagResults = mutableStateOf<List<String>>(emptyList())
@@ -147,21 +148,21 @@ class SearchBarViewModel : ViewModel() {
private fun runSearch() {
if (searchValue.isBlank()) {
hashtagResults.value = emptyList()
searchResults.value = emptyList()
searchResultsUsers.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
return
}
hashtagResults.value = findHashtags(searchValue)
searchResults.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
searchResultsUsers.value = LocalCache.findUsersStartingWith(searchValue).sortedWith(compareBy({ account?.isFollowing(it) }, { it.toBestDisplayName() })).reversed()
searchResultsNotes.value = LocalCache.findNotesStartingWith(searchValue).sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed()
searchResultsChannels.value = LocalCache.findChannelsStartingWith(searchValue)
}
fun clean() {
searchValue = ""
searchResults.value = emptyList()
searchResultsUsers.value = emptyList()
searchResultsChannels.value = emptyList()
searchResultsNotes.value = emptyList()
}
@@ -183,7 +184,7 @@ class SearchBarViewModel : ViewModel() {
@Composable
private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit) {
val searchBarViewModel: SearchBarViewModel = viewModel()
searchBarViewModel.account = accountViewModel.accountLiveData.value?.account
searchBarViewModel.account = accountViewModel.account
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
@@ -196,7 +197,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
}
LaunchedEffect(Unit) {
scope.launch(Dispatchers.IO) {
launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect {
if (searchBarViewModel.isSearching()) {
searchBarViewModel.invalidateData()
@@ -306,7 +307,7 @@ private fun SearchBar(accountViewModel: AccountViewModel, nav: (String) -> Unit)
}
}
itemsIndexed(searchBarViewModel.searchResults.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
itemsIndexed(searchBarViewModel.searchResultsUsers.value, key = { _, item -> "u" + item.pubkeyHex }) { _, item ->
UserCompose(item, accountViewModel = accountViewModel, nav = nav)
}