Removes old follows in Contact lists.

This commit is contained in:
Vitor Pamplona
2025-07-08 19:16:28 -04:00
parent c5ed0cfa60
commit 2ee1285ea1
9 changed files with 67 additions and 193 deletions

View File

@@ -124,9 +124,6 @@ class FollowListState(
} else { } else {
ContactListEvent.createFromScratch( ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)), followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = emptyList(),
relayUse = emptyMap(), relayUse = emptyMap(),
signer = signer, signer = signer,
onReady = onDone, onReady = onDone,

View File

@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.sample
import java.math.BigDecimal import java.math.BigDecimal
@@ -259,7 +261,7 @@ fun observeUserFollowCount(
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable @Composable
fun observeUserTagFollows( fun observeUserTagFollowCount(
user: User, user: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
): State<Int> { ): State<Int> {
@@ -269,12 +271,15 @@ fun observeUserTagFollows(
// Subscribe in the LocalCache for changes that arrive in the device // Subscribe in the LocalCache for changes that arrive in the device
val flow = val flow =
remember(user) { remember(user) {
user accountViewModel
.hashtagFollows(user)
.flow() .flow()
.follows.stateFlow .metadata.stateFlow
.sample(1000) .sample(1000)
.mapLatest { userState -> .mapLatest { noteState ->
userState.user.latestContactList?.countFollowTags() ?: 0 (noteState.note.event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0
}.onStart {
emit((accountViewModel.hashtagFollows(user).event as? HashtagListEvent)?.publicAndCachedPrivateHashtags()?.size ?: 0)
}.distinctUntilChanged() }.distinctUntilChanged()
.flowOn(Dispatchers.Default) .flowOn(Dispatchers.Default)
} }
@@ -282,6 +287,34 @@ fun observeUserTagFollows(
return flow.collectAsStateWithLifecycle(0) return flow.collectAsStateWithLifecycle(0)
} }
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
fun observeUserTagFollows(
user: User,
accountViewModel: AccountViewModel,
): State<List<String>> {
// 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 @Composable
fun observeUserBookmarks( fun observeUserBookmarks(
user: User, user: User,

View File

@@ -113,6 +113,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec
import com.vitorpamplona.quartz.nip37Drafts.DraftEvent import com.vitorpamplona.quartz.nip37Drafts.DraftEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip47WalletConnect.Response
import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent import com.vitorpamplona.quartz.nip51Lists.GeneralListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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) } 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) } viewModelScope.launch(Dispatchers.IO) { account.removeFromGallery(note) }
} }
fun hashtagFollows(user: User): Note = LocalCache.getOrCreateAddressableNote(HashtagListEvent.createAddress(user.pubkeyHex))
fun addPrivateBookmark(note: Note) { fun addPrivateBookmark(note: Note) {
viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) } viewModelScope.launch(Dispatchers.IO) { account.addBookmark(note, true) }
} }

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.FollowListEvent
import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent
import com.vitorpamplona.quartz.nip51Lists.interests.HashtagListEvent
import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent
val UserProfileListKinds = val UserProfileListKinds =
@@ -34,6 +35,7 @@ val UserProfileListKinds =
BookmarkListEvent.KIND, BookmarkListEvent.KIND,
PeopleListEvent.KIND, PeopleListEvent.KIND,
FollowListEvent.KIND, FollowListEvent.KIND,
HashtagListEvent.KIND,
AppRecommendationEvent.KIND, AppRecommendationEvent.KIND,
) )

View File

@@ -64,7 +64,7 @@ fun DeleteFromGalleryDialog(
buttonIcon = Icons.Default.Delete, buttonIcon = Icons.Default.Delete,
buttonText = stringRes(R.string.quick_action_delete_dialog_btn), buttonText = stringRes(R.string.quick_action_delete_dialog_btn),
onClickDoOnce = { onClickDoOnce = {
accountViewModel.removefromMediaGallery(note) accountViewModel.removeFromMediaGallery(note)
onDismiss() onDismiss()
}, },
onDismiss = onDismiss, onDismiss = onDismiss,

View File

@@ -25,7 +25,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -34,7 +34,7 @@ fun FollowedTagsTabHeader(
baseUser: User, baseUser: User,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val usertags by observeUserTagFollows(baseUser, accountViewModel) val usertags by observeUserTagFollowCount(baseUser, accountViewModel)
Text(text = "$usertags ${stringRes(R.string.followed_tags)}") Text(text = "$usertags ${stringRes(R.string.followed_tags)}")
} }

View File

