diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt index ea19dde3c..abb6edf9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/trustedAssertions/UserCardsCache.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import java.text.NumberFormat class UserCardsCache : UserDependencies { val receivedCards = MutableStateFlow(mapOf()) @@ -107,4 +108,38 @@ class UserCardsCache : UserDependencies { }.map { (it?.note?.event as? ContactCardEvent)?.rank() }.flowOn(Dispatchers.IO) + + val formatter = NumberFormat.getInstance() + + fun followerCountStrFlow(trustProviderList: TrustProviderListState) = + combineTransform(receivedCards, trustProviderList.liveUserFollowerCount) { cards, provider -> + if (provider != null) { + val flow = + cards.firstNotNullOfOrNull { + if (it.key.pubkeyHex == provider.pubkey) { + it.value + .flow() + .metadata.stateFlow + } else { + null + } + } + + if (flow != null) { + emitAll(flow) + } else { + emit(null) + } + } else { + emit(null) + } + }.map { + val value = (it?.note?.event as? ContactCardEvent)?.followerCount() + + if (value != null && value > 0) { + formatter.format(value) + } else { + "--" + } + }.flowOn(Dispatchers.IO) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 317cb7dc9..d40d4fb69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -616,6 +616,21 @@ fun observeUserContactCardsScore( return flow.collectAsStateWithLifecycle(null) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserContactCardsFollowerCount( + user: User, + accountViewModel: AccountViewModel, +): State { + // Subscribe in the relay for changes in the metadata of this user. + UserFinderFilterAssemblerSubscription(user, accountViewModel) + + // Subscribe in the LocalCache for changes that arrive in the device + val flow = remember(user) { user.cards().followerCountStrFlow(accountViewModel.account.trustProviderList) } + + return flow.collectAsStateWithLifecycle("--") +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserStatuses( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index dabc99504..baa2930bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.navigation.drawer +import android.R.attr.fontWeight import androidx.compose.foundation.Image import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -95,7 +96,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserContactCardsFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -121,6 +122,7 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier import com.vitorpamplona.amethyst.ui.theme.drawerSpacing import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.tags.ProviderTypes.followerCount import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists @@ -395,29 +397,41 @@ private fun FollowingAndFollowerCounts( Row( modifier = drawerSpacing.clickable(onClick = onClick), ) { - val followingCount = baseAccountUser.kind3FollowList.flow.collectAsStateWithLifecycle() - val followerCount by observeUserFollowerCount(baseAccountUser.userProfile(), accountViewModel) - - Text( - text = - followingCount.value.authors.size - .toString(), - fontWeight = FontWeight.Bold, - ) + DisplayFollowingCount(baseAccountUser) Text(stringRes(R.string.following)) Spacer(modifier = DoubleHorzSpacer) - Text( - text = if (followerCount > 0) followerCount.toString() else "--", - fontWeight = FontWeight.Bold, - ) + DisplayFollowerCount(baseAccountUser, accountViewModel) Text(stringRes(R.string.followers)) } } +@Composable +fun DisplayFollowingCount(baseAccountUser: Account) { + val followingCount by baseAccountUser.kind3FollowList.flow.collectAsStateWithLifecycle() + + Text( + text = followingCount.authors.size.toString(), + fontWeight = FontWeight.Bold, + ) +} + +@Composable +fun DisplayFollowerCount( + baseAccountUser: Account, + accountViewModel: AccountViewModel, +) { + val followerCount by observeUserContactCardsFollowerCount(baseAccountUser.userProfile(), accountViewModel) + + Text( + text = followerCount, + fontWeight = FontWeight.Bold, + ) +} + @Composable fun ListContent( modifier: Modifier, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt index 4919cdb38..d44c02790 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/ContactCardEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.experimental.relationshipStatus import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.FollowerCountTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.PetNameTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.RankTag import com.vitorpamplona.quartz.experimental.relationshipStatus.tags.SummaryTag @@ -49,6 +50,8 @@ class ContactCardEvent( fun rank() = tags.firstNotNullOfOrNull(RankTag::parse) + fun followerCount() = tags.firstNotNullOfOrNull(FollowerCountTag::parse) + fun petName() = tags.firstNotNullOfOrNull(PetNameTag::parse) fun summary() = tags.firstNotNullOfOrNull(SummaryTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/FollowerCountTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/FollowerCountTag.kt new file mode 100644 index 000000000..06e766f63 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/relationshipStatus/tags/FollowerCountTag.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.relationshipStatus.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FollowerCountTag { + companion object { + const val TAG_NAME = "followers" + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(followerCount: Int) = arrayOf(TAG_NAME, followerCount.toString()) + } +}