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 {
ContactListEvent.createFromScratch(
followUsers = listOf(ContactTag(user.pubkeyHex, user.bestRelayHint(), null)),
followTags = emptyList(),
followGeohashes = emptyList(),
followCommunities = emptyList(),
relayUse = emptyMap(),
signer = signer,
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.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<Int> {
@@ -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<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
fun observeUserBookmarks(
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.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) }
}

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.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,
)

View File

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

View File

@@ -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)}")
}

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.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,
)
}
}
}

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.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<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)
@Deprecated("Use HashtagListEvent instead.")
fun unverifiedFollowTagSet() = tags.hashtags()
fun countFollowTags() = tags.countHashtags()
fun follows() = tags.mapNotNull(ContactTag::parseValid)
fun followsTags() = hashtags()
fun relays(): Map<NormalizedRelayUrl, ReadWrite>? {
val regular = RelaySet.parse(content)
@@ -106,9 +99,6 @@ class ContactListEvent(
fun createFromScratch(
followUsers: List<ContactTag> = emptyList(),
followTags: List<String> = emptyList(),
followGeohashes: List<String> = emptyList(),
followCommunities: List<ATag> = emptyList(),
relayUse: Map<String, ReadWrite>? = 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<ContactTag>,
followTags: List<String>,
followGeohashes: List<String>,
followCommunities: List<ATag>,
relayUse: Map<String, ReadWrite>?,
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<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