From d32d2da280bf90e2fe6ccc97b7d48b03331c9dc0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 5 Jun 2023 18:19:23 -0400 Subject: [PATCH] Increases the speed of the Zap Tab in Profiles. --- .../amethyst/service/model/zaps/UserZaps.kt | 14 ++- .../amethyst/ui/actions/NewPostView.kt | 13 +- .../amethyst/ui/components/ClickableRoute.kt | 25 ++-- .../amethyst/ui/components/RichTextViewer.kt | 101 ++++++++++----- .../ui/dal/UserProfileZapsFeedFilter.kt | 14 +-- .../ui/navigation/AccountSwitchBottomSheet.kt | 3 +- .../amethyst/ui/navigation/DrawerContent.kt | 7 +- .../ui/note/ChatroomMessageCompose.kt | 24 ++-- .../amethyst/ui/note/NoteCompose.kt | 9 +- .../amethyst/ui/note/ReplyInformation.kt | 10 +- .../amethyst/ui/note/UsernameDisplay.kt | 10 +- .../amethyst/ui/note/ZapNoteCompose.kt | 117 ++++++++++++------ .../amethyst/ui/qrcode/ShowQRDialog.kt | 3 +- .../amethyst/ui/screen/LnZapFeedState.kt | 9 +- .../amethyst/ui/screen/LnZapFeedView.kt | 70 +++-------- .../amethyst/ui/screen/LnZapFeedViewModel.kt | 21 +++- .../ui/screen/loggedIn/ProfileScreen.kt | 29 +++-- 17 files changed, 275 insertions(+), 204 deletions(-) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt index a7e68b29c..a943797ac 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/model/zaps/UserZaps.kt @@ -2,17 +2,21 @@ package com.vitorpamplona.amethyst.service.model.zaps import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.model.LnZapEventInterface +import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse object UserZaps { - fun forProfileFeed(zaps: Map?): List> { + fun forProfileFeed(zaps: Map?): List { if (zaps == null) return emptyList() return ( zaps - .filter { it.value != null } - .toList() - .sortedBy { (it.second?.event as? LnZapEventInterface)?.amount() } + .mapNotNull { entry -> + entry.value?.let { + ZapReqResponse(entry.key, it) + } + } + .sortedBy { (it.zapEvent.event as? LnZapEventInterface)?.amount() } .reversed() - ) as List> + ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt index 8dc648cb9..a41b2ae97 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostView.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner import com.vitorpamplona.amethyst.ui.screen.loggedIn.UserLine import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -385,11 +386,13 @@ fun Notifying(baseMentions: List?, onClick: (User) -> Unit) { mentions.forEachIndexed { idx, user -> val innerUserState by user.live().metadata.observeAsState() - val innerUser = innerUserState?.user - - innerUser?.let { myUser -> + innerUserState?.user?.let { myUser -> Spacer(modifier = Modifier.width(5.dp)) + val tags = remember(innerUserState) { + myUser.info?.latestMetadata?.tags?.toImmutableList() + } + Button( shape = RoundedCornerShape(20.dp), colors = ButtonDefaults.buttonColors( @@ -400,8 +403,8 @@ fun Notifying(baseMentions: List?, onClick: (User) -> Unit) { } ) { CreateTextWithEmoji( - text = "✖ ${myUser.toBestDisplayName()}", - tags = myUser.info?.latestMetadata?.tags, + text = remember(innerUserState) { "✖ ${myUser.toBestDisplayName()}" }, + tags = tags, color = Color.White, textAlign = TextAlign.Center ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index c46cf3518..e8fc92540 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -13,6 +13,7 @@ import androidx.compose.material.LocalTextStyle import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -48,6 +49,7 @@ import com.vitorpamplona.amethyst.service.model.PrivateDmEvent import com.vitorpamplona.amethyst.service.nip19.Nip19 import com.vitorpamplona.amethyst.ui.note.LoadChannel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -269,7 +271,7 @@ private fun DisplayUser( val userState by it.live().metadata.observeAsState() val route = remember(userState) { "User/${it.pubkeyHex}" } val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() } val addedCharts = remember { "${nip19.additionalChars} " } @@ -323,7 +325,7 @@ fun CreateClickableText( @Composable fun CreateTextWithEmoji( text: String, - tags: List>?, + tags: ImmutableList>?, color: Color = Color.Unspecified, textAlign: TextAlign? = null, fontWeight: FontWeight? = null, @@ -382,7 +384,7 @@ fun CreateTextWithEmoji( @Composable fun CreateTextWithEmoji( text: String, - emojis: Map, + emojis: ImmutableMap, color: Color = Color.Unspecified, textAlign: TextAlign? = null, fontWeight: FontWeight? = null, @@ -438,7 +440,7 @@ fun CreateTextWithEmoji( @Composable fun CreateClickableTextWithEmoji( clickablePart: String, - tags: List>?, + tags: ImmutableList>?, style: TextStyle, onClick: (Int) -> Unit ) { @@ -475,7 +477,7 @@ fun CreateClickableTextWithEmoji( fun CreateClickableTextWithEmoji( clickablePart: String, suffix: String, - tags: List>?, + tags: ImmutableList>?, overrideColor: Color? = null, fontWeight: FontWeight = FontWeight.Normal, route: String, @@ -512,7 +514,7 @@ fun CreateClickableTextWithEmoji( } } -suspend fun assembleAnnotatedList(text: String, emojis: Map): List { +suspend fun assembleAnnotatedList(text: String, emojis: Map): ImmutableList { return NIP30Parser().buildArray(text).map { val url = emojis[it] if (url != null) { @@ -520,15 +522,20 @@ suspend fun assembleAnnotatedList(text: String, emojis: Map): Li } else { TextType(it) } - } + }.toImmutableList() } +@Immutable open class Renderable() + +@Immutable class TextType(val text: String) : Renderable() + +@Immutable class ImageUrlType(val url: String) : Renderable() @Composable -fun ClickableInLineIconRenderer(wordsInOrder: List, style: SpanStyle, onClick: (Int) -> Unit) { +fun ClickableInLineIconRenderer(wordsInOrder: ImmutableList, style: SpanStyle, onClick: (Int) -> Unit) { val inlineContent = wordsInOrder.mapIndexedNotNull { idx, value -> if (value is ImageUrlType) { Pair( @@ -589,7 +596,7 @@ fun ClickableInLineIconRenderer(wordsInOrder: List, style: SpanStyle @Composable fun InLineIconRenderer( - wordsInOrder: List, + wordsInOrder: ImmutableList, style: SpanStyle, maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, 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 4a2cdd2c9..12ea94da0 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 @@ -702,55 +702,90 @@ fun startsWithNIP19Scheme(word: String): Boolean { return listOf("npub1", "naddr1", "note1", "nprofile1", "nevent1").any { cleaned.startsWith(it) } } +@Immutable +data class LoadedBechLink(val baseNote: Note?, val nip19: Nip19.Return) + @Composable fun BechLink(word: String, canPreview: Boolean, backgroundColor: Color, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - var nip19Route by remember { mutableStateOf(null) } - var baseNotePair by remember { mutableStateOf?>(null) } + var loadedLink by remember { mutableStateOf(null) } LaunchedEffect(key1 = word) { launch(Dispatchers.IO) { Nip19.uriToRoute(word)?.let { + var returningNote: Note? = null if (it.type == Nip19.Type.NOTE || it.type == Nip19.Type.EVENT || it.type == Nip19.Type.ADDRESS) { LocalCache.checkGetOrCreateNote(it.hex)?.let { note -> - baseNotePair = Pair(note, it.additionalChars) + returningNote = note } } - nip19Route = it + loadedLink = LoadedBechLink(returningNote, it) } } } if (canPreview) { - baseNotePair?.let { - NoteCompose( - baseNote = it.first, - accountViewModel = accountViewModel, - modifier = Modifier - .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) - .fillMaxWidth() - .clip(shape = RoundedCornerShape(15.dp)) - .border( - 1.dp, - MaterialTheme.colors.onSurface.copy(alpha = 0.12f), - RoundedCornerShape(15.dp) - ), - parentBackgroundColor = backgroundColor, - isQuotedNote = true, - nav = nav - ) - if (!it.second.isNullOrEmpty()) { - Text( - "${it.second} " - ) + loadedLink?.let { loadedLink -> + loadedLink.baseNote?.let { + DisplayFullNote(it, accountViewModel, backgroundColor, nav, loadedLink) + } ?: run { + ClickableRoute(loadedLink.nip19, nav) } - } ?: nip19Route?.let { - ClickableRoute(it, nav) - } ?: Text(text = "$word ") + } ?: run { + Text(text = remember { "$word " }) + } } else { - nip19Route?.let { - ClickableRoute(it, nav) - } ?: Text(text = "$word ") + loadedLink?.let { + ClickableRoute(it.nip19, nav) + } ?: run { + Text(text = remember { "$word " }) + } + } +} + +@Composable +private fun DisplayFullNote( + it: Note, + accountViewModel: AccountViewModel, + backgroundColor: Color, + nav: (String) -> Unit, + loadedLink: LoadedBechLink +) { + val borderColor = MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + + val modifier = remember { + Modifier + .padding(top = 2.dp, bottom = 0.dp, start = 0.dp, end = 0.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(15.dp)) + .border( + 1.dp, + borderColor, + RoundedCornerShape(15.dp) + ) + } + + NoteCompose( + baseNote = it, + accountViewModel = accountViewModel, + modifier = modifier, + parentBackgroundColor = backgroundColor, + isQuotedNote = true, + nav = nav + ) + + val extraChars = remember(loadedLink) { + if (loadedLink.nip19.additionalChars.isNotBlank()) { + "${loadedLink.nip19.additionalChars} " + } else { + null + } + } + + extraChars?.let { + Text( + it + ) } } @@ -876,12 +911,12 @@ fun TagLink(word: String, tags: List>, canPreview: Boolean, backgro "User/${it.first.pubkeyHex}" } val userTags = remember(innerUserState) { - innerUserState?.user?.info?.latestMetadata?.tags + innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() } CreateClickableTextWithEmoji( clickablePart = displayName, - suffix = "${it.second} ", + suffix = remember { "${it.second} " }, tags = userTags, route = route, nav = nav diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt index 70cb680ed..e73b3f0d6 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/dal/UserProfileZapsFeedFilter.kt @@ -1,17 +1,11 @@ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.zaps.UserZaps +import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse -object UserProfileZapsFeedFilter : FeedFilter>() { - var user: User? = null - - fun loadUserProfile(user: User?) { - this.user = user - } - - override fun feed(): List> { - return UserZaps.forProfileFeed(user?.zaps) +class UserProfileZapsFeedFilter(val user: User) : FeedFilter() { + override fun feed(): List { + return UserZaps.forProfileFeed(user.zaps) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt index 419da014b..8f34a3925 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AccountSwitchBottomSheet.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.note.toShortenHex import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginPage +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -227,7 +228,7 @@ private fun AccountName( } val tags by remember(userState) { derivedStateOf { - user.info?.latestMetadata?.tags + user.info?.latestMetadata?.tags?.toImmutableList() } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index ec81626ec..73e14e18d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -128,7 +129,7 @@ fun ProfileContent( val profilePicture = remember(accountUserState) { accountUserState?.user?.profilePicture()?.ifBlank { null }?.let { ResizeImage(it, 100.dp) } } val bestUserName = remember(accountUserState) { accountUserState?.user?.bestUsername() } val bestDisplayName = remember(accountUserState) { accountUserState?.user?.bestDisplayName() } - val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags } + val tags = remember(accountUserState) { accountUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() } val route = remember(accountUserState) { "User/${accountUserState?.user?.pubkeyHex}" } Box { @@ -193,8 +194,8 @@ fun ProfileContent( } if (bestUserName != null) { CreateTextWithEmoji( - text = " @$bestUserName", - tags = accountUser.info?.latestMetadata?.tags, + text = remember { " @$bestUserName" }, + tags = tags, color = Color.LightGray, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt index 67a30bea1..77df4534d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ChatroomMessageCompose.kt @@ -418,7 +418,7 @@ private fun RenderChangeChannelMetadataNote( CreateTextWithEmoji( text = text, - tags = note.author?.info?.latestMetadata?.tags + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableList() } ) } @@ -441,7 +441,7 @@ private fun RenderCreateChannelNote(note: Note) { CreateTextWithEmoji( text = text, - tags = note.author?.info?.latestMetadata?.tags + tags = remember { note.author?.info?.latestMetadata?.tags?.toImmutableList() } ) } @@ -457,7 +457,7 @@ private fun DrawAuthorInfo( val route = remember { "User/$pubkeyHex" } val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } val userProfilePicture = remember(userState) { ResizeImage(userState?.user?.profilePicture(), 25.dp) } - val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() } Row( verticalAlignment = Alignment.CenterVertically, @@ -468,17 +468,19 @@ private fun DrawAuthorInfo( robot = pubkeyHex, model = userProfilePicture, contentDescription = stringResource(id = R.string.profile_image), - modifier = Modifier - .width(25.dp) - .height(25.dp) - .clip(shape = CircleShape) - .clickable(onClick = { - nav(route) - }) + modifier = remember { + Modifier + .width(25.dp) + .height(25.dp) + .clip(shape = CircleShape) + .clickable(onClick = { + nav(route) + }) + } ) CreateClickableTextWithEmoji( - clickablePart = " $userDisplayName", + clickablePart = remember { " $userDisplayName" }, suffix = "", tags = userTags, fontWeight = FontWeight.Bold, 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 a13a8f2a8..af796c467 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 @@ -792,18 +792,19 @@ fun RenderAppDefinition( } } - it.anyName()?.let { + val name = remember(it) { it.anyName() } + name?.let { Row(verticalAlignment = Alignment.Bottom, modifier = Modifier.padding(top = 7.dp)) { CreateTextWithEmoji( text = it, - tags = remember { note.event?.tags() ?: emptyList() }, + tags = remember { (note.event?.tags() ?: emptyList()).toImmutableList() }, fontWeight = FontWeight.Bold, fontSize = 25.sp ) } } - val website = it.website + val website = remember(it) { it.website } if (!website.isNullOrEmpty()) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( @@ -1717,7 +1718,7 @@ fun DisplayHighlight( val userState by userBase.live().metadata.observeAsState() val route = remember { "User/${userBase.pubkeyHex}" } val userDisplayName = remember(userState) { userState?.user?.toBestDisplayName() } - val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val userTags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() } if (userDisplayName != null) { CreateClickableTextWithEmoji( diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 39c97f7cb..01f61574a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -20,6 +20,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.* import com.vitorpamplona.amethyst.ui.components.CreateClickableTextWithEmoji import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -231,17 +232,14 @@ private fun ReplyInfoMention( onUserTagClick: (User) -> Unit ) { val innerUserState by user.live().metadata.observeAsState() - val innerUser = remember(innerUserState) { - innerUserState?.user - } ?: return CreateClickableTextWithEmoji( - clickablePart = "$prefix${innerUser.toBestDisplayName()}", - tags = innerUser.info?.latestMetadata?.tags, + clickablePart = remember(innerUserState) { "$prefix${innerUserState?.user?.toBestDisplayName()}" }, + tags = remember(innerUserState) { innerUserState?.user?.info?.latestMetadata?.tags?.toImmutableList() }, style = LocalTextStyle.current.copy( color = MaterialTheme.colors.primary.copy(alpha = 0.52f), fontSize = 13.sp ), - onClick = { onUserTagClick(innerUser) } + onClick = { onUserTagClick(user) } ) } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 000e6fc40..97b6492cc 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -12,6 +12,8 @@ import androidx.compose.ui.text.style.TextOverflow import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList @Composable fun NoteUsernameDisplay(baseNote: Note, weight: Modifier = Modifier) { @@ -29,7 +31,7 @@ fun UsernameDisplay(baseUser: User, weight: Modifier = Modifier) { val bestUserName = remember(userState) { userState?.user?.bestUsername() } val bestDisplayName = remember(userState) { userState?.user?.bestDisplayName() } val npubDisplay = remember { baseUser.pubkeyDisplayHex() } - val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() } UserNameDisplay(bestUserName, bestDisplayName, npubDisplay, tags, weight) } @@ -39,7 +41,7 @@ private fun UserNameDisplay( bestUserName: String?, bestDisplayName: String?, npubDisplay: String, - tags: List>?, + tags: ImmutableList>?, modifier: Modifier ) { if (bestUserName != null && bestDisplayName != null) { @@ -49,7 +51,7 @@ private fun UserNameDisplay( fontWeight = FontWeight.Bold ) CreateTextWithEmoji( - text = "@$bestUserName", + text = remember { "@$bestUserName" }, tags = tags, color = MaterialTheme.colors.onSurface.copy(alpha = 0.32f), maxLines = 1, @@ -67,7 +69,7 @@ private fun UserNameDisplay( ) } else if (bestUserName != null) { CreateTextWithEmoji( - text = "@$bestUserName", + text = remember { "@$bestUserName" }, tags = tags, fontWeight = FontWeight.Bold, maxLines = 1, diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt index f07f795e5..1302e374e 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapNoteCompose.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.model.LnZapEvent import com.vitorpamplona.amethyst.service.model.LnZapRequestEvent +import com.vitorpamplona.amethyst.ui.screen.ZapReqResponse import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.FollowButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.ShowUserButton @@ -38,37 +39,44 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun ZapNoteCompose(baseNote: Pair, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val baseNoteRequest by baseNote.first.live().metadata.observeAsState() - val noteZapRequest = remember(baseNoteRequest) { baseNoteRequest?.note } ?: return +fun ZapNoteCompose(baseReqResponse: ZapReqResponse, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + val baseNoteRequest by baseReqResponse.request.live().metadata.observeAsState() var baseAuthor by remember { - mutableStateOf(noteZapRequest.author) + mutableStateOf(null) + } + + LaunchedEffect(baseNoteRequest) { + launch(Dispatchers.Default) { + (baseNoteRequest?.note?.event as? LnZapRequestEvent)?.let { + baseNoteRequest?.note?.let { + val decryptedContent = accountViewModel.decryptZap(it) + if (decryptedContent != null) { + baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) + } else { + baseAuthor = it.author + } + } + } + } } if (baseAuthor == null) { BlankNote() } else { + val route = remember(baseAuthor) { + "User/${baseAuthor?.pubkeyHex}" + } + Column( modifier = Modifier.clickable( - onClick = { nav("User/${baseAuthor?.pubkeyHex}") } + onClick = { nav(route) } ), verticalArrangement = Arrangement.Center ) { - LaunchedEffect(Unit) { - launch(Dispatchers.Default) { - (noteZapRequest.event as? LnZapRequestEvent)?.let { - val decryptedContent = accountViewModel.decryptZap(noteZapRequest) - if (decryptedContent != null) { - baseAuthor = LocalCache.getOrCreateUser(decryptedContent.pubKey) - } - } - } - } - baseAuthor?.let { - RenderZapNote(it, baseNote.second, nav, accountViewModel) + RenderZapNote(it, baseReqResponse.zapEvent, nav, accountViewModel) } Divider( @@ -87,30 +95,37 @@ private fun RenderZapNote( accountViewModel: AccountViewModel ) { Row( - modifier = Modifier - .padding( - start = 12.dp, - end = 12.dp, - top = 10.dp - ), + modifier = remember { + Modifier + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp + ) + }, verticalAlignment = Alignment.CenterVertically ) { UserPicture(baseAuthor, nav, accountViewModel, 55.dp) Column( - modifier = Modifier - .padding(start = 10.dp) - .weight(1f) + modifier = remember { + Modifier + .padding(start = 10.dp) + .weight(1f) + } ) { Row(verticalAlignment = Alignment.CenterVertically) { UsernameDisplay(baseAuthor) } - - AboutDisplay(baseAuthor) + Row(verticalAlignment = Alignment.CenterVertically) { + AboutDisplay(baseAuthor) + } } Column( - modifier = Modifier.padding(start = 10.dp), + modifier = remember { + Modifier.padding(start = 10.dp) + }, verticalArrangement = Arrangement.Center ) { ZapAmount(zapNote) @@ -125,13 +140,15 @@ private fun RenderZapNote( @Composable private fun ZapAmount(zapEventNote: Note) { val noteState by zapEventNote.live().metadata.observeAsState() - val noteZap = remember(noteState) { noteState?.note } ?: return var zapAmount by remember { mutableStateOf(null) } - LaunchedEffect(key1 = noteZap) { + LaunchedEffect(key1 = noteState) { launch(Dispatchers.IO) { - zapAmount = showAmountAxis((noteZap.event as? LnZapEvent)?.amount) + val newZapAmount = showAmountAxis((noteState?.note?.event as? LnZapEvent)?.amount) + if (zapAmount != newZapAmount) { + zapAmount = newZapAmount + } } } @@ -151,7 +168,6 @@ fun UserActionOptions( accountViewModel: AccountViewModel ) { val scope = rememberCoroutineScope() - val accountState by accountViewModel.accountLiveData.observeAsState() val isHidden by remember(accountState) { derivedStateOf { @@ -159,20 +175,39 @@ fun UserActionOptions( } } - val userState by accountViewModel.account.userProfile().live().follows.observeAsState() - val isFollowing by remember(userState) { - derivedStateOf { - userState?.user?.isFollowingCached(baseAuthor) ?: false - } - } - if (isHidden) { ShowUserButton { scope.launch(Dispatchers.IO) { accountViewModel.show(baseAuthor) } } - } else if (isFollowing) { + } else { + ShowFollowingOrUnfollowingButton(baseAuthor, accountViewModel) + } +} + +@Composable +fun ShowFollowingOrUnfollowingButton( + baseAuthor: User, + accountViewModel: AccountViewModel +) { + val scope = rememberCoroutineScope() + + var isFollowing by remember { mutableStateOf(false) } + val accountFollowsState by accountViewModel.account.userProfile().live().follows.observeAsState() + + LaunchedEffect(key1 = accountFollowsState) { + launch(Dispatchers.Default) { + val newShowFollowingMark = + accountFollowsState?.user?.isFollowing(baseAuthor) == true + + if (newShowFollowingMark != isFollowing) { + isFollowing = newShowFollowingMark + } + } + } + + if (isFollowing) { UnfollowButton { scope.launch(Dispatchers.IO) { accountViewModel.unfollow(baseAuthor) } } } else { FollowButton({ scope.launch(Dispatchers.IO) { accountViewModel.follow(baseAuthor) } }) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt index 32d64d4da..39b75df38 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/qrcode/ShowQRDialog.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.ResizeImage import com.vitorpamplona.amethyst.ui.components.RobohashAsyncImageProxy import com.vitorpamplona.amethyst.ui.qrcode.NIP19QrCodeScanner +import kotlinx.collections.immutable.toImmutableList @Composable fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { @@ -87,7 +88,7 @@ fun ShowQRDialog(user: User, onScan: (String) -> Unit, onClose: () -> Unit) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth().padding(top = 5.dp)) { CreateTextWithEmoji( text = user.bestDisplayName() ?: user.bestUsername() ?: "", - tags = user.info?.latestMetadata?.tags, + tags = user.info?.latestMetadata?.tags?.toImmutableList(), fontWeight = FontWeight.Bold, fontSize = 18.sp ) diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt index e9fabd3bb..bed311747 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedState.kt @@ -1,11 +1,18 @@ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.Note +import kotlinx.collections.immutable.ImmutableList +@Immutable +data class ZapReqResponse(val request: Note, val zapEvent: Note) + +@Stable sealed class LnZapFeedState { object Loading : LnZapFeedState() - class Loaded(val feed: MutableState>>) : LnZapFeedState() + class Loaded(val feed: MutableState>) : LnZapFeedState() object Empty : LnZapFeedState() class FeedError(val errorMessage: String) : LnZapFeedState() } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt index e32473ad1..d1030532c 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedView.kt @@ -2,78 +2,44 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.ui.note.ZapNoteCompose import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -@OptIn(ExperimentalMaterialApi::class) @Composable fun LnZapFeedView( viewModel: LnZapFeedViewModel, accountViewModel: AccountViewModel, - nav: (String) -> Unit, - enablePullRefresh: Boolean = true + nav: (String) -> Unit ) { val feedState by viewModel.feedContent.collectAsState() - var refreshing by remember { mutableStateOf(false) } - val refresh = { refreshing = true; viewModel.invalidateData(); refreshing = false } - val pullRefreshState = rememberPullRefreshState(refreshing, onRefresh = refresh) - - val modifier = if (enablePullRefresh) { - Modifier.pullRefresh(pullRefreshState) - } else { - Modifier - } - - Box(modifier) { - Column() { - Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> - when (state) { - is LnZapFeedState.Empty -> { - FeedEmpty { - refreshing = true - } - } - is LnZapFeedState.FeedError -> { - FeedError(state.errorMessage) { - refreshing = true - } - } - is LnZapFeedState.Loaded -> { - if (refreshing) { - refreshing = false - } - - LnZapFeedLoaded(state, accountViewModel, nav) - } - is LnZapFeedState.Loading -> { - LoadingFeed() - } + Crossfade(targetState = feedState, animationSpec = tween(durationMillis = 100)) { state -> + when (state) { + is LnZapFeedState.Empty -> { + FeedEmpty { + viewModel.invalidateData() } } - } - - if (enablePullRefresh) { - PullRefreshIndicator(refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) + is LnZapFeedState.FeedError -> { + FeedError(state.errorMessage) { + viewModel.invalidateData() + } + } + is LnZapFeedState.Loaded -> { + LnZapFeedLoaded(state, accountViewModel, nav) + } + is LnZapFeedState.Loading -> { + LoadingFeed() + } } } } @@ -93,7 +59,7 @@ private fun LnZapFeedLoaded( ), state = listState ) { - itemsIndexed(state.feed.value, key = { _, item -> item.second.idHex }) { _, item -> + itemsIndexed(state.feed.value, key = { _, item -> item.zapEvent.idHex }) { _, item -> ZapNoteCompose(item, accountViewModel = accountViewModel, nav = nav) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt index afc273e86..4df9bcd8a 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/LnZapFeedViewModel.kt @@ -2,13 +2,16 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.BundledUpdate import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -17,9 +20,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -class NostrUserProfileZapsFeedViewModel : LnZapFeedViewModel(UserProfileZapsFeedFilter) +class NostrUserProfileZapsFeedViewModel(user: User) : LnZapFeedViewModel(UserProfileZapsFeedFilter(user)) { + class Factory(val user: User) : ViewModelProvider.Factory { + override fun create(modelClass: Class): NostrUserProfileZapsFeedViewModel { + return NostrUserProfileZapsFeedViewModel(user) as NostrUserProfileZapsFeedViewModel + } + } +} -open class LnZapFeedViewModel(val dataSource: FeedFilter>) : ViewModel() { +open class LnZapFeedViewModel(val dataSource: FeedFilter) : ViewModel() { private val _feedContent = MutableStateFlow(LnZapFeedState.Loading) val feedContent = _feedContent.asStateFlow() @@ -32,12 +41,12 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi private fun refreshSuspended() { checkNotInMainThread() - val notes = dataSource.loadTop() + val notes = dataSource.loadTop().toImmutableList() val oldNotesState = _feedContent.value if (oldNotesState is LnZapFeedState.Loaded) { // Using size as a proxy for has changed. - if (notes != oldNotesState.feed.value) { + if (!equalImmutableLists(notes, oldNotesState.feed.value)) { updateFeed(notes) } } else { @@ -45,7 +54,7 @@ open class LnZapFeedViewModel(val dataSource: FeedFilter>) : Vi } } - private fun updateFeed(notes: List>) { + private fun updateFeed(notes: ImmutableList) { val scope = CoroutineScope(Job() + Dispatchers.Main) scope.launch { val currentState = _feedContent.value diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt index 7f64325af..41278dbdf 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ProfileScreen.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.ui.dal.UserProfileBookmarksFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileConversationsFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileNewThreadFeedFilter import com.vitorpamplona.amethyst.ui.dal.UserProfileReportsFeedFilter -import com.vitorpamplona.amethyst.ui.dal.UserProfileZapsFeedFilter import com.vitorpamplona.amethyst.ui.navigation.ShowQRDialog import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.screen.FeedState @@ -154,11 +153,19 @@ fun PrepareViewModels(baseUser: User, accountViewModel: AccountViewModel, nav: ( ) ) + val zapFeedViewModel: NostrUserProfileZapsFeedViewModel = viewModel( + key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", + factory = NostrUserProfileZapsFeedViewModel.Factory( + baseUser + ) + ) + ProfileScreen( baseUser = baseUser, followsFeedViewModel, followersFeedViewModel, appRecommendations, + zapFeedViewModel, accountViewModel = accountViewModel, nav = nav ) @@ -171,12 +178,12 @@ fun ProfileScreen( followsFeedViewModel: NostrUserProfileFollowsUserFeedViewModel, followersFeedViewModel: NostrUserProfileFollowersUserFeedViewModel, appRecommendations: NostrUserAppRecommendationsFeedViewModel, + zapFeedViewModel: NostrUserProfileZapsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit ) { UserProfileNewThreadFeedFilter.loadUserProfile(accountViewModel.account, baseUser) UserProfileConversationsFeedFilter.loadUserProfile(accountViewModel.account, baseUser) - UserProfileZapsFeedFilter.loadUserProfile(baseUser) UserProfileReportsFeedFilter.loadUserProfile(baseUser) UserProfileBookmarksFeedFilter.loadUserProfile(accountViewModel.account, baseUser) @@ -293,10 +300,10 @@ fun ProfileScreen( 1 -> TabNotesConversations(accountViewModel, nav) 2 -> TabFollows(baseUser, followsFeedViewModel, accountViewModel, nav) 3 -> TabFollows(baseUser, followersFeedViewModel, accountViewModel, nav) - 4 -> TabReceivedZaps(baseUser, accountViewModel, nav) - 6 -> TabBookmarks(baseUser, accountViewModel, nav) - 7 -> TabReports(baseUser, accountViewModel, nav) - 8 -> TabRelays(baseUser, accountViewModel) + 4 -> TabReceivedZaps(baseUser, zapFeedViewModel, accountViewModel, nav) + 5 -> TabBookmarks(baseUser, accountViewModel, nav) + 6 -> TabReports(baseUser, accountViewModel, nav) + 7 -> TabRelays(baseUser, accountViewModel) } } } @@ -579,7 +586,7 @@ private fun DrawAdditionalInfo( ) { val userState by baseUser.live().metadata.observeAsState() val user = remember(userState) { userState?.user } ?: return - val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags } + val tags = remember(userState) { userState?.user?.info?.latestMetadata?.tags?.toImmutableList() } val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current @@ -1120,16 +1127,14 @@ private fun WatchFollowChanges( } @Composable -fun TabReceivedZaps(baseUser: User, accountViewModel: AccountViewModel, nav: (String) -> Unit) { - val feedViewModel: NostrUserProfileZapsFeedViewModel = viewModel() - - WatchZapsAndUpdateFeed(baseUser, feedViewModel) +fun TabReceivedZaps(baseUser: User, zapFeedViewModel: NostrUserProfileZapsFeedViewModel, accountViewModel: AccountViewModel, nav: (String) -> Unit) { + WatchZapsAndUpdateFeed(baseUser, zapFeedViewModel) Column(Modifier.fillMaxHeight()) { Column( modifier = Modifier.padding(vertical = 0.dp) ) { - LnZapFeedView(feedViewModel, accountViewModel, nav, enablePullRefresh = false) + LnZapFeedView(zapFeedViewModel, accountViewModel, nav) } } }