From ad3aa124fc7d97ba7187e60ec938e56fa8d06528 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 28 Jun 2023 18:32:10 -0400 Subject: [PATCH] Performance improvements additional MainThread checks. --- .../amethyst/model/HashtagIcon.kt | 3 + .../amethyst/model/LocalCache.kt | 6 +- .../com/vitorpamplona/amethyst/model/Note.kt | 19 +- .../amethyst/service/BlurHashDecoder.kt | 2 + .../amethyst/service/CashuProcessor.kt | 4 + .../amethyst/service/Nip05Verifier.kt | 8 +- .../ui/actions/RelayInformationDialog.kt | 9 +- .../amethyst/ui/components/CashuRedeem.kt | 28 +- .../ui/components/ClickableWithdrawal.kt | 16 +- .../amethyst/ui/components/InvoicePreview.kt | 20 +- .../amethyst/ui/components/MediaCompressor.kt | 3 + .../amethyst/ui/components/RichTextViewer.kt | 309 ++++++------- .../amethyst/ui/components/VideoView.kt | 32 ++ .../ui/components/ZapRaiserRequest.kt | 10 +- .../amethyst/ui/note/ChatroomCompose.kt | 39 +- .../amethyst/ui/note/MultiSetCompose.kt | 423 +++++++++--------- .../amethyst/ui/note/NoteCompose.kt | 96 ++-- .../amethyst/ui/note/ReactionsRow.kt | 2 - .../ui/screen/ChatroomListFeedView.kt | 17 +- .../amethyst/ui/screen/FeedView.kt | 3 +- .../ui/screen/loggedIn/ConnectOrbotDialog.kt | 6 +- .../vitorpamplona/amethyst/ui/theme/Shape.kt | 15 + .../vitorpamplona/amethyst/ui/theme/Theme.kt | 32 +- .../vitorpamplona/amethyst/ui/theme/Type.kt | 34 ++ .../service/lang/LanguageTranslatorService.kt | 20 +- .../components/TranslatableRichTextViewer.kt | 107 +++-- 26 files changed, 736 insertions(+), 527 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt index 1f2f82025..8ffeb9d71 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/HashtagIcon.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.model import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -21,6 +22,8 @@ fun checkForHashtagWithIcon(tag: String): HashtagIcon? { else -> null } } + +@Immutable class HashtagIcon( val icon: Int, val description: String, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 7bb5f3657..91bd47fa5 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -51,7 +51,7 @@ object LocalCache { } fun getUserIfExists(key: String): User? { - return users[key] + return users.get(key) } fun getNoteIfExists(key: String): Note? { @@ -230,7 +230,7 @@ object LocalCache { note.loadEvent(event, author, replyTo) - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}") + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") // Prepares user's profile view. author.addNote(note) @@ -302,7 +302,7 @@ object LocalCache { note.loadEvent(event, author, replyTo) - // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.take(100)} ${formattedDateTime(event.createdAt)}") + // Log.d("TN", "New Note (${notes.size},${users.size}) ${note.author?.toBestDisplayName()} ${note.event?.content()?.split("\n")?.take(100)} ${formattedDateTime(event.createdAt)}") // Prepares user's profile view. author.addNote(note) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index e3610ed6d..48d4253c2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -77,11 +77,20 @@ open class Note(val idHex: String) { open fun idDisplayNote() = idNote().toShortenHex() fun channelHex(): HexKey? { - return (event as? ChannelMessageEvent)?.channel() - ?: (event as? ChannelMetadataEvent)?.channel() - ?: (event as? ChannelCreateEvent)?.id - ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() - ?: (event as? LiveActivitiesEvent)?.address()?.toTag() + return if (event is ChannelMessageEvent || + event is ChannelMetadataEvent || + event is ChannelCreateEvent || + event is LiveActivitiesChatMessageEvent || + event is LiveActivitiesEvent + ) { + (event as? ChannelMessageEvent)?.channel() + ?: (event as? ChannelMetadataEvent)?.channel() + ?: (event as? ChannelCreateEvent)?.id + ?: (event as? LiveActivitiesChatMessageEvent)?.activity()?.toTag() + ?: (event as? LiveActivitiesEvent)?.address()?.toTag() + } else { + null + } } open fun address(): ATag? = null diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt index 02f980056..8efb55dc2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/BlurHashDecoder.kt @@ -49,6 +49,8 @@ object BlurHashDecoder { * By default it is true. */ fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f, useCache: Boolean = true): Bitmap? { + checkNotInMainThread() + if (blurHash == null || blurHash.length < 6) { return null } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 14c88a39b..2929e9b4c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -23,6 +23,8 @@ data class CashuToken( class CashuProcessor { fun parse(cashuToken: String): GenericLoadable { + checkNotInMainThread() + try { val base64token = cashuToken.replace("cashuA", "") val cashu = JsonParser.parseString(String(Base64.getDecoder().decode(base64token))) @@ -44,6 +46,8 @@ class CashuProcessor { } fun melt(token: CashuToken, lud16: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { + checkNotInMainThread() + runCatching { LightningAddressResolver().lnAddressInvoice( lnaddress = lud16, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt index 085afad02..9872b28c4 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/Nip05Verifier.kt @@ -31,7 +31,7 @@ class Nip05Verifier() { } private suspend fun fetchNip05JsonSuspend(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { - // checkNotInMainThread() + checkNotInMainThread() val url = assembleUrl(nip05) @@ -49,6 +49,8 @@ class Nip05Verifier() { HttpClient.getHttpClient().newCall(request).enqueue(object : Callback { override fun onResponse(call: Call, response: Response) { + checkNotInMainThread() + response.use { if (it.isSuccessful) { onSuccess(it.body.string()) @@ -71,13 +73,15 @@ class Nip05Verifier() { fun verifyNip05(nip05: String, onSuccess: (String) -> Unit, onError: (String) -> Unit) { // check fails on tests - // checkNotInMainThread() + checkNotInMainThread() val mapper = jacksonObjectMapper() fetchNip05Json( nip05, onSuccess = { + checkNotInMainThread() + // NIP05 usernames are case insensitive, but JSON properties are not // converts the json to lowercase and then tries to access the username via a // lowercase version of the username. diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt index a9cf7c96d..1ca108c9a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/RelayInformationDialog.kt @@ -3,6 +3,7 @@ package com.vitorpamplona.amethyst.ui.actions import android.content.Context import android.util.Log import android.widget.Toast +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,6 +30,7 @@ import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.RelayInformation import com.vitorpamplona.amethyst.service.HttpClient +import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.ClickableEmail import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.note.LoadUser @@ -246,7 +248,11 @@ private fun DisplayOwnerInformation( nav: (String) -> Unit ) { LoadUser(baseUserHex = userHex) { - UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav) + Crossfade(it) { + if (it != null) { + UserCompose(baseUser = it, accountViewModel = accountViewModel, showDiviser = false, nav = nav) + } + } } } @@ -305,6 +311,7 @@ fun loadRelayInfo( .enqueue( object : Callback { override fun onResponse(call: Call, response: Response) { + checkNotInMainThread() response.use { val body = it.body.string() try { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index afe48697c..1bfdb9843 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -126,20 +126,22 @@ fun CashuPreview(token: CashuToken, accountViewModel: AccountViewModel) { startActivity(context, intent, null) } else { if (lud16 != null) { - CashuProcessor().melt( - token, - lud16, - onSuccess = { - scope.launch { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + scope.launch(Dispatchers.IO) { + CashuProcessor().melt( + token, + lud16, + onSuccess = { + scope.launch { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } + }, + onError = { + scope.launch { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + } } - }, - onError = { - scope.launch { - Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - } - } - ) + ) + } } else { scope.launch { Toast.makeText(context, "No Lightning Address set", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt index 3b32ce8c8..8ef06cb73 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableWithdrawal.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Intent import android.net.Uri +import androidx.compose.animation.Crossfade import androidx.compose.foundation.text.ClickableText import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme @@ -25,13 +26,16 @@ fun MayBeWithdrawal(lnurlWord: String) { } } - lnWithdrawal?.let { - ClickableWithdrawal(withdrawalString = it) + Crossfade(targetState = lnWithdrawal) { + if (it != null) { + ClickableWithdrawal(withdrawalString = it) + } else { + Text( + text = "$lnurlWord ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } - ?: Text( - text = "$lnurlWord ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt index bb152be63..b5abbf84f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/InvoicePreview.kt @@ -2,6 +2,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.Intent import android.net.Uri +import androidx.compose.animation.Crossfade import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -27,7 +28,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LnInvoiceUtil import com.vitorpamplona.amethyst.ui.theme.QuoteBorder import com.vitorpamplona.amethyst.ui.theme.subtleBorder import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.launch import java.text.NumberFormat @Composable @@ -35,7 +36,7 @@ fun MayBeInvoicePreview(lnbcWord: String) { var lnInvoice by remember { mutableStateOf?>(null) } LaunchedEffect(key1 = lnbcWord) { - withContext(Dispatchers.IO) { + launch(Dispatchers.IO) { val myInvoice = LnInvoiceUtil.findInvoice(lnbcWord) if (myInvoice != null) { val myInvoiceAmount = try { @@ -50,13 +51,16 @@ fun MayBeInvoicePreview(lnbcWord: String) { } } - lnInvoice?.let { - InvoicePreview(it.first, it.second) + Crossfade(targetState = lnInvoice) { + if (it != null) { + InvoicePreview(it.first, it.second) + } else { + Text( + text = "$lnbcWord ", + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } } - ?: Text( - text = "$lnbcWord ", - style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) - ) } @Composable diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt index 8d5a5e8e3..4cb9b17dc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/MediaCompressor.kt @@ -10,6 +10,7 @@ import com.abedelazizshe.lightcompressorlibrary.VideoCompressor import com.abedelazizshe.lightcompressorlibrary.VideoQuality import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration import com.abedelazizshe.lightcompressorlibrary.config.Configuration +import com.vitorpamplona.amethyst.service.checkNotInMainThread import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default import java.io.File @@ -24,6 +25,8 @@ class MediaCompressor { onReady: (Uri, String?, Long?) -> Unit, onError: (String) -> Unit ) { + checkNotInMainThread() + if (contentType?.startsWith("video", true) == true) { VideoCompressor.start( context = applicationContext, // => This is required 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 e12b737e5..4b1f5a72d 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 @@ -4,7 +4,6 @@ import android.util.Log import android.util.LruCache import android.util.Patterns import androidx.compose.animation.Crossfade -import androidx.compose.foundation.border import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.InlineTextContent @@ -17,26 +16,19 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.* -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.map import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.markdown.MarkdownParseOptions -import com.halilibo.richtext.ui.HeadingStyle -import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.material.MaterialRichText -import com.halilibo.richtext.ui.resolveDefaults import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User @@ -45,10 +37,11 @@ import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Font17SP import com.vitorpamplona.amethyst.ui.theme.MarkdownTextStyle -import com.vitorpamplona.amethyst.ui.theme.QuoteBorder +import com.vitorpamplona.amethyst.ui.theme.innerPostModifier import com.vitorpamplona.amethyst.ui.theme.markdownStyle -import com.vitorpamplona.amethyst.ui.theme.subtleBorder +import com.vitorpamplona.amethyst.ui.theme.replyModifier import com.vitorpamplona.amethyst.ui.uriToRoute import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap @@ -89,38 +82,6 @@ fun isValidURL(url: String?): Boolean { } } -internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> - when (level) { - 0 -> textStyle.copy( - fontSize = 30.sp, - fontWeight = FontWeight.Light - ) - 1 -> textStyle.copy( - fontSize = 26.sp, - fontWeight = FontWeight.Light - ) - 2 -> textStyle.copy( - fontSize = 22.sp, - fontWeight = FontWeight.Light - ) - 3 -> textStyle.copy( - fontSize = 20.sp, - fontWeight = FontWeight.Bold - ) - 4 -> textStyle.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ) - 5 -> textStyle.copy( - fontWeight = FontWeight.Bold - ) - else -> textStyle - } -} - -internal val DefaultParagraphSpacing: TextUnit = 12.sp -val richTextDefaults = RichTextStyle().resolveDefaults() - fun isMarkdown(content: String): Boolean { return content.startsWith("> ") || content.startsWith("# ") || @@ -399,6 +360,9 @@ private fun NormalWord(word: String) { ) } +@Immutable +data class UrlWithExtraChars(val url: String, val extraChars: String) + @Composable private fun NoProtocolUrlRenderer(word: String) { val wordSpace = remember(word) { @@ -406,7 +370,7 @@ private fun NoProtocolUrlRenderer(word: String) { } var linkedUrl by remember(word) { - mutableStateOf?>(null) + mutableStateOf(null) } LaunchedEffect(key1 = word) { @@ -416,19 +380,30 @@ private fun NoProtocolUrlRenderer(word: String) { val url = matcher.group(1) // url val additionalChars = matcher.group(4) ?: "" // additional chars - linkedUrl = Pair(url, additionalChars) + launch(Dispatchers.Main) { + linkedUrl = UrlWithExtraChars(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) - ) + Crossfade(targetState = linkedUrl) { + if (it != null) { + RenderUrl(it) + } else { + Text( + text = wordSpace, + style = LocalTextStyle.current.copy(textDirection = TextDirection.Content) + ) + } + } +} + +@Composable +private fun RenderUrl(it: UrlWithExtraChars) { + Row() { + ClickableUrl(it.url, "https://${it.url}") + Text("${it.extraChars} ") } } @@ -443,14 +418,16 @@ fun RenderCustomEmoji(word: String, state: RichTextViewerState) { @Composable private fun RenderContentAsMarkdown(content: String, backgroundColor: MutableState, tags: ImmutableListOfLists?, nav: (String) -> Unit) { val uri = LocalUriHandler.current - val onClick = { link: String -> - val route = uriToRoute(link) - if (route != null) { - nav(route) - } else { - runCatching { uri.openUri(link) } + val onClick = remember { + { link: String -> + val route = uriToRoute(link) + if (route != null) { + nav(route) + } else { + runCatching { uri.openUri(link) } + } + Unit } - Unit } ProvideTextStyle(MarkdownTextStyle) { @@ -522,7 +499,7 @@ private fun ObserveNIP19Event( launch(Dispatchers.IO) { if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> - baseNote = note + launch(Dispatchers.Main) { baseNote = note } } } } @@ -559,7 +536,7 @@ private fun ObserveNIP19User( launch(Dispatchers.IO) { if (it.type == Nip19.Type.USER) { LocalCache.checkGetOrCreateUser(it.hex)?.let { user -> - baseUser = user + launch(Dispatchers.Main) { baseUser = user } } } } @@ -761,21 +738,25 @@ fun BechLink(word: String, canPreview: Boolean, backgroundColor: MutableState - loadedLink.baseNote?.let { - DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink) - } ?: run { - ClickableRoute(loadedLink.nip19, nav) + Crossfade(targetState = loadedLink) { + if (canPreview && it?.baseNote != null) { + Row() { + DisplayFullNote(it.baseNote, accountViewModel, backgroundColor, nav, it) } - } ?: run { - Text(text = remember { "$word " }) - } - } else { - loadedLink?.let { - ClickableRoute(it.nip19, nav) - } ?: run { - Text(text = remember { "$word " }) + } else if (it?.nip19 != null) { + Row() { + ClickableRoute(it.nip19, nav) + } + } else { + val text = remember { + if (word.length > 16) { + word.replaceRange(8, word.length - 8, ":") + } else { + "$word " + } + } + + Text(text = text, maxLines = 1) } } } @@ -812,9 +793,11 @@ private fun DisplayFullNote( } } +data class HashWordWithExtra(val hashtag: String, val extras: String?) + @Composable fun HashTag(word: String, nav: (String) -> Unit) { - var tagSuffixPair by remember { mutableStateOf?>(null) } + var tagSuffixPair by remember { mutableStateOf(null) } LaunchedEffect(key1 = word) { launch(Dispatchers.IO) { @@ -830,67 +813,80 @@ fun HashTag(word: String, nav: (String) -> Unit) { if (myTag != null) { launch(Dispatchers.Main) { - tagSuffixPair = Pair(myTag, mySuffix) + tagSuffixPair = HashWordWithExtra(myTag, mySuffix) } } } } - tagSuffixPair?.let { tagPair -> - val hashtagIcon = remember(tagPair.first) { checkForHashtagWithIcon(tagPair.first) } - ClickableText( - text = buildAnnotatedString { - withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() - ) { - append("#${tagPair.first}") - } - }, - onClick = { nav("Hashtag/${tagPair.first}") } - ) - - if (hashtagIcon != null) { - val myId = "inlineContent" - val emptytext = buildAnnotatedString { - withStyle( - LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() - ) { - append("") - appendInlineContent(myId, "[icon]") - } + Crossfade(targetState = tagSuffixPair) { + if (it != null) { + Row() { + RenderHashtag(it, nav) } - val inlineContent = mapOf( - Pair( - myId, - InlineTextContent( - Placeholder( - width = 17.sp, - height = 17.sp, - placeholderVerticalAlign = PlaceholderVerticalAlign.Center - ) - ) { - Icon( - painter = painterResource(hashtagIcon.icon), - contentDescription = hashtagIcon.description, - tint = hashtagIcon.color, - modifier = hashtagIcon.modifier - ) - } - ) - ) - - // Empty Text for Size of Icon - Text( - text = emptytext, - inlineContent = inlineContent - ) + } else { + Text(text = remember { "$word " }) } - tagPair.second?.ifBlank { "" }?.let { - Text(text = "$it ") - } - } ?: Text(text = "$word ") + } } +@Composable +private fun RenderHashtag( + tagPair: HashWordWithExtra, + nav: (String) -> Unit +) { + val hashtagIcon = remember(tagPair.hashtag) { checkForHashtagWithIcon(tagPair.hashtag) } + ClickableText( + text = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("#${tagPair.hashtag}") + } + }, + onClick = { nav("Hashtag/${tagPair.hashtag}") } + ) + + if (hashtagIcon != null) { + val myId = "inlineContent" + val emptytext = buildAnnotatedString { + withStyle( + LocalTextStyle.current.copy(color = MaterialTheme.colors.primary).toSpanStyle() + ) { + append("") + appendInlineContent(myId, "[icon]") + } + } + // Empty Text for Size of Icon + Text( + text = emptytext, + inlineContent = mapOf( + myId to InlineIcon(hashtagIcon) + ) + ) + } + tagPair.extras?.ifBlank { "" }?.let { + Text(text = "$it ") + } +} + +@Composable +private fun InlineIcon(hashtagIcon: HashtagIcon) = + InlineTextContent( + Placeholder( + width = Font17SP, + height = Font17SP, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center + ) + ) { + Icon( + painter = painterResource(hashtagIcon.icon), + contentDescription = hashtagIcon.description, + tint = hashtagIcon.color, + modifier = hashtagIcon.modifier + ) + } + data class LoadedTag(val user: User?, val note: Note?, val addedChars: String) @Composable @@ -932,19 +928,28 @@ fun TagLink(word: String, tags: ImmutableListOfLists, canPreview: Boolea } } - if (loadedTag == null) { - Text( - text = remember { - "$word " + Crossfade(targetState = loadedTag) { + if (it == null) { + Text( + text = remember { + "$word " + } + ) + } else if (it.user != null) { + Row() { + DisplayUserFromTag(it.user, loadedTag?.addedChars ?: "", nav) + } + } else if (it.note != null) { + Row() { + DisplayNoteFromTag( + it.note, + loadedTag?.addedChars ?: "", + canPreview, + accountViewModel, + backgroundColor, + nav + ) } - ) - } else { - loadedTag?.user?.let { - DisplayUserFromTag(it, loadedTag?.addedChars ?: "", nav) - } - - loadedTag?.note?.let { - DisplayNoteFromTag(it, loadedTag?.addedChars ?: "", canPreview, accountViewModel, backgroundColor, nav) } } } @@ -991,16 +996,18 @@ private fun DisplayUserFromTag( }.distinctUntilChanged().observeAsState(baseUser.info) Crossfade(targetState = meta) { - val displayName = remember(it) { - it?.bestDisplayName() ?: hex + Row() { + val displayName = remember(it) { + it?.bestDisplayName() ?: hex + } + CreateClickableTextWithEmoji( + clickablePart = displayName, + suffix = suffix, + maxLines = 1, + route = route, + nav = nav, + tags = it?.tags + ) } - CreateClickableTextWithEmoji( - clickablePart = displayName, - suffix = suffix, - maxLines = 1, - route = route, - nav = nav, - tags = it?.tags ?: ImmutableListOfLists() - ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index c408f29f5..8859a32b9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -62,6 +62,8 @@ import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.HttpClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue public var DefaultMutedSetting = mutableStateOf(true) @@ -97,12 +99,26 @@ fun LoadThumbAndThenVideoView(videoUri: String, description: String? = null, thu } } +@OptIn(ExperimentalTime::class) @Composable fun VideoView( videoUri: String, description: String? = null, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null +) { + val (value, elapsed) = measureTimedValue { + VideoView1(videoUri, description, thumb, onDialog) + } + Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") +} + +@Composable +fun VideoView1( + videoUri: String, + description: String? = null, + thumb: VideoThumb? = null, + onDialog: ((Boolean) -> Unit)? = null ) { var exoPlayerData by remember { mutableStateOf(null) } val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) } @@ -127,6 +143,7 @@ fun VideoView( } } +@OptIn(ExperimentalTime::class) @Composable fun VideoView( videoUri: String, @@ -135,6 +152,21 @@ fun VideoView( defaultToStart: Boolean = false, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null +) { + val (value, elapsed) = measureTimedValue { + VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog) + } + Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") +} + +@Composable +fun VideoView1( + videoUri: String, + description: String? = null, + exoPlayerData: VideoPlayer, + defaultToStart: Boolean = false, + thumb: VideoThumb? = null, + onDialog: ((Boolean) -> Unit)? = null ) { val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt index 4e55dd4b8..6fa836980 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZapRaiserRequest.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.Divider import androidx.compose.material.Icon @@ -25,6 +24,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.NewPostViewModel +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.placeholderText @Composable @@ -33,12 +33,10 @@ fun ZapRaiserRequest( newPostViewModel: NewPostViewModel ) { Column( - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { Column( - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -49,7 +47,7 @@ fun ZapRaiserRequest( Icon( painter = painterResource(R.drawable.lightning), null, - modifier = Modifier.size(20.dp), + modifier = Size20Modifier, tint = Color.Unspecified ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt index c02668cf7..ac381c736 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomCompose.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.note +import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -53,9 +54,14 @@ import com.vitorpamplona.amethyst.service.model.ChannelMetadataEvent import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.ChatHeadlineBorders +import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size55dp +import com.vitorpamplona.amethyst.ui.theme.Size75dp +import com.vitorpamplona.amethyst.ui.theme.StdTopPadding import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.Dispatchers @@ -110,8 +116,16 @@ private fun ChatroomDirectMessage( } userRoomHex?.let { - LoadUser(it) { user -> - UserRoomCompose(baseNote, user, accountViewModel, nav) + LoadUser(it) { baseUser -> + Crossfade(baseUser) { user -> + if (user != null) { + UserRoomCompose(baseNote, user, accountViewModel, nav) + } else { + Box(Modifier.height(Size75dp).fillMaxWidth()) { + // Makes sure just a max amount of objects are loaded. + } + } + } } } } @@ -246,6 +260,7 @@ private fun UserRoomCompose( accountViewModel.decrypt(note) } } + WatchNotificationChanges(note, route, accountViewModel) { newHasNewMessages -> if (hasNewMessages.value != newHasNewMessages) { hasNewMessages.value = newHasNewMessages @@ -288,7 +303,7 @@ private fun WatchNotificationChanges( } @Composable -fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) { +fun LoadUser(baseUserHex: String, content: @Composable (User?) -> Unit) { var user by remember(baseUserHex) { mutableStateOf(LocalCache.getUserIfExists(baseUserHex)) } @@ -301,9 +316,7 @@ fun LoadUser(baseUserHex: String, content: @Composable (User) -> Unit) { } } - user?.let { - content(it) - } + content(user) } @Composable @@ -348,9 +361,7 @@ fun ChannelName( onClick: () -> Unit ) { Column(modifier = remember { Modifier.clickable(onClick = onClick) }) { - Row( - modifier = remember { Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) } - ) { + Row(modifier = ChatHeadlineBorders) { Column(Size55Modifier) { channelPicture() } @@ -362,11 +373,13 @@ fun ChannelName( verticalArrangement = Arrangement.SpaceAround ) { Row( - verticalAlignment = Alignment.CenterVertically, - modifier = remember { Modifier.padding(bottom = 4.dp) } + verticalAlignment = Alignment.CenterVertically ) { FirstRow(channelTitle, channelLastTime, remember { Modifier.weight(1f) }) } + + Spacer(modifier = Height4dpModifier) + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -377,8 +390,8 @@ fun ChannelName( } Divider( - modifier = remember { Modifier.padding(top = 10.dp) }, - thickness = 0.25.dp + modifier = StdTopPadding, + thickness = DividerThickness ) } } 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 1491f2148..1e17dda7b 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 @@ -1,8 +1,8 @@ package com.vitorpamplona.amethyst.ui.note +import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -11,7 +11,6 @@ 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 import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -39,35 +38,49 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource 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 androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.map import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.UserMetadata import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists +import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.screen.CombinedZap import com.vitorpamplona.amethyst.ui.screen.MultiSetCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.showAmountAxis import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier +import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifierSmaller +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.Size18dp +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.Size35Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp +import com.vitorpamplona.amethyst.ui.theme.StdStartPadding +import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifier import com.vitorpamplona.amethyst.ui.theme.WidthAuthorPictureModifierWithPadding import com.vitorpamplona.amethyst.ui.theme.newItemBackgroundColor import com.vitorpamplona.amethyst.ui.theme.overPictureBackground +import com.vitorpamplona.amethyst.ui.theme.profile35dpModifier import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalTime::class) @Composable fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accountViewModel: AccountViewModel, nav: (String) -> Unit) { val baseNote = remember { multiSetCard.note } @@ -121,32 +134,39 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, accoun } Column(modifier = columnModifier) { - Galeries(multiSetCard, backgroundColor, nav, accountViewModel) + val (value, elapsed) = measureTimedValue { + Galeries(multiSetCard, backgroundColor, accountViewModel, nav) + } + Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ") Row(remember { Modifier.fillMaxWidth() }) { Spacer(modifier = WidthAuthorPictureModifierWithPadding) - NoteCompose( - baseNote = baseNote, - routeForLastRead = null, - modifier = remember { Modifier.padding(top = 5.dp) }, - isBoostedNote = true, - parentBackgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + val (value, elapsed) = measureTimedValue { + NoteCompose( + baseNote = baseNote, + routeForLastRead = null, + modifier = remember { Modifier.padding(top = 5.dp) }, + isBoostedNote = true, + parentBackgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav + ) + } + Log.d("Rendering Metrics", "Complete: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") NoteDropDownMenu(baseNote, popupExpanded, { popupExpanded = false }, accountViewModel) } } } +@OptIn(ExperimentalTime::class) @Composable private fun Galeries( multiSetCard: MultiSetCard, backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + accountViewModel: AccountViewModel, + nav: (String) -> Unit ) { val zapEvents by remember { derivedStateOf { multiSetCard.zapEvents } } val boostEvents by remember { derivedStateOf { multiSetCard.boostEvents } } @@ -157,17 +177,26 @@ private fun Galeries( val hasLikeEvents by remember { derivedStateOf { multiSetCard.likeEvents.isNotEmpty() } } if (hasZapEvents) { - RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) + val (value, elapsed) = measureTimedValue { + RenderZapGallery(zapEvents, backgroundColor, nav, accountViewModel) + } + Log.d("Rendering Metrics", "Galeries Zaps: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } if (hasBoostEvents) { - RenderBoostGallery(boostEvents, backgroundColor, nav, accountViewModel) + val (value, elapsed) = measureTimedValue { + RenderBoostGallery(boostEvents, nav, accountViewModel) + } + Log.d("Rendering Metrics", "Galeries Repost: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } if (hasLikeEvents) { - likeEvents.forEach { - RenderLikeGallery(it.key, it.value, backgroundColor, nav, accountViewModel) + val (value, elapsed) = measureTimedValue { + likeEvents.forEach { + RenderLikeGallery(it.key, it.value, nav, accountViewModel) + } } + Log.d("Rendering Metrics", "Galeries Like: ${multiSetCard.note.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } } @@ -175,39 +204,23 @@ private fun Galeries( fun RenderLikeGallery( reactionType: String, likeEvents: ImmutableList, - backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - val isNotEmpty = remember(likeEvents) { - likeEvents.isNotEmpty() - } - - val shortReaction by remember { - derivedStateOf { - reactionType.firstFullChar() - } - } - - if (isNotEmpty) { - Row(remember { Modifier.fillMaxWidth() }) { + if (likeEvents.isNotEmpty()) { + Row(Modifier.fillMaxWidth()) { Box( - modifier = remember { - Modifier - .width(55.dp) - .padding(end = 5.dp) - } + modifier = NotificationIconModifier ) { val modifier = remember { - Modifier - .align(Alignment.TopEnd) + Modifier.align(Alignment.TopEnd) } - when (shortReaction) { + when (val shortReaction = reactionType.firstFullChar()) { "+" -> Icon( painter = painterResource(R.drawable.ic_liked), null, - modifier = remember { modifier.size(18.dp) }, + modifier = modifier.size(Size18dp), tint = Color.Unspecified ) "-" -> Text(text = "\uD83D\uDC4E", modifier = modifier) @@ -215,7 +228,7 @@ fun RenderLikeGallery( } } - AuthorGallery(likeEvents, backgroundColor, nav, accountViewModel) + AuthorGallery(likeEvents, nav, accountViewModel) } } } @@ -237,21 +250,17 @@ fun RenderZapGallery( @Composable private fun ZapIcon() { Box( - modifier = remember { - Modifier - .width(55.dp) - .padding(0.dp) - } + modifier = WidthAuthorPictureModifier ) { Icon( imageVector = Icons.Default.Bolt, contentDescription = "Zaps", tint = BitcoinOrange, - modifier = remember { - Modifier - .size(25.dp) - .align(Alignment.TopEnd) - } + modifier = + Modifier + .size(Size25dp) + .align(Alignment.TopEnd) + ) } } @@ -259,21 +268,14 @@ private fun ZapIcon() { @Composable fun RenderBoostGallery( boostEvents: ImmutableList, - backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { Row( - modifier = remember { - Modifier.fillMaxWidth() - } + modifier = Modifier.fillMaxWidth() ) { Box( - modifier = remember { - Modifier - .width(55.dp) - .padding(end = 4.dp) - } + modifier = NotificationIconModifierSmaller ) { Icon( painter = painterResource(R.drawable.ic_retweeted), @@ -287,7 +289,7 @@ fun RenderBoostGallery( ) } - AuthorGallery(boostEvents, backgroundColor, nav, accountViewModel) + AuthorGallery(boostEvents, nav, accountViewModel) } } @@ -299,15 +301,27 @@ fun AuthorGalleryZaps( nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - Column(modifier = remember { Modifier.padding(start = 10.dp) }) { + Column(modifier = StdStartPadding) { FlowRow() { authorNotes.forEach { - AuthorPictureAndComment(it.request, it.response, backgroundColor, nav, accountViewModel) + ParseAuthorCommentAndAmount(it, backgroundColor, nav, accountViewModel) } } } } +@Composable +private fun ParseAuthorCommentAndAmount( + zap: CombinedZap, + backgroundColor: MutableState, + nav: (String) -> Unit, + accountViewModel: AccountViewModel +) { + ParseAuthorCommentAndAmount(zap.request, zap.response, accountViewModel) { state -> + RenderState(state, backgroundColor, accountViewModel, nav) + } +} + @Immutable data class ZapAmountCommentNotification( val user: User?, @@ -316,12 +330,11 @@ data class ZapAmountCommentNotification( ) @Composable -private fun AuthorPictureAndComment( +private fun ParseAuthorCommentAndAmount( zapRequest: Note, zapEvent: Note?, - backgroundColor: MutableState, - nav: (String) -> Unit, - accountViewModel: AccountViewModel + accountViewModel: AccountViewModel, + onReady: @Composable (MutableState) -> Unit ) { val content = remember { mutableStateOf( @@ -334,7 +347,7 @@ private fun AuthorPictureAndComment( } LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { - launch(Dispatchers.Default) { + launch(Dispatchers.IO) { (zapRequest.event as? LnZapRequestEvent)?.let { val decryptedContent = accountViewModel.decryptZap(zapRequest) val amount = (zapEvent?.event as? LnZapEvent)?.amount @@ -353,111 +366,116 @@ private fun AuthorPictureAndComment( } } - Crossfade(targetState = content) { - Row( - modifier = Modifier.clickable { - nav("User/${it.value.user?.pubkeyHex}") - }, - verticalAlignment = Alignment.CenterVertically - ) { - AuthorPictureAndComment( - authorComment = it, - backgroundColor = backgroundColor, - nav = nav, - accountViewModel = accountViewModel - ) - } + onReady(content) +} + +@Composable +private fun RenderState( + content: MutableState, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + Row( + modifier = Modifier.clickable { + content.value.user?.let { + nav(routeFor(it)) + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + DisplayAuthorCommentAndAmount( + authorComment = content, + backgroundColor = backgroundColor, + nav = nav, + accountViewModel = accountViewModel + ) } } val amountBoxModifier = Modifier - .fillMaxSize() + .size(Size35dp) .clip(shape = CircleShape) val textBoxModifier = Modifier .padding(start = 5.dp) .fillMaxWidth() -val simpleModifier = Modifier - -val sizedModifier = Modifier.size(Size35dp) - val bottomPadding1dp = Modifier.padding(bottom = 1.dp) val commentTextSize = 12.sp @Composable -private fun AuthorPictureAndComment( +private fun DisplayAuthorCommentAndAmount( authorComment: MutableState, backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { Box(modifier = Size35Modifier, contentAlignment = Alignment.BottomCenter) { - DisplayPicture(authorComment, accountViewModel) - DisplayAmount(authorComment) + CrossfadeToDisplayPicture(authorComment, accountViewModel) + CrossfadeToDisplayAmount(authorComment) } - DisplayComment(authorComment, backgroundColor, nav, accountViewModel) + CrossfadeToDisplayComment(authorComment, backgroundColor, nav, accountViewModel) } @Composable -fun DisplayPicture(authorComment: MutableState, accountViewModel: AccountViewModel) { - authorComment.value.user?.let { - FastNoteAuthorPicture( - author = it, - size = Size35dp, - accountViewModel = accountViewModel, - pictureModifier = simpleModifier - ) +fun CrossfadeToDisplayPicture(authorComment: MutableState, accountViewModel: AccountViewModel) { + Crossfade(authorComment.value) { + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(it.user, accountViewModel) } } @Composable -fun DisplayAmount(authorComment: MutableState) { - authorComment.value.amount?.let { - Box( - modifier = amountBoxModifier, - contentAlignment = Alignment.BottomCenter - ) { - val backgroundColor = MaterialTheme.colors.overPictureBackground +fun CrossfadeToDisplayAmount(authorComment: MutableState) { + Crossfade(authorComment.value, modifier = amountBoxModifier) { + it.amount?.let { Box( - modifier = remember { - Modifier - .fillMaxWidth() - .drawBehind { drawRect(backgroundColor) } - }, + modifier = amountBoxModifier, contentAlignment = Alignment.BottomCenter ) { - Text( - text = it, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.secondaryVariant, - fontSize = commentTextSize, - modifier = bottomPadding1dp - ) + val backgroundColor = MaterialTheme.colors.overPictureBackground + Box( + modifier = remember { + Modifier + .width(Size35dp) + .drawBehind { drawRect(backgroundColor) } + }, + contentAlignment = Alignment.BottomCenter + ) { + Text( + text = it, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.secondaryVariant, + fontSize = commentTextSize, + modifier = bottomPadding1dp + ) + } } } } } @Composable -fun DisplayComment( +fun CrossfadeToDisplayComment( authorComment: MutableState, backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - authorComment.value.comment?.let { - TranslatableRichTextViewer( - content = it, - canPreview = true, - tags = remember { ImmutableListOfLists() }, - modifier = textBoxModifier, - backgroundColor = backgroundColor, - accountViewModel = accountViewModel, - nav = nav - ) + Crossfade(authorComment.value) { + it.comment?.let { + TranslatableRichTextViewer( + content = it, + canPreview = true, + tags = remember { ImmutableListOfLists() }, + modifier = textBoxModifier, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav + ) + } } } @@ -465,14 +483,13 @@ fun DisplayComment( @Composable fun AuthorGallery( authorNotes: ImmutableList, - backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { Column(modifier = StdStartPadding) { FlowRow() { authorNotes.forEach { note -> - BoxedAuthor(note, backgroundColor, nav, accountViewModel) + BoxedAuthor(note, nav, accountViewModel) } } } @@ -481,102 +498,78 @@ fun AuthorGallery( @Composable private fun BoxedAuthor( note: Note, - backgroundColor: MutableState, nav: (String) -> Unit, accountViewModel: AccountViewModel ) { - Box(sizedModifier) { - NotePictureAndComment(note, backgroundColor, nav, accountViewModel) + Box(modifier = Size35Modifier.clickable(onClick = { nav(authorRouteFor(note)) })) { + WatchNoteAuthor(note) { targetAuthor -> + Crossfade(targetState = targetAuthor, modifier = Size35Modifier) { author -> + WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor(author, accountViewModel) + } + } } } @Composable -private fun NotePictureAndComment( - baseNote: Note, - backgroundColor: MutableState, - nav: (String) -> Unit, +fun WatchUserMetadataAndFollowsAndRenderUserProfilePictureOrDefaultAuthor( + author: User?, accountViewModel: AccountViewModel ) { - val author = remember(baseNote) { - mutableStateOf( - ZapAmountCommentNotification( - user = baseNote.author, - comment = null, - amount = null + if (author != null) { + WatchUserMetadataAndFollowsAndRenderUserProfilePicture(author, accountViewModel) + } else { + DisplayBlankAuthor(Size35dp) + } +} + +@Composable +fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( + author: User, + accountViewModel: AccountViewModel +) { + WatchUserMetadata(author) { baseUserPicture -> + Crossfade(targetState = baseUserPicture) { userPicture -> + RobohashAsyncImageProxy( + robot = author.pubkeyHex, + model = userPicture, + contentDescription = stringResource(id = R.string.profile_image), + modifier = MaterialTheme.colors.profile35dpModifier, + contentScale = ContentScale.Crop ) - ) - } - - val modifier = remember(baseNote) { - Modifier.clickable { - nav("User/${baseNote.author?.pubkeyHex}") } } - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically - ) { - AuthorPictureAndComment(authorComment = author, backgroundColor, nav, accountViewModel) - } -} - -@Composable -fun FastNoteAuthorPicture( - author: User, - size: Dp, - pictureModifier: Modifier = Modifier, - accountViewModel: AccountViewModel -) { - val myBoxModifier = remember { - Modifier.size(size) - } - - Box(myBoxModifier, contentAlignment = Alignment.TopEnd) { - WatchMetadataAndRenderPictureWithFollwingMark(author, size, pictureModifier, accountViewModel) - } -} - -@Composable -private fun WatchMetadataAndRenderPictureWithFollwingMark( - author: User, - size: Dp, - pictureModifier: Modifier, - accountViewModel: AccountViewModel -) { - var profilePicture by remember { - mutableStateOf(author.info?.picture) - } - - val authorPubKey = remember { - author.pubkeyHex - } - - WatchUserMetadata(author) { - if (it.picture != profilePicture) { - profilePicture = it.picture - } - } - - PictureAndFollowingMark( - userHex = authorPubKey, - userPicture = profilePicture, - size = size, - modifier = pictureModifier, - accountViewModel = accountViewModel - ) -} - -@Composable -fun WatchUserMetadata(userBase: User, onMetadataChanges: (UserMetadata) -> Unit) { - val userState by userBase.live().metadata.observeAsState() - LaunchedEffect(key1 = userState) { - launch(Dispatchers.Default) { - userState?.user?.info?.let { - launch(Dispatchers.Main) { - onMetadataChanges(it) + WatchFollows(author.pubkeyHex, accountViewModel) { isFollowing -> + Crossfade(targetState = isFollowing) { + if (it) { + Box(modifier = Size35Modifier, contentAlignment = Alignment.TopEnd) { + FollowingIcon(Size10dp) } } } } } + +@Composable +private fun WatchNoteAuthor( + baseNote: Note, + onContent: @Composable (User?) -> Unit +) { + val author by baseNote.live().metadata.map { + it.note.author + }.observeAsState(baseNote.author) + + onContent(author) +} + +@Composable +private fun WatchUserMetadata( + author: User, + onNewMetadata: @Composable (String?) -> Unit +) { + val userProfile by author.live().metadata.map { + it.user.profilePicture() + }.distinctUntilChanged().observeAsState(author.profilePicture()) + + onNewMetadata(userProfile) +} 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 0bdd0c86f..ad7f77cee 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 @@ -605,7 +605,7 @@ fun InnerNoteWithReactions( val (value, elapsed) = measureTimedValue { AuthorAndRelayInformation(baseNote, accountViewModel, nav) } - println("AAA Rendering Auth $elapsed - ${baseNote.event?.content()?.take(10)}") + Log.d("Rendering Metrics", "Author: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } Spacer(modifier = DoubleHorzSpacer) } @@ -625,7 +625,7 @@ fun InnerNoteWithReactions( nav = nav ) } - println("AAA Rendering Body $elapsed - ${baseNote.event?.content()?.take(10)}") + Log.d("Rendering Metrics", "TextBody: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } } @@ -646,7 +646,7 @@ fun InnerNoteWithReactions( nav = nav ) } - println("AAA Rendering Reac $elapsed - ${baseNote.event?.content()?.take(10)}") + Log.d("Rendering Metrics", "Reaction: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } } @@ -830,6 +830,14 @@ fun routeFor(note: Note, loggedIn: User): String? { return null } +fun routeFor(user: User): String { + return "User/${user.pubkeyHex}" +} + +fun authorRouteFor(note: Note): String { + return "User/${note.author?.pubkeyHex}" +} + @Composable fun RenderTextEvent( note: Note, @@ -3036,7 +3044,7 @@ fun NoteAuthorPicture( } @Composable -private fun DisplayBlankAuthor(size: Dp, modifier: Modifier) { +fun DisplayBlankAuthor(size: Dp, modifier: Modifier = Modifier) { val backgroundColor = MaterialTheme.colors.background val nullModifier = remember { @@ -3161,16 +3169,14 @@ fun BaseUserPicture( Modifier.size(size) } - Crossfade(targetState = userProfile) { - Box(myBoxModifier, contentAlignment = TopEnd) { - PictureAndFollowingMark( - userHex = userPubkey, - userPicture = it, - size = size, - modifier = modifier, - accountViewModel = accountViewModel - ) - } + Box(myBoxModifier, contentAlignment = TopEnd) { + PictureAndFollowingMark( + userHex = userPubkey, + userPicture = userProfile, + size = size, + modifier = modifier, + accountViewModel = accountViewModel + ) } } @@ -3207,50 +3213,56 @@ fun PictureAndFollowingMark( } @Composable -private fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) { - val showFollowingMark by accountViewModel.userFollows.map { - it.user.isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex) - }.distinctUntilChanged().observeAsState( - accountViewModel.account.userProfile().isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex) - ) - - Crossfade(targetState = showFollowingMark) { - if (it) { - Box(contentAlignment = TopEnd) { - FollowingIcon(iconSize) +fun ObserveAndDisplayFollowingMark(userHex: String, iconSize: Dp, accountViewModel: AccountViewModel) { + WatchFollows(userHex, accountViewModel) { + Crossfade(targetState = it) { + if (it) { + Box(contentAlignment = TopEnd) { + FollowingIcon(iconSize) + } } } } } @Composable -private fun FollowingIcon(iconSize: Dp) { - val myIconBoxModifier = remember { +fun WatchFollows(userHex: String, accountViewModel: AccountViewModel, onFollowChanges: @Composable (Boolean) -> Unit) { + val showFollowingMark by accountViewModel.userFollows.map { + it.user.isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex) + }.distinctUntilChanged().observeAsState( + accountViewModel.account.userProfile().isFollowingCached(userHex) || (userHex == accountViewModel.account.userProfile().pubkeyHex) + ) + + onFollowChanges(showFollowingMark) +} + +@Composable +fun FollowingIcon(iconSize: Dp) { + val modifier = remember { Modifier.size(iconSize) } - Box(myIconBoxModifier, contentAlignment = Alignment.Center) { - val backgroundColor = MaterialTheme.colors.background + val backgroundColor = MaterialTheme.colors.background - val myIconBackgroundModifier = remember { - Modifier - .clip(CircleShape) - .size(iconSize.times(0.6f)) - .drawBehind { drawRect(backgroundColor) } - } + val myIconBackgroundModifier = remember { + Modifier + .clip(CircleShape) + .size(iconSize.times(0.6f)) + .drawBehind { drawRect(backgroundColor) } + } - Box( - myIconBackgroundModifier - ) + FollowingIcon(modifier, myIconBackgroundModifier) +} - val myIconModifier = remember { - Modifier.size(iconSize) - } +@Composable +fun FollowingIcon(modifier: Modifier, myIconBackgroundModifier: Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Box(myIconBackgroundModifier) Icon( painter = painterResource(R.drawable.ic_verified), stringResource(id = R.string.following), - modifier = myIconModifier, + modifier = modifier, tint = Following ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 3955a39ea..2d8494175 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -419,7 +419,6 @@ private fun ReactionDetailGallery( if (hasBoostEvents) { RenderBoostGallery( boostEvents, - backgroundColor, nav, accountViewModel ) @@ -431,7 +430,6 @@ private fun ReactionDetailGallery( RenderLikeGallery( it.key, reactions, - backgroundColor, nav, accountViewModel ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt index 2178c2112..8012f4b8e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/ChatroomListFeedView.kt @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen +import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.PaddingValues @@ -18,6 +19,8 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.ui.note.ChatroomCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue @Composable fun ChatroomListFeedView( @@ -68,6 +71,7 @@ private fun CrossFadeState( } } +@OptIn(ExperimentalTime::class) @Composable private fun FeedLoaded( state: FeedState.Loaded, @@ -108,11 +112,14 @@ private fun FeedLoaded( key = { index, item -> if (index == 0) index else item.idHex } ) { _, item -> Row(Modifier.fillMaxWidth()) { - ChatroomCompose( - item, - accountViewModel = accountViewModel, - nav = nav - ) + val (value, elapsed) = measureTimedValue { + ChatroomCompose( + item, + accountViewModel = accountViewModel, + nav = nav + ) + } + Log.d("Rendering Metrics", "Chat Header Complete: ${item.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } } } 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 7ab564734..0d14e3596 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 @@ -1,5 +1,6 @@ package com.vitorpamplona.amethyst.ui.screen +import android.util.Log import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -215,7 +216,7 @@ private fun FeedLoaded( } } - println("AAA Rendering $elapsed - ${item.event?.content()?.take(10)}") + Log.d("Rendering Metrics", "Complete: ${item.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed") } } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt index fa295db61..3a6d4def2 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt @@ -34,8 +34,8 @@ import com.halilibo.richtext.markdown.Markdown import com.halilibo.richtext.ui.material.MaterialRichText import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.actions.CloseButton -import com.vitorpamplona.amethyst.ui.components.richTextDefaults import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults import com.vitorpamplona.amethyst.ui.theme.placeholderText import kotlinx.coroutines.launch @@ -86,8 +86,8 @@ fun ConnectOrbotDialog(onClose: () -> Unit, onPost: () -> Unit, portNumber: Muta Column( modifier = Modifier.padding(30.dp) ) { - val myMarkDownStyle = richTextDefaults.copy( - stringStyle = richTextDefaults.stringStyle?.copy( + val myMarkDownStyle = RichTextDefaults.copy( + stringStyle = RichTextDefaults.stringStyle?.copy( linkStyle = SpanStyle( textDecoration = TextDecoration.Underline, color = MaterialTheme.colors.primary diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index e158a799a..f15a27e18 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Shapes @@ -20,6 +21,8 @@ val Shapes = Shapes( large = RoundedCornerShape(0.dp) ) +val RippleRadius45dp = 45.dp // Ripple should be +10.dp over the component size + val SmallBorder = RoundedCornerShape(7.dp) val QuoteBorder = RoundedCornerShape(15.dp) val ButtonBorder = RoundedCornerShape(20.dp) @@ -40,6 +43,7 @@ val DoubleVertSpacer = Modifier.height(10.dp) val HalfDoubleVertSpacer = Modifier.height(7.dp) val Size0dp = 0.dp +val Size10dp = 10.dp val Size13dp = 13.dp val Size15dp = 15.dp val Size16dp = 16.dp @@ -56,15 +60,20 @@ val Size75dp = 75.dp val HalfStartPadding = Modifier.padding(start = 5.dp) val StdStartPadding = Modifier.padding(start = 10.dp) +val StdTopPadding = Modifier.padding(top = 10.dp) val HalfPadding = Modifier.padding(5.dp) val StdPadding = Modifier.padding(10.dp) +val Size6Modifier = Modifier.size(6.dp) +val Size10Modifier = Modifier.size(10.dp) val Size15Modifier = Modifier.size(15.dp) +val Size18Modifier = Modifier.size(18.dp) val Size20Modifier = Modifier.size(20.dp) val Size22Modifier = Modifier.size(22.dp) val Size24Modifier = Modifier.size(24.dp) val Size30Modifier = Modifier.size(30.dp) +val Size35Modifier = Modifier.size(35.dp) val Size55Modifier = Modifier.size(55.dp) val TinyBorders = Modifier.padding(2.dp) @@ -93,3 +102,9 @@ val ShowMoreRelaysButtonBoxModifer = Modifier.fillMaxWidth().height(25.dp) val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f) val ModifierWidth3dp = Modifier.width(3.dp) + +val NotificationIconModifier = Modifier.width(55.dp).padding(end = 5.dp) +val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp) + +val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp) +val ChatHeadlineBorders = Modifier.padding(start = 12.dp, end = 12.dp, top = 10.dp) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index c4255fa00..b3ccb57d0 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.Colors import androidx.compose.material.MaterialTheme @@ -25,9 +26,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.halilibo.richtext.ui.RichTextStyle -import com.vitorpamplona.amethyst.ui.components.DefaultHeadingStyle -import com.vitorpamplona.amethyst.ui.components.DefaultParagraphSpacing -import com.vitorpamplona.amethyst.ui.components.richTextDefaults +import com.halilibo.richtext.ui.resolveDefaults private val DarkColorPalette = darkColors( primary = Purple200, @@ -121,6 +120,16 @@ val LightImageModifier = Modifier QuoteBorder ) +val DarkProfile35dpModifier = Modifier + .size(Size35dp) + .clip(shape = CircleShape) + .drawBehind { drawRect(DarkColorPalette.background) } + +val LightProfile35dpModifier = Modifier + .fillMaxWidth() + .clip(shape = CircleShape) + .drawBehind { drawRect(LightColorPalette.background) } + val DarkReplyBorderModifier = Modifier .padding(top = 5.dp) .fillMaxWidth() @@ -161,10 +170,12 @@ val LightInnerPostBorderModifier = Modifier QuoteBorder ) -val MarkDownStyleOnDark = richTextDefaults.copy( +val RichTextDefaults = RichTextStyle().resolveDefaults() + +val MarkDownStyleOnDark = RichTextDefaults.copy( paragraphSpacing = DefaultParagraphSpacing, headingStyle = DefaultHeadingStyle, - codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( + codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy( textStyle = TextStyle( fontFamily = FontFamily.Monospace, fontSize = Font14SP @@ -182,7 +193,7 @@ val MarkDownStyleOnDark = richTextDefaults.copy( drawRect(DarkColorPalette.onSurface.copy(alpha = 0.05f)) } ), - stringStyle = richTextDefaults.stringStyle?.copy( + stringStyle = RichTextDefaults.stringStyle?.copy( linkStyle = SpanStyle( color = DarkColorPalette.primary ), @@ -194,10 +205,10 @@ val MarkDownStyleOnDark = richTextDefaults.copy( ) ) -val MarkDownStyleOnLight = richTextDefaults.copy( +val MarkDownStyleOnLight = RichTextDefaults.copy( paragraphSpacing = DefaultParagraphSpacing, headingStyle = DefaultHeadingStyle, - codeBlockStyle = richTextDefaults.codeBlockStyle?.copy( + codeBlockStyle = RichTextDefaults.codeBlockStyle?.copy( textStyle = TextStyle( fontFamily = FontFamily.Monospace, fontSize = Font14SP @@ -215,7 +226,7 @@ val MarkDownStyleOnLight = richTextDefaults.copy( drawRect(LightColorPalette.onSurface.copy(alpha = 0.05f)) } ), - stringStyle = richTextDefaults.stringStyle?.copy( + stringStyle = RichTextDefaults.stringStyle?.copy( linkStyle = SpanStyle( color = LightColorPalette.primary ), @@ -283,6 +294,9 @@ val Colors.repostProfileBorder: Modifier val Colors.imageModifier: Modifier get() = if (isLight) LightImageModifier else DarkImageModifier +val Colors.profile35dpModifier: Modifier + get() = if (isLight) LightProfile35dpModifier else DarkProfile35dpModifier + val Colors.replyModifier: Modifier get() = if (isLight) LightReplyBorderModifier else DarkReplyBorderModifier diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index 75b1a89a2..57e8ed17f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -4,8 +4,10 @@ import androidx.compose.material.Typography import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp +import com.halilibo.richtext.ui.HeadingStyle // Set of Material typography styles to start with val Typography = Typography( @@ -29,5 +31,37 @@ val Typography = Typography( ) val Font14SP = 14.sp +val Font17SP = 17.sp val MarkdownTextStyle = TextStyle(lineHeight = 1.30.em) + +val DefaultParagraphSpacing: TextUnit = 12.sp + +internal val DefaultHeadingStyle: HeadingStyle = { level, textStyle -> + when (level) { + 0 -> textStyle.copy( + fontSize = 30.sp, + fontWeight = FontWeight.Light + ) + 1 -> textStyle.copy( + fontSize = 26.sp, + fontWeight = FontWeight.Light + ) + 2 -> textStyle.copy( + fontSize = 22.sp, + fontWeight = FontWeight.Light + ) + 3 -> textStyle.copy( + fontSize = 20.sp, + fontWeight = FontWeight.Bold + ) + 4 -> textStyle.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + 5 -> textStyle.copy( + fontWeight = FontWeight.Bold + ) + else -> textStyle + } +} 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 b937b011d..44c701151 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 @@ -12,6 +12,8 @@ import com.google.mlkit.nl.translate.Translator import com.google.mlkit.nl.translate.TranslatorOptions import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions +import com.vitorpamplona.amethyst.service.checkNotInMainThread +import java.util.concurrent.Executors import java.util.regex.Pattern @Immutable @@ -22,7 +24,9 @@ data class ResultOrError( ) object LanguageTranslatorService { - private val options = LanguageIdentificationOptions.Builder().setConfidenceThreshold(0.6f).build() + var executorService = Executors.newScheduledThreadPool(5) + + private val options = LanguageIdentificationOptions.Builder().setExecutor(executorService).setConfidenceThreshold(0.6f).build() private val languageIdentification = LanguageIdentification.getClient(options) val lnRegex = Pattern.compile("\\blnbc[a-z0-9]+\\b", Pattern.CASE_INSENSITIVE) val tagRegex = Pattern.compile("(nostr:)?@?(nsec1|npub1|nevent1|naddr1|note1|nprofile1|nrelay1)([qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)", Pattern.CASE_INSENSITIVE) @@ -48,6 +52,7 @@ object LanguageTranslatorService { } fun translate(text: String, source: String, target: String): Task { + checkNotInMainThread() val sourceLangCode = TranslateLanguage.fromLanguageTag(source) val targetLangCode = TranslateLanguage.fromLanguageTag(target) @@ -62,19 +67,22 @@ object LanguageTranslatorService { val translator = translators[options] - return translator.downloadModelIfNeeded().onSuccessTask { - val tasks = mutableListOf>() + return translator.downloadModelIfNeeded().onSuccessTask(executorService) { + checkNotInMainThread() + val tasks = mutableListOf>() val dict = lnDictionary(text) + urlDictionary(text) + tagDictionary(text) for (paragraph in encodeDictionary(text, dict).split("\n")) { tasks.add(translator.translate(paragraph)) } - Tasks.whenAll(tasks).continueWith { + Tasks.whenAll(tasks).continueWith(executorService) { + checkNotInMainThread() + val results: MutableList = ArrayList() for (task in tasks) { - var fixedText = task.result.replace("# [", "#[") // fixes tags that always return with a space + val fixedText = task.result.replace("# [", "#[") // fixes tags that always return with a space results.add(decodeDictionary(fixedText, dict)) } ResultOrError(results.joinToString("\n"), source, target) @@ -140,7 +148,7 @@ object LanguageTranslatorService { } fun autoTranslate(text: String, dontTranslateFrom: Set, translateTo: String): Task { - return identifyLanguage(text).onSuccessTask { + return identifyLanguage(text).onSuccessTask(executorService) { if (it == translateTo) { Tasks.forCanceled() } else if (it != "und" && !dontTranslateFrom.contains(it)) { 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 110bcbc03..6a2dd8e4a 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 @@ -1,6 +1,7 @@ package com.vitorpamplona.amethyst.ui.components import android.content.res.Resources +import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Divider @@ -13,6 +14,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.derivedStateOf @@ -33,7 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.core.os.ConfigurationCompat import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService -import com.vitorpamplona.amethyst.service.lang.ResultOrError import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.lessImportantLink @@ -41,6 +42,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.Locale +@Immutable +data class TranslationConfig( + val result: String?, + val sourceLang: String?, + val targetLang: String?, + val showOriginal: Boolean +) + @Composable fun TranslatableRichTextViewer( content: String, @@ -51,32 +60,53 @@ fun TranslatableRichTextViewer( accountViewModel: AccountViewModel, nav: (String) -> Unit ) { + var translatedTextState by remember { + mutableStateOf(TranslationConfig(content, null, null, false)) + } + + TranslateAndWatchLanguageChanges(content, accountViewModel) { result -> + if (!translatedTextState.result.equals(result.result, true) || + translatedTextState.sourceLang != result.sourceLang || + translatedTextState.targetLang != result.targetLang + ) { + translatedTextState = result + } + } + + Crossfade(targetState = translatedTextState) { + RenderText( + it, + content, + canPreview, + modifier, + tags, + backgroundColor, + accountViewModel, + nav + ) + } +} + +@Composable +private fun RenderText( + translatedTextState: TranslationConfig, + content: String, + canPreview: Boolean, + modifier: Modifier, + tags: ImmutableListOfLists, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: (String) -> Unit +) { + var showOriginal by remember(translatedTextState) { mutableStateOf(translatedTextState.showOriginal) } + + val toBeViewed by remember(translatedTextState) { + derivedStateOf { + if (showOriginal) content else translatedTextState.result ?: content + } + } + Column { - var translatedTextState by remember { - mutableStateOf(ResultOrError(content, null, null)) - } - - var showOriginal by remember { mutableStateOf(false) } - - TranslateAndWatchLanguageChanges(content, accountViewModel) { result, newShowOriginal -> - if (!translatedTextState.result.equals(result.result, true) || - translatedTextState.sourceLang != result.sourceLang || - translatedTextState.targetLang != result.targetLang - ) { - translatedTextState = result - } - - if (showOriginal != newShowOriginal) { - showOriginal = newShowOriginal - } - } - - val toBeViewed by remember(translatedTextState) { - derivedStateOf { - if (showOriginal) content else translatedTextState.result ?: content - } - } - ExpandableRichTextViewer( toBeViewed, canPreview, @@ -92,8 +122,8 @@ fun TranslatableRichTextViewer( translatedTextState.sourceLang != translatedTextState.targetLang ) { TranslationMessage( - translatedTextState.sourceLang!!, - translatedTextState.targetLang!!, + translatedTextState.sourceLang, + translatedTextState.targetLang, accountViewModel ) { showOriginal = it @@ -280,25 +310,30 @@ private fun TranslationMessage( } @Composable -fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (ResultOrError, Boolean) -> Unit) { +fun TranslateAndWatchLanguageChanges(content: String, accountViewModel: AccountViewModel, onTranslated: (TranslationConfig) -> Unit) { val accountState by accountViewModel.accountLanguagesLiveData.observeAsState() val account = remember(accountState) { accountState?.account } ?: return - val scope = rememberCoroutineScope() - LaunchedEffect(accountState) { - scope.launch(Dispatchers.IO) { + launch(Dispatchers.IO) { LanguageTranslatorService.autoTranslate( content, account.dontTranslateFrom, account.translateTo ).addOnCompleteListener { task -> - if (task.isSuccessful && content != task.result.result) { + if (task.isSuccessful && !content.equals(task.result.result, true)) { if (task.result.sourceLang != null && task.result.targetLang != null) { val preference = account.preferenceBetween(task.result.sourceLang!!, task.result.targetLang!!) - launch(Dispatchers.Main) { - onTranslated(task.result, preference == task.result.sourceLang) - } + val newConfig = TranslationConfig( + result = task.result.result, + sourceLang = task.result.sourceLang, + targetLang = task.result.targetLang, + showOriginal = preference == task.result.sourceLang + ) + + // withContext(Dispatchers.Main) { + onTranslated(newConfig) + // } } } }