diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt index 87eca3f75..e8d8c3182 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip02FollowLists/FollowListState.kt @@ -124,9 +124,6 @@ class FollowListState( } else { ContactListEvent.createFromScratch( followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), - followTags = emptyList(), - followGeohashes = emptyList(), - followCommunities = emptyList(), relayUse = emptyMap(), signer = signer, onReady = onDone, 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 9920cd424..2848f1d83 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 @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.Dispatchers @@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.sample import java.math.BigDecimal @@ -259,7 +261,7 @@ fun observeUserFollowCount( @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable -fun observeUserTagFollows( +fun observeUserTagFollowCount( user: User, accountViewModel: AccountViewModel, ): State { @@ -269,12 +271,15 @@ fun observeUserTagFollows( // Subscribe in the LocalCache for changes that arrive in the device val flow = remember(user) { - user + accountViewModel + .hashtagFollows(user) .flow() - .follows.stateFlow + .metadata.stateFlow .sample(1000) - .mapLatest { userState -> - userState.user.latestContactList?.countFollowTags() ?: 0 + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0 + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0) }.distinctUntilChanged() .flowOn(Dispatchers.Default) } @@ -282,6 +287,34 @@ fun observeUserTagFollows( return flow.collectAsStateWithLifecycle(0) } +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserTagFollows( + 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) { + accountViewModel + .hashtagFollows(user) + .flow() + .metadata.stateFlow + .sample(1000) + .mapLatest { noteState -> + (noteState.note.event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.sorted() ?: emptyList() + }.onStart { + emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.sorted() ?: emptyList()) + }.distinctUntilChanged() + .flowOn(Dispatchers.Default) + } + + return flow.collectAsStateWithLifecycle(emptyList()) +} + @Composable fun observeUserBookmarks( user: User, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 9985fc7cd..9919b0f6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -719,10 +720,12 @@ class AccountViewModel( viewModelScope.launch(Dispatchers.IO) { account.addToGallery(hex, url, relay, blurhash, dim, hash, mimeType) } } - fun removefromMediaGallery(note: Note) { + fun removeFromMediaGallery(note: Note) { viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) } } + fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex)) + fun addPrivateBookmark(note: Note) { viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt index 51258fad5..f3108d494 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfileLists.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent val UserProfileListKinds = @@ -34,6 +35,7 @@ val UserProfileListKinds = BookmarkListEvent.KIND, PeopleListEvent.KIND, FollowListEvent.KIND, + HashtagListEvent.KIND, AppRecommendationEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt index d32bf9456..778e99afd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/QuickActionGallery.kt @@ -64,7 +64,7 @@ fun DeleteFromGalleryDialog( buttonIcon = Icons.Default.Delete, buttonText = stringRes(R.string.quick_action_delete_dialog_btn), onClickDoOnce = { - accountViewModel.removefromMediaGallery(note) + accountViewModel.removeFromMediaGallery(note) onDismiss() }, onDismiss = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt index af082b3f0..4c46aa32b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/FollowedTagsTabHeader.kt @@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollowCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -34,7 +34,7 @@ fun FollowedTagsTabHeader( baseUser: User, accountViewModel: AccountViewModel, ) { - val usertags by observeUserTagFollows(baseUser, accountViewModel) + val usertags by observeUserTagFollowCount(baseUser, accountViewModel) Text(text = "$usertags ${stringRes(R.string.followed_tags)}") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt index fa9c00e4e..ee5cae3bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/hashtags/TabFollowedTags.kt @@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserTagFollows import com.vitorpamplona.amethyst.ui.navigation.INav import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -43,28 +45,23 @@ fun TabFollowedTags( accountViewModel: AccountViewModel, nav: INav, ) { - val items = - remember(baseUser.latestContactList?.id) { - baseUser.latestContactList?.unverifiedFollowTagSet() - } - Column( Modifier .fillMaxHeight() .padding(vertical = 0.dp), ) { - items?.let { - LazyColumn { - itemsIndexed(items) { index, hashtag -> - HashtagHeader( - tag = hashtag, - account = accountViewModel, - onClick = { nav.nav(Route.Hashtag(hashtag)) }, - ) - HorizontalDivider( - thickness = DividerThickness, - ) - } + val items by observeUserTagFollows(baseUser, accountViewModel) + + LazyColumn(Modifier.fillMaxSize()) { + itemsIndexed(items) { index, hashtag -> + HashtagHeader( + tag = hashtag, + account = accountViewModel, + onClick = { nav.nav(Route.Hashtag(hashtag)) }, + ) + HorizontalDivider( + thickness = DividerThickness, + ) } } } diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 56f46bad2..968ad43ab 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -30,16 +30,11 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag -import com.vitorpamplona.quartz.nip01Core.tags.addressables.isTaggedAddressableNote import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint -import com.vitorpamplona.quartz.nip01Core.tags.geohash.isTaggedGeoHash -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.countHashtags import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.hashtags.isTaggedHash import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser -import com.vitorpamplona.quartz.nip02FollowList.tags.AddressFollowTag import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -71,18 +66,16 @@ class ContactListEvent( /** * Returns a list of a-tags that are verified as correct. */ - fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), AddressFollowTag::parseValidAddress) + @Deprecated("Use CommunityListEvent instead.") + fun verifiedFollowAddressSet(): Set = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) + @Deprecated("Use HashtagListEvent instead.") fun unverifiedFollowTagSet() = tags.hashtags() - fun countFollowTags() = tags.countHashtags() - fun follows() = tags.mapNotNull(ContactTag::parseValid) - fun followsTags() = hashtags() - fun relays(): Map? { val regular = RelaySet.parse(content) @@ -106,9 +99,6 @@ class ContactListEvent( fun createFromScratch( followUsers: List = emptyList(), - followTags: List = emptyList(), - followGeohashes: List = emptyList(), - followCommunities: List = emptyList(), relayUse: Map? = emptyMap(), signer: NostrSignerSync, createdAt: Long = TimeUtils.now(), @@ -117,19 +107,13 @@ class ContactListEvent( val tags = listOf(AltTag.assemble(ALT)) + - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } + followUsers.map { it.toTagArray() } return signer.sign(createdAt, KIND, tags.toTypedArray(), content) } fun createFromScratch( followUsers: List, - followTags: List, - followGeohashes: List, - followCommunities: List, relayUse: Map?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), @@ -137,12 +121,7 @@ class ContactListEvent( ) { val content = relayUse?.let { RelaySet.assemble(it) } ?: "" - val tags = - followUsers.map { it.toTagArray() } + - followTags.map { arrayOf("t", it) } + - followCommunities.map { it.toATagArray() } + - followGeohashes.map { arrayOf("g", it) } - + val tags = followUsers.map { it.toTagArray() } return create( content = content, tags = tags.toTypedArray(), @@ -188,118 +167,6 @@ class ContactListEvent( ) } - fun followHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("t", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowHashtag( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.filter { it.size > 1 && !it[1].equals(hashtag, true) }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.plus(element = arrayOf("g", hashtag)), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowGeohash( - earlierVersion: ContactListEvent, - hashtag: String, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedGeoHash(hashtag)) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != hashtag }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun followAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = - earlierVersion.tags.plus( - element = aTag.toATagArray(), - ), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - - fun unfollowAddressableEvent( - earlierVersion: ContactListEvent, - aTag: ATag, - signer: NostrSigner, - createdAt: Long = TimeUtils.now(), - onReady: (ContactListEvent) -> Unit, - ) { - if (!earlierVersion.isTaggedAddressableNote(aTag.toTag())) return - - return create( - content = earlierVersion.content, - tags = earlierVersion.tags.filter { it.size > 1 && it[1] != aTag.toTag() }.toTypedArray(), - signer = signer, - createdAt = createdAt, - onReady = onReady, - ) - } - fun updateRelayList( earlierVersion: ContactListEvent, relayUse: Map?, diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt deleted file mode 100644 index 48d3cd43b..000000000 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip02FollowList/tags/AddressFollowTag.kt +++ /dev/null @@ -1,25 +0,0 @@ -/** - * 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.nip02FollowList.tags - -import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag - -typealias AddressFollowTag = ATag