Merge pull request #1499 from KotlinGeekDev/follows-and-followsets-unified

Unification of follows and follow sets.
This commit is contained in:
Vitor Pamplona
2025-09-29 13:47:39 -04:00
committed by GitHub
15 changed files with 234 additions and 133 deletions

View File

@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
@@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.quartz.experimental.bounties.BountyAddValueEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
@@ -118,6 +118,7 @@ import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.value
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
@@ -214,7 +215,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.math.BigDecimal
import java.util.Locale
import kotlin.coroutines.cancellation.CancellationException
@@ -266,6 +266,7 @@ class Account(
val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings)
val kind3FollowList = FollowListState(signer, cache, scope, settings)
val followSetsState = FollowSetState(signer, cache, scope)
val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer)
val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings)
@@ -317,7 +318,7 @@ class Account(
val followsPerRelay = FollowsPerOutboxRelay(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope).flow
// Merges all follow lists to create a single All Follows feed.
val allFollows = MergedFollowListsState(kind3FollowList, hashtagList, geohashList, communityList, scope)
val allFollows = MergedFollowListsState(kind3FollowList, followSetsState, hashtagList, geohashList, communityList, scope)
val privateDMDecryptionCache = PrivateDMCache(signer)
val privateZapsDecryptionCache = PrivateZapCache(signer)
@@ -829,20 +830,6 @@ class Account(
fun upgradeAttestations() = otsState.upgradeAttestationsIfNeeded(::sendAutomatic)
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile())
Log.d(this@Account.javaClass.simpleName, "Number of follow sets: ${followSetNotes.size}")
return@withContext followSetNotes
}
fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user))
suspend fun unfollow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.unfollow(user))

View File

