diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4962c47be..c10501084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.ots.OkHttpOtsResolverBuilder import com.vitorpamplona.amethyst.service.uploads.FileHeader +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 @@ -215,6 +216,7 @@ import kotlinx.coroutines.flow.StateFlow 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 @@ -839,6 +841,23 @@ class Account( } } + suspend fun getFollowSetNotes() = + withContext(Dispatchers.Default) { + val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile()) + userProfile().updateFollowSetNotes(followSetNotes) +// userProfile().followSets = followSetNotes + println("Number of follow sets: ${followSetNotes.size}") + } + + suspend fun mapNoteToFollowSet(note: Note): FollowSet = + FollowSet + .mapEventToSet( + event = note.event as PeopleListEvent, + signer, + ) + +// fun followSetNotesFlow() = MutableStateFlow(userProfile().followSets) + suspend fun updateAttestations() = sendAutomatic(otsState.updateAttestations()) suspend fun follow(user: User) = sendMyPublicAndPrivateOutbox(kind3FollowList.follow(user)) @@ -1609,60 +1628,6 @@ class Account( suspend fun hideUser(pubkeyHex: HexKey) { sendMyPublicAndPrivateOutbox(muteList.hideUser(pubkeyHex)) - fun getAppSpecificDataNote() = LocalCache.getOrCreateAddressableNote(AppSpecificDataEvent.createAddress(userProfile().pubkeyHex, APP_SPECIFIC_DATA_D_TAG)) - - fun getAppSpecificDataFlow(): StateFlow = getAppSpecificDataNote().flow().metadata.stateFlow - - fun getBlockListNote() = LocalCache.getOrCreateAddressableNote(PeopleListEvent.createBlockAddress(userProfile().pubkeyHex)) - - fun getMuteListNote() = LocalCache.getOrCreateAddressableNote(MuteListEvent.createAddress(userProfile().pubkeyHex)) - - suspend fun getFollowSetNotes() = - withContext(Dispatchers.Default) { - val followSetNotes = LocalCache.getFollowSetNotesFor(userProfile()) - userProfile().updateFollowSetNotes(followSetNotes) -// userProfile().followSets = followSetNotes - println("Number of follow sets: ${followSetNotes.size}") - } - - fun mapNoteToFollowSet(note: Note): FollowSet = - FollowSet - .mapEventToSet( - event = note.event as PeopleListEvent, - signer, - ) - -// fun followSetNotesFlow() = MutableStateFlow(userProfile().followSets) - - fun getMuteListFlow(): StateFlow = getMuteListNote().flow().metadata.stateFlow - - fun getBlockList(): PeopleListEvent? = getBlockListNote().event as? PeopleListEvent - - fun getMuteList(): MuteListEvent? = getMuteListNote().event as? MuteListEvent - - fun hideWord(word: String) { - val muteList = getMuteList() - - if (muteList != null) { - MuteListEvent.addWord( - earlierVersion = muteList, - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } else { - MuteListEvent.createListWithWord( - word = word, - isPrivate = true, - signer = signer, - ) { - Amethyst.instance.client.send(it) - LocalCache.consume(it, null) - } - } } suspend fun showUser(pubkeyHex: HexKey) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FollowSetFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FollowSetFeedFilter.kt index bba8dd44d..6ff2b403d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FollowSetFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FollowSetFeedFilter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -32,9 +32,10 @@ class FollowSetFeedFilter( override fun feedKey(): String = account.userProfile().pubkeyHex override fun feed(): List { - val userFollowSets = account.userProfile().followSetNotes - if (userFollowSets.isEmpty()) { - account.scope.launch { + val followSetCache = mutableListOf() + account.scope.launch { + val userFollowSets = account.userProfile().followSetNotes + if (userFollowSets.isEmpty()) { try { account.getFollowSetNotes() } catch (e: Exception) { @@ -43,8 +44,10 @@ class FollowSetFeedFilter( null } } + userFollowSets.map { account.mapNoteToFollowSet(it) }.forEach { + followSetCache.add(it) + } } - val followSets = userFollowSets.map { account.mapNoteToFollowSet(it) } - return followSets + return followSetCache.toList() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt index a01472009..f30c153dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FeedViewModel.kt @@ -23,116 +23,19 @@ package com.vitorpamplona.amethyst.ui.screen import android.util.Log import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.FeedFilter import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter -import com.vitorpamplona.amethyst.ui.dal.GeoHashFeedFilter -import com.vitorpamplona.amethyst.ui.dal.HashtagFeedFilter -import com.vitorpamplona.amethyst.ui.dal.NIP90ContentDiscoveryResponseFilter -import com.vitorpamplona.amethyst.ui.dal.ThreadFeedFilter import com.vitorpamplona.amethyst.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.NostrListFeedViewModel -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -class NostrChannelFeedViewModel( - val channel: Channel, - val account: Account, -) : FeedViewModel(ChannelFeedFilter(channel, account)) { - class Factory( - val channel: Channel, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChannelFeedViewModel = NostrChannelFeedViewModel(channel, account) as NostrChannelFeedViewModel - } -} - -class NostrChatroomFeedViewModel( - val user: ChatroomKey, - val account: Account, -) : FeedViewModel(ChatroomFeedFilter(user, account)) { - class Factory( - val user: ChatroomKey, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrChatRoomFeedViewModel = NostrChatroomFeedViewModel(user, account) as NostrChatRoomFeedViewModel - } -} - -class NostrThreadFeedViewModel( - account: Account, - noteId: String, -) : LevelFeedViewModel(ThreadFeedFilter(account, noteId)) { - class Factory( - val account: Account, - val noteId: String, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrThreadFeedViewModel = NostrThreadFeedViewModel(account, noteId) as NostrThreadFeedViewModel - } -} - -class NostrHashtagFeedViewModel( - val hashtag: String, - val account: Account, -) : FeedViewModel(HashtagFeedFilter(hashtag, account)) { - class Factory( - val hashtag: String, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrHashtagFeedViewModel = NostrHashtagFeedViewModel(hashtag, account) as NostrHashtagFeedViewModel - } -} - -class NostrGeoHashFeedViewModel( - val geohash: String, - val account: Account, -) : FeedViewModel(GeoHashFeedFilter(geohash, account)) { - class Factory( - val geohash: String, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrGeoHashFeedViewModel = NostrGeoHashFeedViewModel(geohash, account) as NostrGeoHashFeedViewModel - } -} - -class NostrCommunityFeedViewModel( - val note: AddressableNote, - val account: Account, -) : FeedViewModel(CommunityFeedFilter(note, account)) { - class Factory( - val note: AddressableNote, - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrCommunityFeedViewModel = NostrCommunityFeedViewModel(note, account) as NostrCommunityFeedViewModel - } -} - -@Stable -class NostrBookmarkPublicFeedViewModel( - val account: Account, -) : FeedViewModel(BookmarkPublicFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPublicFeedViewModel = NostrBookmarkPublicFeedViewModel(account) as NostrBookmarkPublicFeedViewModel - } -} - -@Stable -class NostrBookmarkPrivateFeedViewModel( - val account: Account, -) : FeedViewModel(BookmarkPrivateFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrBookmarkPrivateFeedViewModel = NostrBookmarkPrivateFeedViewModel(account) as NostrBookmarkPrivateFeedViewModel - } -} - @Stable class NostrUserListFeedViewModel( val account: Account, @@ -144,82 +47,6 @@ class NostrUserListFeedViewModel( } } -@Stable -class NostrNIP90ContentDiscoveryFeedViewModel( - val account: Account, - dvmkey: String, - requestid: String, -) : FeedViewModel(NIP90ContentDiscoveryResponseFilter(account, dvmkey, requestid)) { - class Factory( - val account: Account, - val dvmkey: String, - val requestid: String, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrNIP90ContentDiscoveryFeedViewModel = NostrNIP90ContentDiscoveryFeedViewModel(account, dvmkey, requestid) as NostrNIP90ContentDiscoveryFeedViewModel - } -} - -@Stable -class NostrDraftEventsFeedViewModel( - val account: Account, -) : FeedViewModel(DraftEventsFeedFilter(account)) { - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): NostrDraftEventsFeedViewModel = NostrDraftEventsFeedViewModel(account) as NostrDraftEventsFeedViewModel - } -} - -abstract class LevelFeedViewModel( - localFilter: FeedFilter, -) : FeedViewModel(localFilter) { - var llState: LazyListState by mutableStateOf(LazyListState(0, 0)) - - val hasDragged = mutableStateOf(false) - - val selectedIDHex = - llState.interactionSource.interactions - .onEach { - if (it is DragInteraction.Start) { - hasDragged.value = true - } - }.stateIn( - viewModelScope, - SharingStarted.Eagerly, - null, - ) - - @OptIn(ExperimentalCoroutinesApi::class) - val levelCacheFlow: StateFlow> = - feedState.feedContent - .transformLatest { feed -> - emitAll( - if (feed is FeedState.Loaded) { - feed.feed.map { - val cache = mutableMapOf() - it.list.forEach { - ThreadLevelCalculator.replyLevel(it, cache) - } - cache - } - } else { - MutableStateFlow(mapOf()) - }, - ) - }.flowOn(Dispatchers.Default) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - mapOf(), - ) - - fun levelFlowForItem(note: Note) = - levelCacheFlow - .map { - it[note] ?: 0 - }.distinctUntilChanged() -} - @Stable abstract class FeedViewModel( localFilter: FeedFilter, 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 d2a08444b..4a7d181be 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 @@ -177,25 +177,9 @@ class AccountViewModel( var firstRoute: Route? = null - // TODO: contact lists are not notes yet - // val kind3Relays: StateFlow = observeByAuthor(ContactListEvent.KIND, account.signer.pubKey) + val toastManager = ToastManager() - val normalizedKind3RelaySetFlow = - account - .userProfile() - .flow() - .relays.stateFlow - .map { contactListState -> - checkNotInMainThread() - contactListState.user.latestContactList?.relays()?.map { - RelayUrlFormatter.normalize(it.key) - } ?: emptySet() - }.flowOn(Dispatchers.Default) - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(10000, 10000), - emptySet(), - ) + val feedStates = AccountFeedContentStates(this) val followSetsFlow = account @@ -215,13 +199,6 @@ class AccountViewModel( emptyList(), ) - val dmRelays: StateFlow = observeByAuthor(ChatMessageRelayListEvent.KIND, account.signer.pubKey) - val searchRelays: StateFlow = observeByAuthor(SearchRelayListEvent.KIND, account.signer.pubKey) - - val toastManager = ToastManager() - - val feedStates = AccountFeedContentStates(this) - @OptIn(ExperimentalCoroutinesApi::class) val notificationHasNewItems = combineTransform( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt index 68b19bf22..fa6bfe54f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -63,18 +63,18 @@ import com.vitorpamplona.amethyst.ui.feeds.FeedEmpty import com.vitorpamplona.amethyst.ui.feeds.FeedError import com.vitorpamplona.amethyst.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox -import com.vitorpamplona.amethyst.ui.navigation.INav -import com.vitorpamplona.amethyst.ui.navigation.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.NostrUserListFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -118,7 +118,7 @@ fun ListsScreen( }, openItem = { currentCoroutineScope.launch(Dispatchers.IO) { - val note = followSetsViewModel.getFollowSetAddressable(it, accountViewModel.account) + val note = followSetsViewModel.getFollowSetNote(it, accountViewModel.account) if (note != null) { val event = note.event as PeopleListEvent println("Found list, with title: ${event.nameOrTitle()}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSet.kt index ce37ba19d..f262844ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSet.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -21,8 +21,9 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.value import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent @Stable data class FollowSet( @@ -33,7 +34,7 @@ data class FollowSet( val profileList: Set, ) : NostrList(listVisibility = visibility, content = profileList) { companion object { - fun mapEventToSet( + suspend fun mapEventToSet( event: PeopleListEvent, signer: NostrSigner, ): FollowSet { @@ -41,9 +42,12 @@ data class FollowSet( val dTag = event.dTag() val listTitle = event.nameOrTitle() ?: dTag val listDescription = event.description() ?: "" - val publicFollows = event.filterTagList("p", null) - val privateFollows = mutableListOf() - event.privateTaggedUsers(signer) { userList -> privateFollows.addAll(userList) } + val publicFollows = event.publicPeople().map { it.toTagArray() }.map { it.value() } + val privateFollows = + event + .privatePeople(signer) + ?.map { it.toTagArray() } + ?.map { it.value() } ?: emptyList() return if (publicFollows.isEmpty() && privateFollows.isNotEmpty()) { FollowSet( identifierTag = address.toValue(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetState.kt index 7f0c374dc..d39a66110 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetState.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/ListVisibility.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/ListVisibility.kt index ea2524f90..8948cd93b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/ListVisibility.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/ListVisibility.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrList.kt index 25c079441..e5fb44cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrList.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrListFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrListFeedViewModel.kt index 7c84c2747..53757a57a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrListFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/NostrListFeedViewModel.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 @@ -22,18 +22,22 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists import android.util.Log import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider 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.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.dal.FeedFilter +import com.vitorpamplona.amethyst.ui.dal.FollowSetFeedFilter import com.vitorpamplona.amethyst.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists import com.vitorpamplona.ammolite.relays.BundledUpdate -import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers @@ -42,9 +46,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID // TODO: Investigate the addition of feed filters, for bookmark sets and general ones. -abstract class NostrListFeedViewModel( +open class NostrListFeedViewModel( val dataSource: FeedFilter, ) : ViewModel(), InvalidatableContent { @@ -57,15 +62,28 @@ abstract class NostrListFeedViewModel( } } - fun getFollowSetAddressable( - addressValue: String, + fun getFollowSetNote( + noteIdentifier: String, account: Account, ): AddressableNote? { checkNotInMainThread() - val potentialNote = LocalCache.getAddressableNoteIfExists(Address.parse(addressValue)!!) + val potentialNote = account.userProfile().followSetNotes.find { it.dTag() == noteIdentifier } return potentialNote } + fun followSetExistsWithName( + setName: String, + account: Account, + ): Boolean { + checkNotInMainThread() + val potentialNote = + account + .userProfile() + .followSetNotes + .find { (it.event as PeopleListEvent).nameOrTitle() == setName } + return potentialNote != null + } + override val isRefreshing: MutableState = mutableStateOf(false) private fun refreshSuspended() { @@ -96,6 +114,111 @@ abstract class NostrListFeedViewModel( } } + fun addFollowSet( + setName: String, + setDescription: String?, + setType: ListVisibility, + account: Account, + ) { + if (account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + } else { + viewModelScope.launch(Dispatchers.IO) { + PeopleListEvent.createListWithDescription( + dTag = UUID.randomUUID().toString(), + title = setName, + description = setDescription, + isPrivate = setType == ListVisibility.Private, + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + + fun renameFollowSet( + newName: String, + followSet: FollowSet, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + } else { + viewModelScope.launch(Dispatchers.IO) { + val setEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent + PeopleListEvent.modifyListName( + earlierVersion = setEvent, + newName = newName, + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + + fun deleteFollowSet( + followSet: FollowSet, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + return + } else { + viewModelScope.launch(Dispatchers.IO) { + val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent + val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followSetEvent))) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + } + } + + fun addUserToSet( + userProfileHex: String, + followSet: FollowSet, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + return + } else { + viewModelScope.launch(Dispatchers.IO) { + val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent + PeopleListEvent.addUser( + earlierVersion = followSetEvent, + pubKeyHex = userProfileHex, + isPrivate = followSet.visibility == ListVisibility.Private, + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + + fun removeUserFromSet( + userProfileHex: String, + followSet: FollowSet, + account: Account, + ) { + if (!account.settings.isWriteable()) { + println("You are in read-only mode. Please login to make modifications.") + return + } else { + viewModelScope.launch(Dispatchers.IO) { + val followSetEvent = getFollowSetNote(followSet.identifierTag, account)?.event as PeopleListEvent + PeopleListEvent.removeUser( + earlierVersion = followSetEvent, + pubKeyHex = userProfileHex, + signer = account.signer, + ) { + account.sendMyPublicAndPrivateOutbox(it) + } + } + } + } + private fun updateFeed(sets: ImmutableList) { if (sets.isNotEmpty()) { _feedContent.update { FollowSetState.Loaded(sets) } @@ -104,13 +227,14 @@ abstract class NostrListFeedViewModel( } } - private val bundler = BundledUpdate(1000, Dispatchers.IO) + private val bundler = BundledUpdate(2000, Dispatchers.IO) override fun invalidateData(ignoreIfDoing: Boolean) { // refresh() bundler.invalidate(ignoreIfDoing) { // adds the time to perform the refresh into this delay // holding off new updates in case of heavy refresh routines. + refreshSuspended() } } @@ -135,4 +259,11 @@ abstract class NostrListFeedViewModel( collectorJob?.cancel() super.onCleared() } + + @Stable + class Factory( + val account: Account, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T = NostrListFeedViewModel(FollowSetFeedFilter(account)) as T + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/FollowSetsActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/FollowSetsActionMenu.kt index 6366643d6..87997757f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/FollowSetsActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/FollowSetsActionMenu.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * 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 diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt index a0c69898a..35a024698 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.nip51Lists.replaceAll import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag import com.vitorpamplona.quartz.nip51Lists.tags.NameTag import com.vitorpamplona.quartz.utils.TimeUtils @@ -214,5 +215,138 @@ class PeopleListEvent( initializer() } + + suspend fun createListWithDescription( + dTag: String, + title: String, + description: String? = null, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + if (description == null) { + val newList = + create( + name = title, + person = UserTag(pubKey = signer.pubKey), + isPrivate = isPrivate, + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) + onReady(newList) + } else { + if (isPrivate) { + val event = + build( + name = title, + privatePeople = listOf(UserTag(pubKey = signer.pubKey)), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) { + addUnique(arrayOf("description", description)) + } + val list = signer.sign(event) + onReady(list) + } else { + val event = + build( + name = title, + publicPeople = listOf(UserTag(pubKey = signer.pubKey)), + signer = signer, + dTag = dTag, + createdAt = createdAt, + ) { + addUnique(arrayOf("description", description)) + } + val list = signer.sign(event) + onReady(list) + } + } + } + + suspend fun createListWithUser( + name: String, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val newList = + create( + name = name, + person = UserTag(pubKey = pubKeyHex), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + onReady(newList) + } + + suspend fun addUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val newList = + add( + earlierVersion = earlierVersion, + person = UserTag(pubKey = pubKeyHex), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + onReady(newList) + } + + suspend fun removeUser( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit, + ) { + val updatedList = + remove( + earlierVersion = earlierVersion, + person = UserTag(pubKey = pubKeyHex), + signer = signer, + createdAt = createdAt, + ) + onReady(updatedList) + } + + suspend fun modifyListName( + earlierVersion: PeopleListEvent, + newName: String, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + onReady: (PeopleListEvent) -> Unit = {}, + ) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME } + val newTitleTag = + if (currentTitle[0] == NameTag.TAG_NAME) { + NameTag.assemble(newName) + } else { + com.vitorpamplona.quartz.nip51Lists.tags.TitleTag + .assemble(newName) + } + + val modified = + resign( + publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag), + privateTags = privateTags.replaceAll(currentTitle, newTitleTag), + signer = signer, + createdAt = createdAt, + ) + onReady(modified) + } } }