@@ -22,15 +22,17 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.User 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.INav
import com.vitorpamplona.amethyst.ui.navigation.Route import com.vitorpamplona.amethyst.ui.navigation.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -43,28 +45,23 @@ fun TabFollowedTags(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val items =
remember(baseUser.latestContactList?.id) {
baseUser.latestContactList?.unverifiedFollowTagSet()
}
Column( Column(
Modifier Modifier
.fillMaxHeight() .fillMaxHeight()
.padding(vertical = 0.dp), .padding(vertical = 0.dp),
) { ) {
items?.let { val items by observeUserTagFollows(baseUser, accountViewModel)
LazyColumn {
itemsIndexed(items) { index, hashtag -> LazyColumn(Modifier.fillMaxSize()) {
HashtagHeader( itemsIndexed(items) { index, hashtag ->
tag = hashtag, HashtagHeader(
account = accountViewModel, tag = hashtag,
onClick = { nav.nav(Route.Hashtag(hashtag)) }, account = accountViewModel,
) onClick = { nav.nav(Route.Hashtag(hashtag)) },
HorizontalDivider( )
thickness = DividerThickness, HorizontalDivider(
) thickness = DividerThickness,
} )
} }
} }
} }

View File

@@ -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.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip01Core.tags.addressables.ATag 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.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.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.parseAsHint
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser 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.nip02FollowList.tags.ContactTag
import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -71,18 +66,16 @@ class ContactListEvent(
/** /**
* Returns a list of a-tags that are verified as correct. * Returns a list of a-tags that are verified as correct.
*/ */
fun verifiedFollowAddressSet(): Set<HexKey> = tags.mapNotNullTo(HashSet(), AddressFollowTag::parseValidAddress) @Deprecated("Use CommunityListEvent instead.")
fun verifiedFollowAddressSet(): Set<HexKey> = tags.mapNotNullTo(HashSet(), ATag::parseValidAddress)
fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey) fun unverifiedFollowKeySet() = tags.mapNotNull(ContactTag::parseKey)
@Deprecated("Use HashtagListEvent instead.")
fun unverifiedFollowTagSet() = tags.hashtags() fun unverifiedFollowTagSet() = tags.hashtags()
fun countFollowTags() = tags.countHashtags()
fun follows() = tags.mapNotNull(ContactTag::parseValid) fun follows() = tags.mapNotNull(ContactTag::parseValid)
fun followsTags() = hashtags()
fun relays(): Map<NormalizedRelayUrl, ReadWrite>? { fun relays(): Map<NormalizedRelayUrl, ReadWrite>? {
val regular = RelaySet.parse(content) val regular = RelaySet.parse(content)
@@ -106,9 +99,6 @@ class ContactListEvent(
fun createFromScratch( fun createFromScratch(
followUsers: List<ContactTag> = emptyList(), followUsers: List<ContactTag> = emptyList(),
followTags: List<String> = emptyList(),
followGeohashes: List<String> = emptyList(),
followCommunities: List<ATag> = emptyList(),
relayUse: Map<String, ReadWrite>? = emptyMap(), relayUse: Map<String, ReadWrite>? = emptyMap(),
signer: NostrSignerSync, signer: NostrSignerSync,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
@@ -117,19 +107,13 @@ class ContactListEvent(
val tags = val tags =
listOf(AltTag.assemble(ALT)) + listOf(AltTag.assemble(ALT)) +
followUsers.map { it.toTagArray() } + followUsers.map { it.toTagArray() }
followTags.map { arrayOf("t", it) } +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
return signer.sign(createdAt, KIND, tags.toTypedArray(), content) return signer.sign(createdAt, KIND, tags.toTypedArray(), content)
} }
fun createFromScratch( fun createFromScratch(
followUsers: List<ContactTag>, followUsers: List<ContactTag>,
followTags: List<String>,
followGeohashes: List<String>,
followCommunities: List<ATag>,
relayUse: Map<String, ReadWrite>?, relayUse: Map<String, ReadWrite>?,
signer: NostrSigner, signer: NostrSigner,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
@@ -137,12 +121,7 @@ class ContactListEvent(
) { ) {
val content = relayUse?.let { RelaySet.assemble(it) } ?: "" val content = relayUse?.let { RelaySet.assemble(it) } ?: ""
val tags = val tags = followUsers.map { it.toTagArray() }
followUsers.map { it.toTagArray() } +
followTags.map { arrayOf("t", it) } +
followCommunities.map { it.toATagArray() } +
followGeohashes.map { arrayOf("g", it) }
return create( return create(
content = content, content = content,
tags = tags.toTypedArray(), 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( fun updateRelayList(
earlierVersion: ContactListEvent, earlierVersion: ContactListEvent,
relayUse: Map<String, ReadWrite>?, relayUse: Map<String, ReadWrite>?,

View File

@@ -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