@@ -18,7 +18,7 @@
* 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.amethyst.ui.screen.loggedIn.lists
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.core.value
@@ -31,9 +31,9 @@ data class FollowSet(
val identifierTag: String,
val title: String,
val description: String?,
val visibility: ListVisibility,
val profileList: Set<String>,
) : NostrList(listVisibility = visibility, content = profileList) {
val visibility: SetVisibility,
val profiles: Set<String>,
) : NostrSet(setVisibility = visibility, content = profiles) {
companion object {
fun mapEventToSet(
event: PeopleListEvent,
@@ -53,16 +53,16 @@ data class FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Private,
profileList = privateFollows.toSet(),
visibility = SetVisibility.Private,
profiles = privateFollows.toSet(),
)
} else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) {
FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Public,
profileList = publicFollows.toSet(),
visibility = SetVisibility.Public,
profiles = publicFollows.toSet(),
)
} else {
// Follow set is empty, so assume public. Why? Nostr limitation.
@@ -71,8 +71,8 @@ data class FollowSet(
identifierTag = dTag,
title = listTitle,
description = listDescription,
visibility = ListVisibility.Public,
profileList = publicFollows.toSet(),
visibility = SetVisibility.Public,
profiles = publicFollows.toSet(),
)
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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.amethyst.model.nip51Lists.followSets
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FollowSetState(
val signer: NostrSigner,
val cache: LocalCache,
val scope: CoroutineScope,
) {
val user = cache.getOrCreateUser(signer.pubKey)
private val isActive = MutableStateFlow(false)
suspend fun getFollowSetNotes() =
withContext(Dispatchers.Default) {
val followSetNotes = LocalCache.getFollowSetNotesFor(user)
return@withContext followSetNotes
}
private fun getFollowSetNotesFlow() =
flow {
while (isActive.value) {
val followSetNotes = getFollowSetNotes()
val followSets = followSetNotes.map { mapNoteToFollowSet(it) }
emit(followSets)
delay(2000)
}
}.flowOn(Dispatchers.Default)
val profilesFlow =
getFollowSetNotesFlow()
.map { it ->
it.flatMapTo(mutableSetOf()) { it.profiles }.toSet()
}.stateIn(scope, SharingStarted.Eagerly, emptySet())
fun mapNoteToFollowSet(note: Note): FollowSet =
FollowSet
.mapEventToSet(
event = note.event as PeopleListEvent,
signer,
)
fun isUserInFollowSets(user: User): Boolean = profilesFlow.value.contains(user.pubkeyHex)
init {
isActive.update { true }
scope.launch(Dispatchers.Default) {
getFollowSetNotesFlow()
.onCompletion {
isActive.update { false }
}.catch {
Log.e(this@FollowSetState.javaClass.simpleName, "Error on flow collection: ${it.message}")
isActive.update { false }
}.collect {}
}
}
}

View File

@@ -18,15 +18,15 @@
* 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.amethyst.ui.screen.loggedIn.lists
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
sealed class NostrList(
val listVisibility: ListVisibility,
sealed class NostrSet(
val setVisibility: SetVisibility,
val content: Collection<String>,
)
class CuratedBookmarkList(
class CuratedBookmarkSet(
val name: String,
val visibility: ListVisibility,
val listItems: List<String>,
) : NostrList(visibility, listItems)
val visibility: SetVisibility,
val setItems: List<String>,
) : NostrSet(visibility, setItems)

View File

@@ -18,9 +18,9 @@
* 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.amethyst.ui.screen.loggedIn.lists
package com.vitorpamplona.amethyst.model.nip51Lists.followSets
enum class ListVisibility {
enum class SetVisibility {
Public,
Private,
Mixed,

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.serverList
import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListState
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
@@ -37,6 +38,7 @@ import kotlinx.coroutines.flow.stateIn
class MergedFollowListsState(
val kind3List: FollowListState,
val followSetList: FollowSetState,
val hashtagList: HashtagListState,
val geohashList: GeohashListState,
val communityList: CommunityListState,
@@ -44,12 +46,13 @@ class MergedFollowListsState(
) {
fun mergeLists(
kind3: FollowListState.Kind3Follows,
followSetProfiles: Set<String>,
hashtags: Set<String>,
geohashes: Set<String>,
community: Set<CommunityTag>,
): FollowListState.Kind3Follows =
FollowListState.Kind3Follows(
kind3.authors,
kind3.authors + followSetProfiles,
kind3.authorsPlusMe,
kind3.hashtags + hashtags,
kind3.geotags + geohashes,
@@ -59,15 +62,17 @@ class MergedFollowListsState(
val flow: StateFlow<FollowListState.Kind3Follows> =
combine(
kind3List.flow,
followSetList.profilesFlow,
hashtagList.flow,
geohashList.flow,
communityList.flow,
) { kind3, hashtag, geohash, community ->
mergeLists(kind3, hashtag, geohash, community)
) { kind3, followSet, hashtag, geohash, community ->
mergeLists(kind3, followSet, hashtag, geohash, community)
}.onStart {
emit(
mergeLists(
kind3List.flow.value,
followSetList.profilesFlow.value,
hashtagList.flow.value,
geohashList.flow.value,
communityList.flow.value,

View File

@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user
import android.annotation.SuppressLint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.Account
@@ -411,6 +412,10 @@ fun observeUserIsFollowing(
): State<Boolean> {
// Subscribe in the relay for changes in the metadata of this user.
UserFinderFilterAssemblerSubscription(user1, accountViewModel)
val isUserInFollowSets =
remember(accountViewModel.account.followSetsState) {
accountViewModel.account.followSetsState.isUserInFollowSets(user2)
}
// Subscribe in the LocalCache for changes that arrive in the device
val flow =
@@ -420,12 +425,14 @@ fun observeUserIsFollowing(
.follows.stateFlow
.sample(1000)
.mapLatest { userState ->
userState.user.isFollowing(user2)
userState.user.isFollowing(user2) || isUserInFollowSets
}.distinctUntilChanged()
.flowOn(Dispatchers.Default)
}
return flow.collectAsStateWithLifecycle(user1.isFollowing(user2))
return flow.collectAsStateWithLifecycle(
user1.isFollowing(user2) || isUserInFollowSets,
)
}
@SuppressLint("StateFlowValueCalledInComposition")

View File

@@ -21,20 +21,20 @@
package com.vitorpamplona.amethyst.ui.dal
import android.util.Log
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState
import kotlinx.coroutines.runBlocking
class FollowSetFeedFilter(
val account: Account,
val followSetState: FollowSetState,
) : FeedFilter<FollowSet>() {
override fun feedKey(): String = account.userProfile().pubkeyHex + "-followsets"
override fun feedKey(): String = followSetState.user.pubkeyHex + "-followsets"
override fun feed(): List<FollowSet> =
runBlocking(account.scope.coroutineContext) {
runBlocking(followSetState.scope.coroutineContext) {
try {
val fetchedSets = account.getFollowSetNotes()
val followSets = fetchedSets.map { account.mapNoteToFollowSet(it) }
val fetchedSets = followSetState.getFollowSetNotes()
val followSets = fetchedSets.map { followSetState.mapNoteToFollowSet(it) }
println("Updated follow set size for feed filter: ${followSets.size}")
followSets
} catch (e: Exception) {

View File

@@ -59,6 +59,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -76,10 +78,10 @@ fun ListsAndSetsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val followSetsViewModel: NostrUserListFeedViewModel =
val followSetsViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
ListsAndSetsScreen(
@@ -91,7 +93,7 @@ fun ListsAndSetsScreen(
@Composable
fun ListsAndSetsScreen(
followSetsViewModel: NostrUserListFeedViewModel,
followSetsViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -117,8 +119,8 @@ fun ListsAndSetsScreen(
refresh = {
followSetsViewModel.invalidateData()
},
addItem = { title: String, description: String?, listType: ListVisibility ->
val isSetPrivate = listType == ListVisibility.Private
addItem = { title: String, description: String?, listType: SetVisibility ->
val isSetPrivate = listType == SetVisibility.Private
followSetsViewModel.addFollowSet(
setName = title,
setDescription = description,
@@ -149,9 +151,9 @@ fun ListsAndSetsScreen(
@Composable
fun CustomListsScreen(
followSetState: FollowSetState,
followSetFeedState: FollowSetFeedState,
refresh: () -> Unit,
addItem: (title: String, description: String?, listType: ListVisibility) -> Unit,
addItem: (title: String, description: String?, listType: SetVisibility) -> Unit,
openItem: (identifier: String) -> Unit,
renameItem: (followSet: FollowSet, newName: String) -> Unit,
deleteItem: (followSet: FollowSet) -> Unit,
@@ -195,10 +197,10 @@ fun CustomListsScreen(
// TODO: Show components based on current tab
FollowSetFabsAndMenu(
onAddPrivateSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Private)
addItem(name, description, SetVisibility.Private)
},
onAddPublicSet = { name: String, description: String? ->
addItem(name, description, ListVisibility.Public)
addItem(name, description, SetVisibility.Public)
},
)
},
@@ -216,7 +218,7 @@ fun CustomListsScreen(
when (page) {
0 ->
FollowSetFeedView(
followSetState = followSetState,
followSetFeedState = followSetFeedState,
onRefresh = refresh,
onOpenItem = openItem,
onRenameItem = renameItem,
@@ -410,7 +412,7 @@ private fun SetItemPreview() {
identifierTag = "00001-2222",
title = "Sample List Title",
description = "Sample List Description",
visibility = ListVisibility.Mixed,
visibility = SetVisibility.Mixed,
emptySet(),
)
ThemeComparisonColumn {

View File

@@ -55,6 +55,8 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.stringRes
@@ -98,7 +100,7 @@ fun CustomSetItem(
selected = true,
onClick = {},
label = {
Text(text = "${followSet.profileList.size}")
Text(text = "${followSet.profiles.size}")
},
leadingIcon = {
Icon(
@@ -121,9 +123,9 @@ fun CustomSetItem(
followSet.visibility.let {
val text by derivedStateOf {
when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
}
}
Column(
@@ -135,9 +137,9 @@ fun CustomSetItem(
painter =
painterResource(
when (it) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = stringRes(R.string.follow_set_type_description, text),

View File

@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists
sealed class FollowSetState {
data object Loading : FollowSetState()
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
sealed class FollowSetFeedState {
data object Loading : FollowSetFeedState()
data class Loaded(
val feed: List<FollowSet>,
) : FollowSetState()
) : FollowSetFeedState()
data object Empty : FollowSetState()
data object Empty : FollowSetFeedState()
data class FeedError(
val errorMessage: String,
) : FollowSetState()
) : FollowSetFeedState()
}

View File

@@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.ui.feeds.FeedError
import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
@@ -46,17 +47,17 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
@Composable
fun FollowSetFeedView(
modifier: Modifier = Modifier,
followSetState: FollowSetState,
followSetFeedState: FollowSetFeedState,
onRefresh: () -> Unit = {},
onOpenItem: (String) -> Unit = {},
onRenameItem: (targetSet: FollowSet, newName: String) -> Unit,
onDeleteItem: (followSet: FollowSet) -> Unit,
) {
when (followSetState) {
FollowSetState.Loading -> LoadingFeed()
when (followSetFeedState) {
FollowSetFeedState.Loading -> LoadingFeed()
is FollowSetState.Loaded -> {
val followSetFeed = followSetState.feed
is FollowSetFeedState.Loaded -> {
val followSetFeed = followSetFeedState.feed
FollowSetLoaded(
loadedFeedState = followSetFeed,
onRefresh = onRefresh,
@@ -66,7 +67,7 @@ fun FollowSetFeedView(
)
}
is FollowSetState.Empty -> {
is FollowSetFeedState.Empty -> {
FollowSetFeedEmpty(
message = stringRes(R.string.follow_set_empty_feed_msg),
) {
@@ -74,9 +75,9 @@ fun FollowSetFeedView(
}
}
is FollowSetState.FeedError ->
is FollowSetFeedState.FeedError ->
FeedError(
followSetState.errorMessage,
followSetFeedState.errorMessage,
) {
onRefresh()
}

View File

@@ -30,6 +30,8 @@ import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.dal.FeedFilter
import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter
@@ -49,12 +51,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.UUID
// TODO Update: Rename this to be used only for follow sets, and create separate VMs for bookmark sets, etc.
class NostrUserListFeedViewModel(
class FollowSetFeedViewModel(
val dataSource: FeedFilter<FollowSet>,
) : ViewModel(),
InvalidatableContent {
private val _feedContent = MutableStateFlow<FollowSetState>(FollowSetState.Loading)
private val _feedContent = MutableStateFlow<FollowSetFeedState>(FollowSetFeedState.Loading)
val feedContent = _feedContent.asStateFlow()
fun refresh() {
@@ -67,9 +68,8 @@ class NostrUserListFeedViewModel(
noteIdentifier: String,
account: Account,
): AddressableNote? {
// checkNotInMainThread()
val potentialNote =
runBlocking(Dispatchers.IO) { account.getFollowSetNotes() }
runBlocking(Dispatchers.IO) { account.followSetsState.getFollowSetNotes() }
.find { it.dTag() == noteIdentifier }
return potentialNote
}
@@ -79,7 +79,7 @@ class NostrUserListFeedViewModel(
account: Account,
): Boolean {
val potentialNote =
runBlocking(viewModelScope.coroutineContext) { account.getFollowSetNotes() }
runBlocking(viewModelScope.coroutineContext) { account.followSetsState.getFollowSetNotes() }
.find { (it.event as PeopleListEvent).nameOrTitle() == setName }
return potentialNote != null
}
@@ -94,7 +94,7 @@ class NostrUserListFeedViewModel(
val newSets = dataSource.loadTop().toImmutableList()
if (oldFeedState is FollowSetState.Loaded) {
if (oldFeedState is FollowSetFeedState.Loaded) {
val oldFeedList = oldFeedState.feed.toImmutableList()
// Using size as a proxy for has changed.
if (!equalImmutableLists(newSets, oldFeedList)) {
@@ -108,7 +108,7 @@ class NostrUserListFeedViewModel(
this.javaClass.simpleName,
"refreshSuspended: Error loading or refreshing feed -> ${e.message}",
)
_feedContent.update { FollowSetState.FeedError(e.message.toString()) }
_feedContent.update { FollowSetFeedState.FeedError(e.message.toString()) }
} finally {
isRefreshing.value = false
}
@@ -190,7 +190,7 @@ class NostrUserListFeedViewModel(
PeopleListEvent.addUser(
earlierVersion = followSetEvent,
pubKeyHex = userProfileHex,
isPrivate = followSet.visibility == ListVisibility.Private,
isPrivate = followSet.visibility == SetVisibility.Private,
signer = account.signer,
) {
account.sendMyPublicAndPrivateOutbox(it)
@@ -223,9 +223,9 @@ class NostrUserListFeedViewModel(
private fun updateFeed(sets: ImmutableList<FollowSet>) {
if (sets.isNotEmpty()) {
_feedContent.update { FollowSetState.Loaded(sets) }
_feedContent.update { FollowSetFeedState.Loaded(sets) }
} else {
_feedContent.update { FollowSetState.Empty }
_feedContent.update { FollowSetFeedState.Empty }
}
}
@@ -244,7 +244,7 @@ class NostrUserListFeedViewModel(
init {
Log.d("Init", this.javaClass.simpleName)
Log.d(this.javaClass.simpleName, " FollowSetState : ${_feedContent.value}")
Log.d(this.javaClass.simpleName, " FollowSetFeedState : ${_feedContent.value}")
collectorJob =
viewModelScope.launch(Dispatchers.IO) {
LocalCache.live.newEventBundles.collect { newNotes ->
@@ -266,8 +266,8 @@ class NostrUserListFeedViewModel(
val account: Account,
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T =
NostrUserListFeedViewModel(
FollowSetFeedFilter(account),
FollowSetFeedViewModel(
FollowSetFeedFilter(account.followSetsState),
) as T
}
}

View File

@@ -67,14 +67,14 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSet
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
@@ -92,10 +92,10 @@ fun FollowSetScreen(
accountViewModel: AccountViewModel,
navigator: INav,
) {
val followSetViewModel: NostrUserListFeedViewModel =
val followSetViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
FollowSetScreen(selectedSetIdentifier, followSetViewModel, accountViewModel, navigator)
@@ -105,7 +105,7 @@ fun FollowSetScreen(
@Composable
fun FollowSetScreen(
selectedSetIdentifier: String,
followSetViewModel: NostrUserListFeedViewModel,
followSetViewModel: FollowSetFeedViewModel,
accountViewModel: AccountViewModel,
navigator: INav,
) {
@@ -144,7 +144,7 @@ fun FollowSetScreen(
when {
selectedSetState.value != null -> {
val selectedSet = selectedSetState.value
val users = selectedSet!!.profileList.mapToUsers(accountViewModel).filterNotNull()
val users = selectedSet!!.profiles.mapToUsers(accountViewModel).filterNotNull()
Scaffold(
topBar = {
TopAppBar(
@@ -235,10 +235,10 @@ fun TitleAndDescription(
Icon(
painter =
painterResource(
when (followSet.listVisibility) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
when (followSet.setVisibility) {
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = null,

View File

@@ -78,13 +78,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip51Lists.followSets.SetVisibility
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.FollowSetFeedViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NewSetCreationDialog
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrUserListFeedViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
@@ -97,10 +97,10 @@ fun FollowSetsManagementDialog(
accountViewModel: AccountViewModel,
navigator: INav,
) {
val followSetViewModel: NostrUserListFeedViewModel =
val followSetViewModel: FollowSetFeedViewModel =
viewModel(
key = "NostrUserListFeedViewModel",
factory = NostrUserListFeedViewModel.Factory(accountViewModel.account),
key = "FollowSetFeedViewModel",
factory = FollowSetFeedViewModel.Factory(accountViewModel.account),
)
FollowSetsManagementDialog(userHex, followSetViewModel, accountViewModel.account, navigator)
@@ -110,7 +110,7 @@ fun FollowSetsManagementDialog(
@Composable
fun FollowSetsManagementDialog(
userHex: String,
followSetsViewModel: NostrUserListFeedViewModel,
followSetsViewModel: FollowSetFeedViewModel,
account: Account,
navigator: INav,
) {
@@ -164,17 +164,17 @@ fun FollowSetsManagementDialog(
.imePadding(),
) {
when (followSetsState) {
is FollowSetState.Loaded -> {
val lists = (followSetsState as FollowSetState.Loaded).feed
is FollowSetFeedState.Loaded -> {
val lists = (followSetsState as FollowSetFeedState.Loaded).feed
lists.forEachIndexed { index, list ->
Spacer(StdVertSpacer)
FollowSetItem(
modifier = Modifier.fillMaxWidth(),
listHeader = list.title,
listVisibility = list.visibility,
setVisibility = list.visibility,
userName = userInfo.toBestDisplayName(),
isUserInList = list.profileList.contains(userHex),
isUserInList = list.profiles.contains(userHex),
onRemoveUser = {
Log.d(
"Amethyst",
@@ -187,7 +187,7 @@ fun FollowSetsManagementDialog(
)
Log.d(
"Amethyst",
"Updated List. New size: ${list.profileList.size}",
"Updated List. New size: ${list.profiles.size}",
)
},
onAddUser = {
@@ -198,28 +198,28 @@ fun FollowSetsManagementDialog(
followSetsViewModel.addUserToSet(userHex, list, account)
Log.d(
"Amethyst",
"Updated List. New size: ${list.profileList.size}",
"Updated List. New size: ${list.profiles.size}",
)
},
)
}
}
FollowSetState.Empty -> {
FollowSetFeedState.Empty -> {
EmptyOrNoneFound { followSetsViewModel.refresh() }
}
is FollowSetState.FeedError -> {
val errorMsg = (followSetsState as FollowSetState.FeedError).errorMessage
is FollowSetFeedState.FeedError -> {
val errorMsg = (followSetsState as FollowSetFeedState.FeedError).errorMessage
ErrorMessage(errorMsg) { followSetsViewModel.refresh() }
}
FollowSetState.Loading -> {
FollowSetFeedState.Loading -> {
Loading()
}
}
if (followSetsState != FollowSetState.Loading) {
if (followSetsState != FollowSetFeedState.Loading) {
FollowSetsCreationMenu(
userName = userInfo.toBestDisplayName(),
onSetCreate = { setName, setIsPrivate, description ->
@@ -304,7 +304,7 @@ private fun ErrorMessage(
fun FollowSetItem(
modifier: Modifier = Modifier,
listHeader: String,
listVisibility: ListVisibility,
setVisibility: SetVisibility,
userName: String,
isUserInList: Boolean,
onAddUser: () -> Unit,
@@ -330,21 +330,21 @@ fun FollowSetItem(
) {
Text(listHeader, fontWeight = FontWeight.Bold)
Spacer(modifier = StdHorzSpacer)
listVisibility.let {
setVisibility.let {
val text by derivedStateOf {
when (it) {
ListVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
ListVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
ListVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
SetVisibility.Public -> stringRes(context, R.string.follow_set_type_public)
SetVisibility.Private -> stringRes(context, R.string.follow_set_type_private)
SetVisibility.Mixed -> stringRes(context, R.string.follow_set_type_mixed)
}
}
Icon(
painter =
painterResource(
when (listVisibility) {
ListVisibility.Public -> R.drawable.ic_public
ListVisibility.Private -> R.drawable.lock
ListVisibility.Mixed -> R.drawable.format_list_bulleted_type
when (setVisibility) {
SetVisibility.Public -> R.drawable.ic_public
SetVisibility.Private -> R.drawable.lock
SetVisibility.Mixed -> R.drawable.format_list_bulleted_type
},
),
contentDescription = stringRes(R.string.follow_set_type_description, text),