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 334949ff1..794a38bc1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -54,12 +54,10 @@ import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState -import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache 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 @@ -68,6 +66,9 @@ import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache @@ -133,7 +134,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder @@ -165,8 +165,6 @@ import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip47WalletConnect.Response -import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent @@ -267,7 +265,6 @@ class Account( val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) val kind3FollowList = Kind3FollowListState(signer, cache, scope, settings) - val followSetsState = FollowSetState(signer, cache, scope) val ephemeralChatListDecryptionCache = EphemeralChatListDecryptionCache(signer) val ephemeralChatList = EphemeralChatListState(signer, cache, ephemeralChatListDecryptionCache, scope, settings) @@ -289,6 +286,8 @@ class Account( val peopleListDecryptionCache = PeopleListDecryptionCache(signer) val blockPeopleList = BlockPeopleListState(signer, cache, peopleListDecryptionCache, scope) + val peopleListsState = PeopleListsState(signer, cache, peopleListDecryptionCache, scope) + val followListsState = FollowListsState(signer, cache, scope) val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) @@ -326,7 +325,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, followSetsState, hashtagList, geohashList, communityList, scope) + val allFollows = MergedFollowListsState(kind3FollowList, peopleListsState, hashtagList, geohashList, communityList, scope) val privateDMDecryptionCache = PrivateDMCache(signer) val privateZapsDecryptionCache = PrivateZapCache(signer) @@ -1742,22 +1741,6 @@ class Account( suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) - fun getAllPeopleLists(): List = getAllPeopleLists(signer.pubKey) - - fun getAllPeopleLists(pubkey: HexKey): List = - cache.addressables - .filter { _, addressableNote -> - val noteEvent = addressableNote.event - - if (noteEvent is PeopleListEvent) { - noteEvent.pubKey == pubkey && (noteEvent.hasAnyTaggedUser() || peopleListDecryptionCache.cachedUserIdSet(noteEvent).isNotEmpty()) - } else if (noteEvent is FollowListEvent) { - noteEvent.pubKey == pubkey && noteEvent.hasAnyTaggedUser() - } else { - false - } - } - fun markAsRead( route: String, timestampInSecs: Long, @@ -1797,14 +1780,18 @@ class Account( logTime("Account ${userProfile().toBestDisplayName()} newEventBundle Update with ${newNotes.size} new notes") { upgradeAttestations() newNotesPreProcessor.runNew(newNotes) + peopleListsState.newNotes(newNotes) + followListsState.newNotes(newNotes) } } } scope.launch { - cache.live.deletedEventBundles.collect { newNotes -> - logTime("Account ${userProfile().toBestDisplayName()} deletedEventBundle Update with ${newNotes.size} new notes") { - newNotesPreProcessor.runDeleted(newNotes) + cache.live.deletedEventBundles.collect { deletedNotes -> + logTime("Account ${userProfile().toBestDisplayName()} deletedEventBundle Update with ${deletedNotes.size} new notes") { + newNotesPreProcessor.runDeleted(deletedNotes) + peopleListsState.deletedNotes(deletedNotes) + followListsState.deletedNotes(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 32fe4d0d8..3a2085460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -300,6 +300,10 @@ object LocalCache : ILocalCache { fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull() + fun load(keys: List): List = keys.mapNotNull(::checkGetOrCreateUser) + + fun load(keys: Set): Set = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser) + fun getOrCreateUser(key: HexKey): User { require(isValidHex(key = key)) { "$key is not a valid hex" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt index fd4d927f7..38091bff4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Note.kt @@ -71,7 +71,9 @@ import com.vitorpamplona.quartz.utils.containsAny import com.vitorpamplona.quartz.utils.launchAndWaitAll import kotlinx.coroutines.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import java.math.BigDecimal @@ -994,3 +996,38 @@ class NoteBundledRefresherFlow( class NoteState( val note: Note, ) + +fun List.eventIdSet() = mapNotNullTo(mutableSetOf()) { it.event?.id } + +fun Array.events() = mapNotNull { it.note.event as? T } + +fun List.events() = mapNotNull { it.event as? T } + +fun List.updateFlow(): Flow> = + if (this.isEmpty()) { + MutableStateFlow(emptyList()) + } else { + combine( + flows = this.map { it.flow().metadata.stateFlow }, + ) { + it.events() + } + } + +public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolean { + if (this is Collection && isEmpty()) return false + for (note in this) { + val noteEvent = note.event as? T + if (noteEvent != null && predicate(noteEvent)) return true + } + return false +} + +public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { + if (this is Collection && isEmpty()) return false + for (note in this) { + val noteEvent = note.event + if (noteEvent != null && predicate(noteEvent)) return true + } + return false +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt index 22ea116b2..f4db82512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/BlockPeopleListState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.muteList.tags.MuteTag import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt deleted file mode 100644 index 0ec59bfd3..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSet.kt +++ /dev/null @@ -1,77 +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.amethyst.model.nip51Lists.followSets - -import androidx.compose.runtime.Stable -import com.vitorpamplona.quartz.nip01Core.core.value -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import kotlinx.coroutines.runBlocking - -@Stable -data class FollowSet( - val identifierTag: String, - val title: String, - val description: String?, - val visibility: SetVisibility = SetVisibility.Mixed, - val privateProfiles: Set = emptySet(), - val publicProfiles: Set = emptySet(), -) : NostrSet(setVisibility = visibility, privateContent = privateProfiles, publicContent = publicProfiles) { - companion object { - fun mapEventToSet( - event: PeopleListEvent, - signer: NostrSigner, - ): FollowSet { - val address = event.address() - val dTag = event.dTag() - val listTitle = event.nameOrTitle() ?: dTag - val listDescription = event.description() - val publicFollows = event.publicPeople().map { it.toTagArray() }.map { it.value() } - val privateFollows = - runBlocking { event.privatePeople(signer) } - ?.map { it.toTagArray() } - ?.map { it.value() } ?: emptyList() - return if (publicFollows.isEmpty() && privateFollows.isNotEmpty()) { - FollowSet( - identifierTag = dTag, - title = listTitle, - description = listDescription, - privateProfiles = privateFollows.toSet(), - ) - } else if (publicFollows.isNotEmpty() && privateFollows.isEmpty()) { - FollowSet( - identifierTag = dTag, - title = listTitle, - description = listDescription, - publicProfiles = publicFollows.toSet(), - ) - } else { - FollowSet( - identifierTag = dTag, - title = listTitle, - description = listDescription, - privateProfiles = privateFollows.toSet(), - publicProfiles = publicFollows.toSet(), - ) - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt deleted file mode 100644 index 360ec05c0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/FollowSetState.kt +++ /dev/null @@ -1,82 +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.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 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.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -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.IO) { - val followSetNotes = cache.getPeopleListNotesFor(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.IO) - - val profilesFlow = - getFollowSetNotesFlow() - .map { it -> - it.flatMapTo(mutableSetOf()) { it.privateProfiles + it.publicProfiles }.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 } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt deleted file mode 100644 index 13295f7c9..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/NostrSet.kt +++ /dev/null @@ -1,34 +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.amethyst.model.nip51Lists.followSets - -sealed class NostrSet( - val setVisibility: SetVisibility, - val privateContent: Collection, - val publicContent: Collection, -) - -class CuratedBookmarkSet( - val name: String, - val visibility: SetVisibility, - val privateSetItems: List, - val publicSetItems: List, -) : NostrSet(visibility, privateSetItems, publicSetItems) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/SetVisibility.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/SetVisibility.kt deleted file mode 100644 index fc8da1658..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/followSets/SetVisibility.kt +++ /dev/null @@ -1,27 +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.amethyst.model.nip51Lists.followSets - -enum class SetVisibility { - Public, - Private, - Mixed, -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt new file mode 100644 index 000000000..3e3f76bd3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/FollowListsState.kt @@ -0,0 +1,144 @@ +/** + * 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.peopleList + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.anyNotNullEvent +import com.vitorpamplona.amethyst.model.eventIdSet +import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.utils.flattenToSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update +import kotlin.collections.map + +/** + * Maintains several stateflows for each step in processing PeopleLists + * + * This class must receive updates from Large Cache as it receives new events. + */ +class FollowListsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val user = cache.getOrCreateUser(signer.pubKey) + + fun existingPeopleListNotes() = cache.addressables.filter(FollowListEvent.Companion.KIND, user.pubkeyHex) + + val followListVersions = MutableStateFlow(0) + + val followListNotes = + followListVersions + .map { existingPeopleListNotes() } + .onStart { emit(existingPeopleListNotes()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + + val followListsEventIds = + followListNotes + .map { it.eventIdSet() } + .onStart { emit(followListNotes.value.eventIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + + @OptIn(ExperimentalCoroutinesApi::class) + val latestLists: StateFlow> = + followListNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(followListNotes.value.events()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + + fun List.mapToUserIdSet() = this.map { it.followIdSet() }.flattenToSet() + + val allPeopleListProfiles: StateFlow> = + latestLists + .map { it.mapToUserIdSet() } + .onStart { emit(latestLists.value.mapToUserIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Companion.Eagerly, emptySet()) + + fun FollowListEvent.toUI() = + PeopleList( + identifierTag = this.dTag(), + title = this.title() ?: this.dTag(), + description = this.description(), + privateMembers = emptySet(), + publicMembers = cache.load(this.followIdSet()), + ) + + fun List.toUI() = this.map { it.toUI() } + + val uiListFlow = + latestLists + .map { it.toUI() } + .onStart { emit(latestLists.value.toUI()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Companion.Eagerly, emptyList()) + + fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) + + fun DeletionEvent.hasDeletedAnyFollowList() = deleteAddressesWithKind(FollowListEvent.Companion.KIND) || deletesAnyEventIn(followListsEventIds.value) + + fun hasItemInNoteList(notes: Set): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is FollowListEvent || (event is DeletionEvent && event.hasDeletedAnyFollowList()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + if (hasItemInNoteList(deletedNotes)) { + forceRefresh() + } + } + + fun forceRefresh() { + followListVersions.update { it + 1 } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt index 59109582b..e1da41772 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleList.kt @@ -18,20 +18,21 @@ * 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.peopleList -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.User +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toPersistentList -sealed class FollowSetFeedState { - data object Loading : FollowSetFeedState() - - data class Loaded( - val feed: List, - ) : FollowSetFeedState() - - data object Empty : FollowSetFeedState() - - data class FeedError( - val errorMessage: String, - ) : FollowSetFeedState() +@Stable +data class PeopleList( + val identifierTag: String, + val title: String, + val description: String?, + val privateMembers: Set = emptySet(), + val publicMembers: Set = emptySet(), +) { + val privateMembersList: ImmutableList = privateMembers.toPersistentList() + val publicMembersList: ImmutableList = publicMembers.toPersistentList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt similarity index 90% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt index 23d5ed4b3..30ab6b599 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/blockPeopleList/PeopleListDecryptionCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListDecryptionCache.kt @@ -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.model.nip51Lists.blockPeopleList +package com.vitorpamplona.amethyst.model.nip51Lists.peopleList import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEventCache @@ -53,4 +53,8 @@ class PeopleListDecryptionCache( suspend fun words(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWords() suspend fun wordSet(event: PeopleListEvent) = cachedPrivateLists.mergeTagList(event).mutedWordSet() + + suspend fun privateUsers(event: PeopleListEvent) = cachedPrivateLists.privateTags(event)?.mutedUsers() ?: emptyList() + + suspend fun privateUserIdSet(event: PeopleListEvent) = cachedPrivateLists.privateTags(event)?.mutedUserIdSet() ?: emptySet() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt new file mode 100644 index 000000000..47bce8c59 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/peopleList/PeopleListsState.kt @@ -0,0 +1,303 @@ +/** + * 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.peopleList + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.anyNotNullEvent +import com.vitorpamplona.amethyst.model.eventIdSet +import com.vitorpamplona.amethyst.model.events +import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.amethyst.model.updateFlow +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.tags.UserTag +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.utils.flattenToSet +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.flow.update +import java.util.UUID + +/** + * Maintains several stateflows for each step in processing PeopleLists + * + * This class must receive updates from Large Cache as it receives new events. + */ +class PeopleListsState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: PeopleListDecryptionCache, + val scope: CoroutineScope, +) { + val user = cache.getOrCreateUser(signer.pubKey) + + fun existingPeopleListNotes() = cache.addressables.filter(PeopleListEvent.KIND, user.pubkeyHex) + + val peopleListVersions = MutableStateFlow(0) + + val peopleListNotes = + peopleListVersions + .map { existingPeopleListNotes() } + .onStart { emit(existingPeopleListNotes()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val peopleListsEventIds = + peopleListNotes + .map { it.eventIdSet() } + .onStart { emit(peopleListNotes.value.eventIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + @OptIn(ExperimentalCoroutinesApi::class) + val latestLists: StateFlow> = + peopleListNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(peopleListNotes.value.events()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun PeopleListEvent.userIdSet() = decryptionCache.userIdSet(this) + + suspend fun List.mapToUserIdSet() = this.map { it.userIdSet() }.flattenToSet() + + val allPeopleListProfiles: StateFlow> = + latestLists + .map { it.mapToUserIdSet() } + .onStart { emit(latestLists.value.mapToUserIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + suspend fun PeopleListEvent.toUI() = + PeopleList( + identifierTag = this.dTag(), + title = this.nameOrTitle() ?: this.dTag(), + description = this.description(), + privateMembers = cache.load(decryptionCache.privateUserIdSet(this)), + publicMembers = cache.load(this.publicUsersIdSet()), + ) + + suspend fun List.toUI() = this.map { it.toUI() } + + val uiListFlow = + latestLists + .map { it.toUI() } + .onStart { emit(latestLists.value.toUI()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + fun selectListFlow(selectedDTag: String) = + uiListFlow + .map { peopleLists -> + peopleLists.firstOrNull { list -> + list.identifierTag == selectedDTag + } + }.onStart { + emit( + uiListFlow.value.firstOrNull { it.identifierTag == selectedDTag }, + ) + } + + fun isUserInFollowSets(user: User): Boolean = allPeopleListProfiles.value.contains(user.pubkeyHex) + + fun DeletionEvent.hasDeletedAnyPeopleList() = deleteAddressesWithKind(PeopleListEvent.KIND) || deletesAnyEventIn(peopleListsEventIds.value) + + fun hasItemInNoteList(notes: Set): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is PeopleListEvent || (event is DeletionEvent && event.hasDeletedAnyPeopleList()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + if (hasItemInNoteList(deletedNotes)) { + forceRefresh() + } + } + + fun forceRefresh() { + peopleListVersions.update { it + 1 } + } + + // -------------- + // Updating Lists + // -------------- + + fun getPeopleListNote(noteIdentifier: String): AddressableNote? = existingPeopleListNotes().find { it.dTag() == noteIdentifier } + + fun getPeopleList(noteIdentifier: String): PeopleListEvent = getPeopleListNote(noteIdentifier)?.event as PeopleListEvent + + fun User.toUserTag() = UserTag(this.pubkeyHex, this.bestRelayHint()) + + fun Set.toUserTags() = map { it.toUserTag() } + + suspend fun addFollowList( + listName: String, + listDescription: String?, + member: User? = null, + isPrivate: Boolean = false, + account: Account, + ) { + val newList = + PeopleListEvent.createListWithDescription( + dTag = UUID.randomUUID().toString(), + title = listName, + description = listDescription, + publicMembers = if (!isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), + privateMembers = if (isPrivate && member != null) listOf(member.toUserTag()) else emptyList(), + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun renameFollowList( + newName: String, + peopleList: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(peopleList.identifierTag) + val newList = + PeopleListEvent.modifyListName( + earlierVersion = listEvent, + newName = newName, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun modifyFollowSetDescription( + newDescription: String?, + peopleList: PeopleList, + account: Account, + ) { + val listEvent = getPeopleList(peopleList.identifierTag) + val newList = + PeopleListEvent.modifyDescription( + earlierVersion = listEvent, + newDescription = newDescription, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun cloneFollowSet( + currentPeopleList: PeopleList, + customCloneName: String?, + customCloneDescription: String?, + account: Account, + ) { + val newList = + PeopleListEvent.createListWithDescription( + dTag = UUID.randomUUID().toString(), + title = customCloneName ?: currentPeopleList.title, + description = customCloneDescription ?: currentPeopleList.description, + publicMembers = currentPeopleList.publicMembers.toUserTags(), + privateMembers = currentPeopleList.privateMembers.toUserTags(), + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun deleteFollowSet( + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val deletionEvent = account.signer.sign(DeletionEvent.build(listOf(followListEvent))) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + + suspend fun addUserToSet( + user: User, + identifierTag: String, + shouldBePrivateMember: Boolean, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val newList = + PeopleListEvent.addUser( + earlierVersion = followListEvent, + pubKeyHex = user.pubkeyHex, + relayHint = user.bestRelayHint(), + isPrivate = shouldBePrivateMember, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun addUserFirstToSet( + user: User, + identifierTag: String, + shouldBePrivateMember: Boolean, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val newList = + PeopleListEvent.addUserFirst( + earlierVersion = followListEvent, + pubKeyHex = user.pubkeyHex, + relayHint = user.bestRelayHint(), + isPrivate = shouldBePrivateMember, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } + + suspend fun removeUserFromSet( + user: User, + isPrivate: Boolean, + identifierTag: String, + account: Account, + ) { + val followListEvent = getPeopleList(identifierTag) + val newList = + PeopleListEvent.removeUser( + earlierVersion = followListEvent, + pubKeyHex = user.pubkeyHex, + isUserPrivate = isPrivate, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt index 0e93aa73c..3b025c79a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip72Communities/CommunityListState.kt @@ -79,6 +79,22 @@ class CommunityListState( emptySet(), ) + @OptIn(ExperimentalCoroutinesApi::class) + val flowNotes: StateFlow> = + flow + .map { hint -> + hint.map { + cache.getOrCreateAddressableNote(it.address) + } + }.onStart { + emit(flow.value.map { cache.getOrCreateAddressableNote(it.address) }) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptyList(), + ) + @OptIn(ExperimentalCoroutinesApi::class) val flowSet: StateFlow> = flow diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt index c794eb0bb..78f1461ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowListsState.kt @@ -22,9 +22,9 @@ package com.vitorpamplona.amethyst.model.serverList import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState -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.nip51Lists.peopleList.PeopleListsState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.quartz.nip72ModCommunities.follow.tags.CommunityTag import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId @@ -41,7 +41,7 @@ import kotlinx.coroutines.flow.stateIn class MergedFollowListsState( val kind3List: Kind3FollowListState, - val followSetList: FollowSetState, + val followSetList: PeopleListsState, val hashtagList: HashtagListState, val geohashList: GeohashListState, val communityList: CommunityListState, @@ -78,7 +78,7 @@ class MergedFollowListsState( val flow: StateFlow = combine( kind3List.flow, - followSetList.profilesFlow, + followSetList.allPeopleListProfiles, hashtagList.flow, geohashList.flow, communityList.flow, @@ -87,7 +87,7 @@ class MergedFollowListsState( emit( mergeLists( kind3List.flow.value, - followSetList.profilesFlow.value, + followSetList.allPeopleListProfiles.value, hashtagList.flow.value, geohashList.flow.value, communityList.flow.value, @@ -100,7 +100,7 @@ class MergedFollowListsState( SharingStarted.Eagerly, mergeLists( kind3List.flow.value, - followSetList.profilesFlow.value, + followSetList.allPeopleListProfiles.value, hashtagList.flow.value, geohashList.flow.value, communityList.flow.value, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt index 2ab279765..22cc69c34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedDecryptionCaches.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds -import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListDecryptionCache import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache class FeedDecryptionCaches( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 7b5932a5f..272c6d0d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -412,10 +412,10 @@ fun observeUserIsFollowing( // Subscribe in the relay for changes in the metadata of this user. UserFinderFilterAssemblerSubscription(user1, accountViewModel) val isUserInFollowSets = - remember(accountViewModel.account.followSetsState) { + remember(accountViewModel.account.peopleListsState) { // Only check follow sets if user1 is the logged-in user if (user1 == accountViewModel.account.userProfile()) { - accountViewModel.account.followSetsState.isUserInFollowSets(user2) + accountViewModel.account.peopleListsState.isUserInFollowSets(user2) } else { false } 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 deleted file mode 100644 index 7ca7271e1..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/FollowSetFeedFilter.kt +++ /dev/null @@ -1,46 +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.amethyst.ui.dal - -import android.util.Log -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSetState -import kotlinx.coroutines.runBlocking - -class FollowSetFeedFilter( - val followSetState: FollowSetState, -) : FeedFilter() { - override fun feedKey(): String = followSetState.user.pubkeyHex + "-followsets" - - override fun feed(): List = - runBlocking(followSetState.scope.coroutineContext) { - try { - 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) { - // if (e is CancellationException) throw e - Log.e(this@FollowSetFeedFilter.javaClass.simpleName, "Failed to load follow lists: ${e.message}") - throw e - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 4878a4722..1dce11d19 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -78,9 +78,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.ListsAndSetsScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.followsets.FollowSetScreen -import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.followsets.FollowSetsManagementDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.PeopleListScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit.EditPeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.NotificationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.publicMessages.NewPublicMessageScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScreen @@ -125,13 +125,9 @@ fun AppNavigation( composable { DiscoverScreen(accountViewModel, nav) } composable { NotificationScreen(accountViewModel, nav) } - composableFromEnd { ListsAndSetsScreen(accountViewModel, nav) } - composableFromEndArgs { - FollowSetScreen(it.setIdentifier, accountViewModel, nav) - } - composableFromEndArgs { - FollowSetsManagementDialog(it.userHexKey, accountViewModel, nav) - } + composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } + composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } + composableFromEndArgs { EditPeopleListScreen(it.userToAdd, accountViewModel, nav) } composable { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f70941252..1f309d01c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -436,7 +436,7 @@ fun ListContent( ) NavigationRow( - title = R.string.my_lists_and_sets, + title = R.string.my_lists, icon = ImageVector.vectorResource(R.drawable.format_list_bulleted_type), tint = MaterialTheme.colorScheme.onBackground, nav = nav, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c7e17f57d..fa30ee8b2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -54,12 +54,12 @@ sealed class Route { @Serializable object Lists : Route() - @Serializable data class FollowSetRoute( - val setIdentifier: String, + @Serializable data class PeopleListView( + val dTag: String, ) : Route() - @Serializable data class FollowSetManagement( - val userHexKey: String, + @Serializable data class PeopleListManagement( + val userToAdd: HexKey, ) : Route() @Serializable object EditProfile : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt index 7e862d7af..cb91b312c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserCompose.kt @@ -39,15 +39,12 @@ import com.vitorpamplona.amethyst.ui.theme.StdPadding @Composable fun UserCompose( baseUser: User, - overallModifier: Modifier = StdPadding, + modifier: Modifier = StdPadding, accountViewModel: AccountViewModel, nav: INav, ) { Row( - modifier = - overallModifier.clickable( - onClick = { nav.nav(routeFor(baseUser)) }, - ), + modifier = modifier.clickable { nav.nav(routeFor(baseUser)) }, verticalAlignment = Alignment.CenterVertically, ) { UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) @@ -65,3 +62,26 @@ fun UserCompose( } } } + +@Composable +fun UserComposeNoAction( + baseUser: User, + modifier: Modifier = StdPadding, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = modifier.clickable { nav.nav(routeFor(baseUser)) }, + verticalAlignment = Alignment.CenterVertically, + ) { + UserPicture(baseUser, Size55dp, accountViewModel = accountViewModel, nav = nav) + + Column(modifier = remember { Modifier.padding(start = 10.dp).weight(1f) }) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay(baseUser, accountViewModel = accountViewModel) + } + + AboutDisplay(baseUser, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt index f125148b8..95b6cdca9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/userSuggestions/ShowUserSuggestionList.kt @@ -125,12 +125,15 @@ fun UserLine( ) { Row( modifier = - Modifier.fillMaxWidth().clickable(onClick = onClick).padding( - start = 12.dp, - end = 12.dp, - top = 10.dp, - bottom = 10.dp, - ), + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + bottom = 10.dp, + ), ) { ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 18699875f..98043a7b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -187,7 +187,7 @@ fun NoteDropDownMenu( text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) }, onClick = { val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem - nav.nav(Route.FollowSetManagement(authorHexKey)) + nav.nav(Route.PeopleListManagement(authorHexKey)) onDismiss() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt similarity index 71% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index d98cc6277..c3d257757 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/FollowListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -30,8 +30,6 @@ import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes @@ -40,7 +38,6 @@ import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -63,16 +60,17 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @Stable -class FollowListState( +class TopNavFilterState( val account: Account, - val viewModelScope: CoroutineScope, + val scope: CoroutineScope, ) { val kind3Follow = PeopleListOutBoxFeedDefinition( @@ -119,10 +117,12 @@ class FollowListState( val defaultLists = persistentListOf(kind3Follow, kind3FollowUsers, aroundMe, globalFollow, muteListFollow) - fun getPeopleLists(): List = - account - .getAllPeopleLists() - .map { + fun mergePeopleLists( + peopleLists: List, + followLists: List, + ): List { + val peopleListsDefs = + peopleLists.map { PeopleListOutBoxFeedDefinition( it.idHex, PeopleListName(it), @@ -130,100 +130,101 @@ class FollowListState( kinds = DEFAULT_FEED_KINDS, listOf(it.idHex), ) - }.sortedBy { it.name.name() } + } - val livePeopleListsFlow = MutableStateFlow(emptyList()) - - fun hasItemInNoteList(notes: Set): Boolean = - notes.any { it -> - val noteEvent = it.event - - noteEvent?.pubKey == account.userProfile().pubkeyHex && - ( - ( - noteEvent is PeopleListEvent || - noteEvent is FollowListEvent || - noteEvent is MuteListEvent || - noteEvent is ContactListEvent - ) || - ( - noteEvent is DeletionEvent && - ( - noteEvent.deleteEventIds().any { LocalCache.getNoteIfExists(it)?.event is PeopleListEvent } || - noteEvent.deleteAddresses().any { it.kind == PeopleListEvent.KIND } - ) - ) + val followListsDefs = + followLists.map { + PeopleListOutBoxFeedDefinition( + it.idHex, + PeopleListName(it), + CodeNameType.PEOPLE_LIST, + kinds = DEFAULT_FEED_KINDS, + listOf(it.idHex), ) - } + } - fun deleteFromFeed(deletedNotes: Set) { - checkNotInMainThread() - - if (hasItemInNoteList(deletedNotes)) { - livePeopleListsFlow.tryEmit(getPeopleLists()) - } + return (peopleListsDefs + followListsDefs).sortedBy { it.name.name() } } - fun updateFeedWith(newNotes: Set) { - checkNotInMainThread() - - if (hasItemInNoteList(newNotes)) { - livePeopleListsFlow.tryEmit(getPeopleLists()) + val livePeopleListsFlow: Flow> = + combine( + account.peopleListsState.peopleListNotes, + account.followListsState.followListNotes, + ::mergePeopleLists, + ).onStart { + emit( + mergePeopleLists( + account.peopleListsState.peopleListNotes.value, + account.followListsState.followListNotes.value, + ), + ) } + + fun mergeInterests( + hashtagList: Set, + geotagList: Set, + communityList: List, + ): List { + val hashtags = + hashtagList.map { + TagFeedDefinition( + "Hashtag/$it", + HashtagName(it), + CodeNameType.ROUTE, + route = Route.Hashtag(it), + kinds = DEFAULT_FEED_KINDS, + tTags = listOf(it), + ) + } + + val geotags = + geotagList.map { + TagFeedDefinition( + "Geohash/$it", + GeoHashName(it), + CodeNameType.ROUTE, + route = Route.Geohash(it), + kinds = DEFAULT_FEED_KINDS, + gTags = listOf(it), + ) + } + + val communities = + communityList.map { communityNote -> + TagFeedDefinition( + "Community/${communityNote.idHex}", + CommunityName(communityNote), + CodeNameType.ROUTE, + route = Route.Community(communityNote.address.kind, communityNote.address.pubKeyHex, communityNote.address.dTag), + kinds = DEFAULT_COMMUNITY_FEEDS, + aTags = listOf(communityNote.idHex), + ) + } + + return (communities + hashtags + geotags).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) - val liveKind3FollowsFlow: Flow> = - combineTransform(account.kind3FollowList.flow, account.hashtagList.flow, account.geohashList.flow, account.communityList.flow) { kind3List, hashtagList, geotagList, communityList -> - checkNotInMainThread() - - val communities = - communityList.map { - LocalCache.getOrCreateAddressableNote(it.address).let { communityNote -> - TagFeedDefinition( - "Community/${communityNote.idHex}", - CommunityName(communityNote), - CodeNameType.ROUTE, - route = Route.Community(communityNote.address.kind, communityNote.address.pubKeyHex, communityNote.address.dTag), - kinds = DEFAULT_COMMUNITY_FEEDS, - aTags = listOf(communityNote.idHex), - ) - } - } - - val hashtags = - hashtagList.map { - TagFeedDefinition( - "Hashtag/$it", - HashtagName(it), - CodeNameType.ROUTE, - route = Route.Hashtag(it), - kinds = DEFAULT_FEED_KINDS, - tTags = listOf(it), - ) - } - - val geotags = - geotagList.map { - TagFeedDefinition( - "Geohash/$it", - GeoHashName(it), - CodeNameType.ROUTE, - route = Route.Geohash(it), - kinds = DEFAULT_FEED_KINDS, - gTags = listOf(it), - ) - } - + val liveInterestFlows: Flow> = + combine( + account.hashtagList.flow, + account.geohashList.flow, + account.communityList.flowNotes, + ::mergeInterests, + ).onStart { emit( - (communities + hashtags + geotags).sortedBy { it.name.name() }, + mergeInterests( + account.hashtagList.flow.value, + account.geohashList.flow.value, + account.communityList.flowNotes.value, + ), ) } private val _kind3GlobalPeopleRoutes = combineTransform( livePeopleListsFlow, - liveKind3FollowsFlow, + liveInterestFlows, ) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> checkNotInMainThread() emit( @@ -239,7 +240,7 @@ class FollowListState( private val _kind3GlobalPeople = combineTransform( livePeopleListsFlow, - liveKind3FollowsFlow, + liveInterestFlows, ) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> checkNotInMainThread() emit( @@ -254,17 +255,12 @@ class FollowListState( val kind3GlobalPeopleRoutes = _kind3GlobalPeopleRoutes .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) + .stateIn(scope, SharingStarted.Eagerly, defaultLists) + val kind3GlobalPeople = _kind3GlobalPeople .flowOn(Dispatchers.IO) - .stateIn(viewModelScope, SharingStarted.Eagerly, defaultLists) - - suspend fun initializeSuspend() { - checkNotInMainThread() - - livePeopleListsFlow.emit(getPeopleLists()) - } + .stateIn(scope, SharingStarted.Eagerly, defaultLists) fun destroy() { Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt index 79d6d7da0..10982b6c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListKnownFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.dal.ChatroomListNewFeedFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm.DiscoverLongFormFeedFilter @@ -69,13 +69,12 @@ class AccountFeedContentStates( val notifications = CardFeedContentState(NotificationFeedFilter(account), scope) val notificationSummary = NotificationSummaryState(account) - val feedListOptions = FollowListState(account, scope) + val feedListOptions = TopNavFilterState(account, scope) val drafts = FeedContentState(DraftEventsFeedFilter(account), scope) suspend fun init() { notificationSummary.initializeSuspend() - feedListOptions.initializeSuspend() } fun updateFeedsWith(newNotes: Set) { @@ -101,8 +100,6 @@ class AccountFeedContentStates( notifications.updateFeedWith(newNotes) notificationSummary.invalidateInsertData(newNotes) - feedListOptions.updateFeedWith(newNotes) - drafts.updateFeedWith(newNotes) } @@ -129,8 +126,6 @@ class AccountFeedContentStates( notifications.deleteFromFeed(newNotes) notificationSummary.invalidateInsertData(newNotes) - feedListOptions.deleteFromFeed(newNotes) - drafts.deleteFromFeed(newNotes) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt index d412f75c1..dfc8bb0b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/header/RenderRoomTopBar.kt @@ -156,7 +156,7 @@ fun GroupMembersHeader( if (it != null) { UserCompose( baseUser = it, - overallModifier = lineModifier, + modifier = lineModifier, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 2101fe7b6..0718e0265 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -41,7 +41,7 @@ fun DiscoveryTopBar( val list by accountViewModel.account.settings.defaultDiscoveryFollowList .collectAsStateWithLifecycle() - FollowList( + TopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -51,8 +51,8 @@ fun DiscoveryTopBar( } @Composable -private fun FollowList( - followListsModel: FollowListState, +private fun TopNavFilterBar( + followListsModel: TopNavFilterState, listName: String, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index bff05caa9..ff1110fac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index c61f41794..5bc35e195 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -41,7 +41,7 @@ fun HomeTopBar( val list by accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - FollowList( + TopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -56,8 +56,8 @@ fun HomeTopBar( } @Composable -private fun FollowList( - followListsModel: FollowListState, +private fun TopNavFilterBar( + followListsModel: TopNavFilterState, listName: String, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt deleted file mode 100644 index d9bc4d2fe..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedView.kt +++ /dev/null @@ -1,152 +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.amethyst.ui.screen.loggedIn.lists - -import android.util.Log -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Text -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 -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer - -@Composable -fun FollowSetFeedView( - modifier: Modifier = Modifier, - followSetFeedState: FollowSetFeedState, - onRefresh: () -> Unit = {}, - onOpenItem: (String) -> Unit = {}, - onRenameItem: (targetSet: FollowSet, newName: String) -> Unit, - onItemDescriptionChange: (followSet: FollowSet, newDescription: String?) -> Unit, - onItemClone: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, - onDeleteItem: (followSet: FollowSet) -> Unit, -) { - when (followSetFeedState) { - FollowSetFeedState.Loading -> LoadingFeed() - - is FollowSetFeedState.Loaded -> { - val followSetFeed = followSetFeedState.feed - FollowSetLoaded( - loadedFeedState = followSetFeed, - onRefresh = onRefresh, - onItemClick = onOpenItem, - onItemRename = onRenameItem, - onItemDescriptionChange = onItemDescriptionChange, - onItemClone = onItemClone, - onItemDelete = onDeleteItem, - ) - } - - is FollowSetFeedState.Empty -> { - FollowSetFeedEmpty( - message = stringRes(R.string.follow_set_empty_feed_msg), - ) { - onRefresh() - } - } - - is FollowSetFeedState.FeedError -> - FeedError( - followSetFeedState.errorMessage, - ) { - onRefresh() - } - } -} - -@Composable -fun FollowSetLoaded( - modifier: Modifier = Modifier, - loadedFeedState: List, - onRefresh: () -> Unit = {}, - onItemClick: (itemIdentifier: String) -> Unit = {}, - onItemRename: (followSet: FollowSet, newName: String) -> Unit, - onItemDescriptionChange: (followSet: FollowSet, newDescription: String?) -> Unit, - onItemClone: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, - onItemDelete: (followSet: FollowSet) -> Unit, -) { - Log.d("FollowSetComposable", "FollowSetLoaded: Follow Set size: ${loadedFeedState.size}") - - val listState = rememberLazyListState() - RefresheableBox( - onRefresh = onRefresh, - ) { - LazyColumn( - state = listState, - contentPadding = FeedPadding, - ) { - itemsIndexed(loadedFeedState, key = { _, item -> item.identifierTag }) { _, set -> - CustomSetItem( - modifier = Modifier.fillMaxSize().animateItem(), - followSet = set, - onFollowSetClick = { - onItemClick(set.identifierTag) - }, - onFollowSetRename = { - onItemRename(set, it) - }, - onFollowSetDescriptionChange = { newDescription -> - onItemDescriptionChange(set, newDescription) - }, - onFollowSetClone = { cloneName, cloneDescription -> - onItemClone(set, cloneName, cloneDescription) - }, - onFollowSetDelete = { - onItemDelete(set) - }, - ) - HorizontalDivider(thickness = DividerThickness) - } - } - } -} - -@Composable -fun FollowSetFeedEmpty( - message: String = stringRes(R.string.feed_is_empty), - onRefresh: () -> Unit, -) { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(message) - Spacer(modifier = StdVertSpacer) - OutlinedButton(onClick = onRefresh) { Text(text = stringRes(R.string.refresh)) } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt deleted file mode 100644 index f7aae1c45..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/FollowSetFeedViewModel.kt +++ /dev/null @@ -1,321 +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.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.model.nip51Lists.followSets.FollowSet -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.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import java.util.UUID - -class FollowSetFeedViewModel( - val dataSource: FeedFilter, -) : ViewModel(), - InvalidatableContent { - private val _feedContent = MutableStateFlow(FollowSetFeedState.Loading) - val feedContent = _feedContent.asStateFlow() - - fun refresh() { - viewModelScope.launch(Dispatchers.IO) { - refreshSuspended() - } - } - - fun getFollowSetNote( - noteIdentifier: String, - account: Account, - ): AddressableNote? { - val potentialNote = - runBlocking(Dispatchers.IO) { account.followSetsState.getFollowSetNotes() } - .find { it.dTag() == noteIdentifier } - return potentialNote - } - - fun followSetExistsWithName( - setName: String, - account: Account, - ): Boolean { - val potentialNote = - runBlocking(viewModelScope.coroutineContext) { account.followSetsState.getFollowSetNotes() } - .find { (it.event as PeopleListEvent).nameOrTitle() == setName } - return potentialNote != null - } - - override val isRefreshing: MutableState = mutableStateOf(false) - - private fun refreshSuspended() { - checkNotInMainThread() - try { - isRefreshing.value = true - val oldFeedState = _feedContent.value - - val newSets = dataSource.loadTop().toImmutableList() - - if (oldFeedState is FollowSetFeedState.Loaded) { - val oldFeedList = oldFeedState.feed.toImmutableList() - // Using size as a proxy for has changed. - if (!equalImmutableLists(newSets, oldFeedList)) { - updateFeed(newSets) - } - } else { - updateFeed(newSets) - } - } catch (e: Exception) { - Log.e( - this.javaClass.simpleName, - "refreshSuspended: Error loading or refreshing feed -> ${e.message}", - ) - _feedContent.update { FollowSetFeedState.FeedError(e.message.toString()) } - } finally { - isRefreshing.value = false - } - } - - fun addFollowSet( - setName: String, - setDescription: String?, - optionalFirstMemberHex: String? = null, - firstMemberShouldBePrivate: Boolean = false, - 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 = firstMemberShouldBePrivate, - firstPublicMembers = if (optionalFirstMemberHex != null) listOf(optionalFirstMemberHex) else emptyList(), - firstPrivateMembers = if (optionalFirstMemberHex != null) listOf(optionalFirstMemberHex) else emptyList(), - 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 modifyFollowSetDescription( - newDescription: 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.modifyDescription( - earlierVersion = setEvent, - newDescription = newDescription, - signer = account.signer, - ) { - account.sendMyPublicAndPrivateOutbox(it) - } - } - } - } - - fun cloneFollowSet( - currentFollowSet: FollowSet, - customCloneName: String?, - customCloneDescription: String?, - account: Account, - ) { - if (!account.settings.isWriteable()) { - println("You are in read-only mode. Please login to make modifications.") - } else { - viewModelScope.launch(Dispatchers.IO) { - PeopleListEvent.copy( - dTag = UUID.randomUUID().toString(), - title = customCloneName ?: currentFollowSet.title, - description = customCloneDescription ?: currentFollowSet.description, - firstPublicMembers = currentFollowSet.publicProfiles.toList(), - firstPrivateMembers = currentFollowSet.privateProfiles.toList(), - 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, - shouldBePrivateMember: Boolean, - 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 = shouldBePrivateMember, - signer = account.signer, - ) { - account.sendMyPublicAndPrivateOutbox(it) - } - } - } - } - - fun removeUserFromSet( - userProfileHex: String, - userIsPrivate: Boolean, - 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, - isUserPrivate = userIsPrivate, - signer = account.signer, - ) { - account.sendMyPublicAndPrivateOutbox(it) - } - } - } - } - - private fun updateFeed(sets: ImmutableList) { - if (sets.isNotEmpty()) { - _feedContent.update { FollowSetFeedState.Loaded(sets) } - } else { - _feedContent.update { FollowSetFeedState.Empty } - } - } - - private val bundler = BundledUpdate(2000, Dispatchers.IO) - - override fun invalidateData(ignoreIfDoing: Boolean) { - bundler.invalidate(ignoreIfDoing) { - // adds the time to perform the refresh into this delay - // holding off new updates in case of heavy refresh routines. - - refreshSuspended() - } - } - - var collectorJob: Job? = null - - init { - Log.d("Init", this.javaClass.simpleName) - Log.d(this.javaClass.simpleName, " FollowSetFeedState : ${_feedContent.value}") - collectorJob = - viewModelScope.launch(Dispatchers.IO) { - LocalCache.live.newEventBundles.collect { newNotes -> - - invalidateData() - } - } - } - - override fun onCleared() { - Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") - bundler.cancel() - collectorJob?.cancel() - super.onCleared() - } - - @Stable - class Factory( - val account: Account, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T = - FollowSetFeedViewModel( - FollowSetFeedFilter(account.followSetsState), - ) as T - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt new file mode 100644 index 000000000..664dcc0e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListScreen.kt @@ -0,0 +1,729 @@ +/** + * 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.ui.screen.loggedIn.lists.display + +import android.annotation.SuppressLint +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults.cardElevation +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.searchCommand.UserSearchDataSourceSubscription +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.AboutDisplay +import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.note.ClearTextIcon +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.UserComposeNoAction +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.AnimateOnNewSearch +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ButtonBorder +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier +import com.vitorpamplona.amethyst.ui.theme.HalfPadding +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer +import com.vitorpamplona.amethyst.ui.theme.LightRedColor +import com.vitorpamplona.amethyst.ui.theme.PopupUpEffect +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.SmallBorder +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.StdPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.TabRowHeight +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import java.lang.reflect.Modifier.isPrivate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeopleListScreen( + selectedDTag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: PeopleListViewModel = viewModel() + viewModel.init(accountViewModel.account, selectedDTag) + + val pagerState = rememberPagerState { 2 } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { + TitleAndDescription(viewModel) + }, + navigationIcon = { + IconButton(nav::popBack) { + ArrowBackIcon() + } + }, + actions = { + ListActionsMenuButton(viewModel, accountViewModel, nav) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + TabRow( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + selectedTabIndex = pagerState.currentPage, + modifier = TabRowHeight, + ) { + val scope = rememberCoroutineScope() + Tab( + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } }, + text = { Text(text = stringRes(R.string.private_members)) }, + ) + Tab( + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } }, + text = { Text(text = stringRes(R.string.public_members)) }, + ) + } + } + }, + ) { padding -> + ListViewAndEditColumn( + viewModel = viewModel, + pagerState = pagerState, + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ).consumeWindowInsets(padding) + .imePadding(), + accountViewModel = accountViewModel, + nav = nav, + ) + } +} + +@Composable +fun TitleAndDescription(viewModel: PeopleListViewModel) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + TitleAndDescription(selectedSet.title, selectedSet.description) + } +} + +@Composable +fun TitleAndDescription( + title: String, + description: String?, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title) + Spacer(DoubleHorzSpacer) + Icon( + painter = painterResource(R.drawable.format_list_bulleted_type), + contentDescription = null, + ) + } + + if (description != null) { + Text( + text = description, + fontSize = 14.sp, + fontWeight = FontWeight.Thin, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ListViewAndEditColumn( + viewModel: PeopleListViewModel, + pagerState: PagerState, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + Column(modifier = modifier) { + FollowSetListView( + viewModel = viewModel, + pagerState = pagerState, + modifier = Modifier.weight(1f), + onDeleteUser = { user, isPrivate -> + accountViewModel.runIOCatching { + viewModel.removeUserFromSet(user, isPrivate) + } + }, + accountViewModel = accountViewModel, + nav = nav, + ) + + RenderAddUserFieldAndSuggestions(viewModel, pagerState, accountViewModel) + } +} + +@Composable +private fun RenderAddUserFieldAndSuggestions( + viewModel: PeopleListViewModel, + pagerState: PagerState, + accountViewModel: AccountViewModel, +) { + UserSearchDataSourceSubscription(viewModel.userSuggestions, accountViewModel) + + LaunchedEffect(Unit) { + launch(Dispatchers.IO) { + LocalCache.live.newEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + launch(Dispatchers.IO) { + LocalCache.live.deletedEventBundles.collect { + viewModel.userSuggestions.invalidateData() + } + } + } + + Spacer(HalfVertSpacer) + + UserSearchField(viewModel.userSuggestions) + + ShowUserSuggestions( + userSuggestions = viewModel.userSuggestions, + hasUserFlow = { user -> + viewModel.hasUserFlow(user, pagerState.currentPage == 0) + }, + onSelect = { user -> + accountViewModel.runIOCatching { + viewModel.addUserToSet(user, pagerState.currentPage == 0) + } + }, + onDelete = { user -> + accountViewModel.runIOCatching { + viewModel.removeUserFromSet(user, pagerState.currentPage == 0) + } + }, + accountViewModel = accountViewModel, + ) +} + +@Composable +private fun FollowSetListView( + viewModel: PeopleListViewModel, + pagerState: PagerState, + modifier: Modifier, + onDeleteUser: (User, Boolean) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val selectedSetState = viewModel.selectedList.collectAsStateWithLifecycle() + selectedSetState.value?.let { selectedSet -> + HorizontalPager(state = pagerState, modifier) { page -> + when (page) { + 0 -> + FollowSetListView( + memberList = selectedSet.privateMembersList, + onDeleteUser = { user -> + onDeleteUser(user, true) + }, + modifier = Modifier.fillMaxSize(), + accountViewModel = accountViewModel, + nav = nav, + ) + + 1 -> + FollowSetListView( + memberList = selectedSet.publicMembersList, + onDeleteUser = { user -> + onDeleteUser(user, false) + }, + modifier = Modifier.fillMaxSize(), + accountViewModel = accountViewModel, + nav = nav, + ) + } + } + } +} + +@Composable +@Preview(device = "spec:width=2160px,height=2940px,dpi=440") +fun FollowSetListViewPreview() { + val accountViewModel = mockAccountViewModel() + + val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf") + + ThemeComparisonRow { + Column { + FollowSetListView( + memberList = persistentListOf(user1, user2, user3), + onDeleteUser = { user -> }, + accountViewModel = accountViewModel, + nav = EmptyNav, + ) + + Spacer(HalfVertSpacer) + + var userName by remember { mutableStateOf("") } + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = + Modifier + .padding(horizontal = Size10dp) + .fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = {}, + ) { + ClearTextIcon() + } + }, + ) + + Card( + modifier = Modifier.padding(horizontal = 10.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = Modifier.heightIn(0.dp, 200.dp), + ) { + itemsIndexed(persistentListOf(user1, user2, user3), key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser( + baseUser, + { MutableStateFlow(false) }, + {}, + {}, + accountViewModel, + ) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + } +} + +@Composable +private fun FollowSetListView( + memberList: ImmutableList, + modifier: Modifier = Modifier, + onDeleteUser: (User) -> Unit, + accountViewModel: AccountViewModel, + nav: INav, +) { + val listState = rememberLazyListState() + + LazyColumn( + modifier = modifier, + contentPadding = FeedPadding, + state = listState, + ) { + itemsIndexed(memberList, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> + FollowSetListItem( + modifier = Modifier.animateContentSize(), + user = item, + accountViewModel = accountViewModel, + nav = nav, + onDeleteUser = onDeleteUser, + ) + } + } +} + +@Composable +fun ShowUserSuggestions( + userSuggestions: UserSuggestionState, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + val listState = rememberLazyListState() + + AnimateOnNewSearch(userSuggestions, listState) + + val suggestions by userSuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + Card( + modifier = Modifier.padding(start = 11.dp, end = 11.dp), + elevation = cardElevation(5.dp), + shape = PopupUpEffect, + ) { + LazyColumn( + contentPadding = PaddingValues(top = 10.dp), + modifier = + Modifier + .heightIn(0.dp, 200.dp), + state = listState, + ) { + itemsIndexed(suggestions, key = { _, item -> item.pubkeyHex }) { _, baseUser -> + DrawUser(baseUser, hasUserFlow, onSelect, onDelete, accountViewModel) + + HorizontalDivider( + thickness = DividerThickness, + ) + } + } + } + } + + Spacer(StdVertSpacer) +} + +@Composable +private fun DrawUser( + baseUser: User, + hasUserFlow: (User) -> Flow, + onSelect: (User) -> Unit, + onDelete: (User) -> Unit, + accountViewModel: AccountViewModel, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = { onSelect(baseUser) }) + .padding( + start = 12.dp, + end = 12.dp, + top = 10.dp, + bottom = 10.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ClickableUserPicture(baseUser, 55.dp, accountViewModel, Modifier, null) + + Column( + modifier = + Modifier + .padding(start = 10.dp) + .weight(1f), + verticalArrangement = Arrangement.Center, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + UsernameDisplay( + baseUser, + accountViewModel = accountViewModel, + ) + HasUserTag(baseUser, hasUserFlow, onDelete) + } + + AboutDisplay(baseUser, accountViewModel) + } + } +} + +@Composable +fun RowScope.HasUserTag( + baseUser: User, + hasUserFlow: (User) -> Flow, + onDelete: (User) -> Unit, +) { + val hasUserState by hasUserFlow(baseUser).collectAsStateWithLifecycle(false) + if (hasUserState) { + Spacer(StdHorzSpacer) + Text( + text = stringRes(id = R.string.in_the_list), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + remember { + Modifier + .clip(SmallBorder) + .background(Color.Black) + .padding(horizontal = 5.dp) + }, + ) + Spacer(Modifier.weight(1f)) + IconButton( + modifier = Modifier.size(30.dp).padding(start = 10.dp), + onClick = { onDelete(baseUser) }, + ) { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = stringRes(id = R.string.remove), + modifier = Modifier.size(15.dp), + tint = LightRedColor, + ) + } + } +} + +@Composable +fun FollowSetListItem( + modifier: Modifier = Modifier, + user: User, + accountViewModel: AccountViewModel, + nav: INav, + onDeleteUser: (User) -> Unit, +) { + Column( + modifier = modifier, + ) { + Row(HalfHalfHorzModifier) { + UserComposeNoAction( + user, + modifier = HalfPadding.weight(1f, fill = false), + accountViewModel = accountViewModel, + nav = nav, + ) + IconButton( + onClick = { + onDeleteUser(user) + }, + modifier = + HalfPadding + .align(Alignment.CenterVertically) + .background( + color = MaterialTheme.colorScheme.errorContainer, + shape = RoundedCornerShape(percent = 80), + ), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + } + } + HorizontalDivider(thickness = DividerThickness) + } +} + +@Composable +fun ListActionsMenuButton( + viewModel: PeopleListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + ListActionsMenuButton( + onBroadcastList = { + accountViewModel.runIOCatching { + viewModel.loadNote()?.let { updatedSetNote -> + accountViewModel.broadcast(updatedSetNote) + } + } + }, + onDeleteList = { + accountViewModel.runIOCatching { + viewModel.deleteFollowSet() + } + nav.popBack() + }, + ) +} + +@Composable +fun ListActionsMenuButton( + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + val isActionListOpen = remember { mutableStateOf(false) } + + ClickableBox( + modifier = + StdPadding + .size(30.dp) + .border( + width = Dp.Hairline, + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ).background( + color = ButtonDefaults.filledTonalButtonColors().containerColor, + shape = ButtonBorder, + ), + onClick = { isActionListOpen.value = true }, + ) { + VerticalDotsIcon() + ListActionsMenu( + onCloseMenu = { isActionListOpen.value = false }, + isOpen = isActionListOpen.value, + onBroadcastList = onBroadcastList, + onDeleteList = onDeleteList, + ) + } +} + +@Composable +fun ListActionsMenu( + onCloseMenu: () -> Unit, + isOpen: Boolean, + onBroadcastList: () -> Unit, + onDeleteList: () -> Unit, +) { + DropdownMenu( + expanded = isOpen, + onDismissRequest = onCloseMenu, + ) { + DropdownMenuItem( + text = { + Text("Broadcast List") + }, + onClick = { + onBroadcastList() + onCloseMenu() + }, + ) + HorizontalDivider(thickness = DividerThickness) + DropdownMenuItem( + text = { + Text("Delete List") + }, + onClick = { + onDeleteList() + onCloseMenu() + }, + ) + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun UserSearchField( + userSuggestions: UserSuggestionState, + modifier: Modifier = Modifier, +) { + var userName by remember(userSuggestions) { mutableStateOf(userSuggestions.currentWord.value) } + val focusManager = LocalFocusManager.current + + OutlinedTextField( + label = { Text(text = stringRes(R.string.search_and_add_a_user)) }, + modifier = + modifier + .padding(horizontal = Size10dp) + .fillMaxWidth(), + value = userName, + onValueChange = { + userName = it + userSuggestions.processCurrentWord(it) + }, + singleLine = true, + trailingIcon = { + IconButton( + onClick = { + userName = "" + userSuggestions.processCurrentWord("") + focusManager.clearFocus() + }, + ) { + ClearTextIcon() + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt new file mode 100644 index 000000000..3a967980f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/display/PeopleListViewModel.kt @@ -0,0 +1,108 @@ +/** + * 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.ui.screen.loggedIn.lists.display + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest + +@Stable +class PeopleListViewModel : ViewModel() { + lateinit var account: Account + lateinit var userSuggestions: UserSuggestionState + + var userSuggestionFocus by mutableStateOf(null) + + val selectedDTag = MutableStateFlow("") + + @OptIn(ExperimentalCoroutinesApi::class) + val selectedList = + selectedDTag + .transformLatest { + emitAll( + account.peopleListsState.selectListFlow(it).flowOn(Dispatchers.IO), + ) + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + fun init( + account: Account, + selectedDTag: String, + ) { + if (!this::account.isInitialized || this.account != account) { + this.account = account + this.userSuggestions = UserSuggestionState(account, false) + } + + this.selectedDTag.tryEmit(selectedDTag) + } + + suspend fun deleteFollowSet() { + account.peopleListsState.deleteFollowSet(selectedDTag.value, account) + } + + fun loadNote(): AddressableNote? = account.peopleListsState.getPeopleListNote(selectedDTag.value) + + suspend fun removeUserFromSet( + user: User, + isPrivate: Boolean, + ) { + account.peopleListsState.removeUserFromSet(user, isPrivate, selectedDTag.value, account) + } + + suspend fun addUserToSet( + user: User, + isPrivate: Boolean, + ) { + account.peopleListsState.addUserToSet(user, selectedDTag.value, isPrivate, account) + } + + fun hasUserFlow( + user: User, + isPrivate: Boolean, + ): Flow = + selectedList.map { + if (it == null) { + false + } else if (isPrivate) { + user in it.privateMembers + } else { + user in it.publicMembers + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt deleted file mode 100644 index a3e644730..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetScreen.kt +++ /dev/null @@ -1,460 +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.amethyst.ui.screen.loggedIn.lists.followsets - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.model.nip51Lists.followSets.FollowSet -import com.vitorpamplona.amethyst.ui.components.ClickableBox -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav -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.FollowSetFeedViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.ButtonBorder -import com.vitorpamplona.amethyst.ui.theme.DividerThickness -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.FeedPadding -import com.vitorpamplona.amethyst.ui.theme.HalfHalfHorzModifier -import com.vitorpamplona.amethyst.ui.theme.HalfPadding -import com.vitorpamplona.amethyst.ui.theme.StdPadding -import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FollowSetScreen( - selectedSetIdentifier: String, - accountViewModel: AccountViewModel, - navigator: INav, -) { - val followSetViewModel: FollowSetFeedViewModel = - viewModel( - key = "FollowSetFeedViewModel", - factory = FollowSetFeedViewModel.Factory(accountViewModel.account), - ) - - FollowSetScreen(selectedSetIdentifier, followSetViewModel, accountViewModel, navigator) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FollowSetScreen( - selectedSetIdentifier: String, - followSetViewModel: FollowSetFeedViewModel, - accountViewModel: AccountViewModel, - navigator: INav, -) { - val followSetState by followSetViewModel.feedContent.collectAsState() - val uiScope = rememberCoroutineScope() - - val selectedSetState = - remember(followSetState) { - derivedStateOf { - uiScope.launch { - delay(500L) - } - val note = - followSetViewModel.getFollowSetNote( - selectedSetIdentifier, - accountViewModel.account, - ) - - if (note != null) { - val event = note.event as PeopleListEvent - println("Found list, with title: ${event.nameOrTitle()}") - val selectedFollowSet = - FollowSet.mapEventToSet( - event, - accountViewModel.account.signer, - ) - return@derivedStateOf selectedFollowSet - } else { - null - } - } - } - - BackHandler { navigator.popBack() } - - when { - selectedSetState.value != null -> { - val selectedSet = selectedSetState.value - val publicMembers = selectedSet!!.publicProfiles.mapToUsers(accountViewModel).filterNotNull() - val privateMembers = selectedSet.privateProfiles.mapToUsers(accountViewModel).filterNotNull() - Scaffold( - topBar = { - TopAppBar( - title = { - TitleAndDescription(followSet = selectedSet) - }, - navigationIcon = { - BackButton( - onPress = { navigator.popBack() }, - ) - }, - actions = { - ListActionsMenuButton( - onBroadcastList = { - val updatedSetNote = - followSetViewModel.getFollowSetNote( - selectedSet.identifierTag, - accountViewModel.account, - ) - accountViewModel.broadcast(updatedSetNote!!) - }, - onDeleteList = { - followSetViewModel.deleteFollowSet( - selectedSet, - accountViewModel.account, - ) - navigator.popBack() - }, - ) - }, - colors = - TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - }, - ) { padding -> - FollowSetListView( - modifier = - Modifier - .fillMaxSize() - .padding( - top = padding.calculateTopPadding(), - bottom = padding.calculateBottomPadding(), - ).consumeWindowInsets(padding) - .imePadding(), - publicMemberList = publicMembers, - privateMemberList = privateMembers, - onDeleteUser = { - followSetViewModel.removeUserFromSet( - it, - userIsPrivate = selectedSet.privateProfiles.contains(it), - selectedSet, - accountViewModel.account, - ) - }, - accountViewModel = accountViewModel, - nav = navigator, - ) - } - } - - selectedSetState.value == null -> { - accountViewModel.toastManager.toast( - "Follow Set Error", - "Could not find requested follow set", - ) { - navigator.popBack() - } - } - } -} - -fun Set.mapToUsers(accountViewModel: AccountViewModel): List = map { accountViewModel.checkGetOrCreateUser(it) } - -@Composable -fun TitleAndDescription( - modifier: Modifier = Modifier, - followSet: FollowSet, -) { - Column( - modifier = modifier, - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = followSet.title, - ) - Spacer(modifier = DoubleHorzSpacer) - Icon( - painter = painterResource(R.drawable.format_list_bulleted_type), - contentDescription = null, - ) - } - - if (followSet.description != null) { - Text( - text = followSet.description, - fontSize = 18.sp, - fontWeight = FontWeight.Thin, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} - -@Composable -@Preview(device = "spec:width=2160px,height=2340px,dpi=440") -fun FollowSetListViewPreview() { - val accountViewModel = mockAccountViewModel() - - val user1: User = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") - val user2: User = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") - val user3: User = LocalCache.getOrCreateUser("7eb29c126b3628077e2e3d863b917a56b74293aa9d8a9abc26a40ba3f2866baf") - - ThemeComparisonRow { - FollowSetListView( - publicMemberList = listOf(user1, user2, user3), - privateMemberList = listOf(user1, user2, user3), - onDeleteUser = { }, - accountViewModel = accountViewModel, - nav = EmptyNav, - ) - } -} - -@Composable -private fun FollowSetListView( - modifier: Modifier = Modifier, - publicMemberList: List, - privateMemberList: List, - onDeleteUser: (String) -> Unit, - accountViewModel: AccountViewModel, - nav: INav, -) { - val listState = rememberLazyListState() - - LazyColumn( - modifier = modifier, - contentPadding = FeedPadding, - state = listState, - ) { - if (publicMemberList.isNotEmpty()) { - stickyHeader { - Text( - text = stringRes(R.string.follow_set_public_members_header_label2), - modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 5.dp), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ) - HorizontalDivider(thickness = DividerThickness) - } - itemsIndexed(publicMemberList, key = { _, item -> "u" + item.pubkeyHex }) { _, item -> - FollowSetListItem( - modifier = Modifier.animateItem(), - user = item, - accountViewModel = accountViewModel, - nav = nav, - onDeleteUser = onDeleteUser, - ) - } - item { - Spacer(modifier = Modifier.height(10.dp)) - } - } - if (privateMemberList.isNotEmpty()) { - stickyHeader { - Text( - text = stringRes(R.string.follow_set_private_members_header_label2), - modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.background).padding(vertical = 5.dp), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - ) - HorizontalDivider(thickness = DividerThickness) - } - itemsIndexed(privateMemberList, key = { _, item -> "i" + item.pubkeyHex }) { _, item -> - FollowSetListItem( - modifier = Modifier.animateItem(), - user = item, - accountViewModel = accountViewModel, - nav = nav, - onDeleteUser = onDeleteUser, - ) - } - item { - Spacer(modifier = Modifier.height(30.dp)) - } - } - } -} - -@Composable -fun FollowSetListItem( - modifier: Modifier = Modifier, - user: User, - accountViewModel: AccountViewModel, - nav: INav, - onDeleteUser: (String) -> Unit, -) { - Column( - modifier = modifier, - ) { - Row(HalfHalfHorzModifier) { - UserCompose( - user, - overallModifier = HalfPadding.weight(1f, fill = false), - accountViewModel = accountViewModel, - nav = nav, - ) - IconButton( - onClick = { - onDeleteUser(user.pubkeyHex) - }, - modifier = - HalfPadding - .align(Alignment.CenterVertically) - .background( - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(percent = 80), - ), - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - } - } - HorizontalDivider(thickness = DividerThickness) - } -} - -@Composable -fun ListActionsMenuButton( - modifier: Modifier = Modifier, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - val isActionListOpen = remember { mutableStateOf(false) } - - ClickableBox( - modifier = - StdPadding - .size(30.dp) - .border( - width = Dp.Hairline, - color = ButtonDefaults.filledTonalButtonColors().containerColor, - shape = ButtonBorder, - ).background( - color = ButtonDefaults.filledTonalButtonColors().containerColor, - shape = ButtonBorder, - ), - onClick = { isActionListOpen.value = true }, - ) { - VerticalDotsIcon() - ListActionsMenu( - onCloseMenu = { isActionListOpen.value = false }, - isOpen = isActionListOpen.value, - onBroadcastList = onBroadcastList, - onDeleteList = onDeleteList, - ) - } -} - -@Composable -fun ListActionsMenu( - modifier: Modifier = Modifier, - onCloseMenu: () -> Unit, - isOpen: Boolean, - onBroadcastList: () -> Unit, - onDeleteList: () -> Unit, -) { - DropdownMenu( - expanded = isOpen, - onDismissRequest = onCloseMenu, - ) { - DropdownMenuItem( - text = { - Text("Broadcast List") - }, - onClick = { - onBroadcastList() - onCloseMenu() - }, - ) - HorizontalDivider(thickness = DividerThickness) - DropdownMenuItem( - text = { - Text("Delete List") - }, - onClick = { - onDeleteList() - onCloseMenu() - }, - ) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt new file mode 100644 index 000000000..d6756ee56 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListFeedView.kt @@ -0,0 +1,93 @@ +/** + * 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.ui.screen.loggedIn.lists.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleList +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.Size30dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import kotlinx.coroutines.flow.StateFlow + +@Composable +fun AllPeopleListFeedView( + listFlow: StateFlow>, + onOpenItem: (String) -> Unit = {}, + onRenameItem: (targetSet: PeopleList, newName: String) -> Unit, + onItemDescriptionChange: (peopleList: PeopleList, newDescription: String?) -> Unit, + onItemClone: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, + onDeleteItem: (peopleList: PeopleList) -> Unit, +) { + val followSetFeedState by listFlow.collectAsStateWithLifecycle() + + if (followSetFeedState.isEmpty()) { + AllPeopleListFeedEmpty( + message = stringRes(R.string.follow_set_empty_feed_msg), + ) + } else { + LazyColumn( + state = rememberLazyListState(), + contentPadding = FeedPadding, + ) { + itemsIndexed(followSetFeedState, key = { _, item -> item.identifierTag }) { _, list -> + PeopleListItem( + modifier = Modifier.fillMaxSize().animateItem(), + peopleList = list, + onClick = { onOpenItem(list.identifierTag) }, + onRename = { onRenameItem(list, it) }, + onDescriptionChange = { newDescription -> onItemDescriptionChange(list, newDescription) }, + onClone = { cloneName, cloneDescription -> onItemClone(list, cloneName, cloneDescription) }, + onDelete = { onDeleteItem(list) }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } + } +} + +@Composable +fun AllPeopleListFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) { + Column( + Modifier.fillMaxSize().padding(horizontal = Size30dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(message) + Spacer(modifier = StdVertSpacer) + } +} 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/list/ListOfPeopleListsScreen.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomListsScreen.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/ListOfPeopleListsScreen.kt index b5739ef97..40dc823dd 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/list/ListOfPeopleListsScreen.kt @@ -18,17 +18,14 @@ * 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.ui.screen.loggedIn.lists.list import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd @@ -37,176 +34,103 @@ import androidx.compose.material3.Button import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -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.model.nip51Lists.peopleList.PeopleList import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer -import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.StateFlow @Composable -fun ListsAndSetsScreen( +fun ListOfPeopleListsScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val followSetsViewModel: FollowSetFeedViewModel = - viewModel( - key = "FollowSetFeedViewModel", - factory = FollowSetFeedViewModel.Factory(accountViewModel.account), - ) - - ListsAndSetsScreen( - followSetsViewModel, - accountViewModel, - nav, - ) -} - -@Composable -fun ListsAndSetsScreen( - followSetsViewModel: FollowSetFeedViewModel, - accountViewModel: AccountViewModel, - nav: INav, -) { - val lifeCycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - println("Custom Lists Start") - followSetsViewModel.invalidateData() - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - val followSetsFlow by followSetsViewModel.feedContent.collectAsStateWithLifecycle() - - CustomListsScreen( - followSetsFlow, - refresh = { - followSetsViewModel.invalidateData() - }, + ListOfPeopleListsScreen( + listFlow = accountViewModel.account.peopleListsState.uiListFlow, addItem = { title: String, description: String? -> - - followSetsViewModel.addFollowSet( - setName = title, - setDescription = description, - account = accountViewModel.account, - ) + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.addFollowList( + listName = title, + listDescription = description, + account = accountViewModel.account, + ) + } }, openItem = { - nav.nav(Route.FollowSetRoute(it)) + nav.nav(Route.PeopleListView(it)) }, renameItem = { followSet, newValue -> - followSetsViewModel.renameFollowSet( - newName = newValue, - followSet = followSet, - account = accountViewModel.account, - ) + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.renameFollowList( + newName = newValue, + peopleList = followSet, + account = accountViewModel.account, + ) + } }, changeItemDescription = { followSet, newDescription -> - followSetsViewModel.modifyFollowSetDescription( - newDescription = newDescription, - followSet = followSet, - account = accountViewModel.account, - ) + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.modifyFollowSetDescription( + newDescription = newDescription, + peopleList = followSet, + account = accountViewModel.account, + ) + } }, cloneItem = { followSet, customName, customDescription -> - followSetsViewModel.cloneFollowSet( - currentFollowSet = followSet, - customCloneName = customName, - customCloneDescription = customDescription, - account = accountViewModel.account, - ) + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.cloneFollowSet( + currentPeopleList = followSet, + customCloneName = customName, + customCloneDescription = customDescription, + account = accountViewModel.account, + ) + } }, deleteItem = { followSet -> - followSetsViewModel.deleteFollowSet( - followSet = followSet, - account = accountViewModel.account, - ) + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.deleteFollowSet( + identifierTag = followSet.identifierTag, + account = accountViewModel.account, + ) + } }, - accountViewModel, nav, ) } @Composable -fun CustomListsScreen( - followSetFeedState: FollowSetFeedState, - refresh: () -> Unit, +fun ListOfPeopleListsScreen( + listFlow: StateFlow>, addItem: (title: String, description: String?) -> Unit, openItem: (identifier: String) -> Unit, - renameItem: (followSet: FollowSet, newName: String) -> Unit, - changeItemDescription: (followSet: FollowSet, newDescription: String?) -> Unit, - cloneItem: (followSet: FollowSet, customName: String?, customDesc: String?) -> Unit, - deleteItem: (followSet: FollowSet) -> Unit, - accountViewModel: AccountViewModel, + renameItem: (peopleList: PeopleList, newName: String) -> Unit, + changeItemDescription: (peopleList: PeopleList, newDescription: String?) -> Unit, + cloneItem: (peopleList: PeopleList, customName: String?, customDesc: String?) -> Unit, + deleteItem: (peopleList: PeopleList) -> Unit, nav: INav, ) { - val pagerState = rememberPagerState { 2 } - val coroutineScope = rememberCoroutineScope() - - DisappearingScaffold( - isInvertedLayout = false, - accountViewModel = accountViewModel, + Scaffold( topBar = { - Column { - TopBarWithBackButton(stringRes(R.string.my_lists_and_sets), nav::popBack) - TabRow( - selectedTabIndex = pagerState.currentPage, - modifier = TabRowHeight, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - ) { - Tab( - selected = pagerState.currentPage == 0, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, - text = { Text(text = stringRes(R.string.follow_sets), overflow = TextOverflow.Visible) }, - ) - Tab( - selected = pagerState.currentPage == 1, - onClick = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, - text = { Text(text = stringRes(R.string.labeled_bookmarks), overflow = TextOverflow.Visible) }, - ) - } - } + TopBarWithBackButton(stringRes(R.string.my_lists), nav::popBack) }, - floatingButton = { - // TODO: Show components based on current tab - FollowSetFabsAndMenu( + floatingActionButton = { + PeopleListFabsAndMenu( onAddSet = { name: String, description: String? -> addItem(name, description) }, @@ -222,32 +146,20 @@ fun CustomListsScreen( end = 10.dp, ).fillMaxHeight(), ) { - HorizontalPager(state = pagerState) { page -> - when (page) { - 0 -> - FollowSetFeedView( - followSetFeedState = followSetFeedState, - onRefresh = refresh, - onOpenItem = openItem, - onRenameItem = renameItem, - onItemDescriptionChange = changeItemDescription, - onItemClone = cloneItem, - onDeleteItem = deleteItem, - ) - - 1 -> LabeledBookmarksFeedView() -// 2 -> GeneralBookmarksFeedView() - } - } + AllPeopleListFeedView( + listFlow = listFlow, + onOpenItem = openItem, + onRenameItem = renameItem, + onItemDescriptionChange = changeItemDescription, + onItemClone = cloneItem, + onDeleteItem = deleteItem, + ) } } } @Composable -private fun FollowSetFabsAndMenu( - modifier: Modifier = Modifier, - onAddSet: (name: String, description: String?) -> Unit, -) { +private fun PeopleListFabsAndMenu(onAddSet: (name: String, description: String?) -> Unit) { val isSetAdditionDialogOpen = remember { mutableStateOf(false) } ExtendedFloatingActionButton( @@ -268,7 +180,7 @@ private fun FollowSetFabsAndMenu( ) if (isSetAdditionDialogOpen.value) { - NewSetCreationDialog( + NewPeopleListCreationDialog( onDismiss = { isSetAdditionDialogOpen.value = false }, @@ -280,7 +192,7 @@ private fun FollowSetFabsAndMenu( } @Composable -fun NewSetCreationDialog( +fun NewPeopleListCreationDialog( modifier: Modifier = Modifier, onDismiss: () -> Unit, onCreateList: (name: String, description: String?) -> Unit, @@ -346,59 +258,35 @@ fun NewSetCreationDialog( ) } -@Composable -fun LabeledBookmarksFeedView() { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = "Not implemented yet.") - Spacer(modifier = StdVertSpacer) - } -} - -@Composable -fun GeneralBookmarksFeedView() { - Column( - Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = "Not implemented yet.") - Spacer(modifier = StdVertSpacer) - } -} - @Preview(showSystemUi = true) @Composable -private fun SetItemPreview() { - val sampleFollowSet = - FollowSet( +private fun PeopleListItemPreview() { + val samplePeopleList = + PeopleList( identifierTag = "00001-2222", title = "Sample List Title", description = "Sample List Description", - visibility = SetVisibility.Mixed, + emptySet(), emptySet(), ) ThemeComparisonColumn { - CustomSetItem( + PeopleListItem( modifier = Modifier, - sampleFollowSet, - onFollowSetClick = { - println("follow set: ${sampleFollowSet.identifierTag}") + samplePeopleList, + onClick = { + println("follow set: ${samplePeopleList.identifierTag}") }, - onFollowSetRename = { + onRename = { println("Follow set new name: $it") }, - onFollowSetDescriptionChange = { description -> + onDescriptionChange = { description -> println("The follow set's description has been changed to $description") }, - onFollowSetClone = { newName, newDesc -> + onClone = { newName, newDesc -> println("The follow set has been cloned, and has custom name: $newName, Desc: $newDesc") }, - onFollowSetDelete = { - println(" The follow set ${sampleFollowSet.title} has been deleted.") + onDelete = { + println(" The follow set ${samplePeopleList.title} has been deleted.") }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt similarity index 82% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index 89dec3b22..4001cb7da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/CustomSetItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -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.ui.screen.loggedIn.lists.list import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -52,31 +52,32 @@ 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.peopleList.PeopleList import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.Size5dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @Composable -fun CustomSetItem( +fun PeopleListItem( modifier: Modifier = Modifier, - followSet: FollowSet, - onFollowSetClick: () -> Unit, - onFollowSetRename: (String) -> Unit, - onFollowSetDescriptionChange: (String?) -> Unit, - onFollowSetClone: (customName: String?, customDescription: String?) -> Unit, - onFollowSetDelete: () -> Unit, + peopleList: PeopleList, + onClick: () -> Unit, + onRename: (String) -> Unit, + onDescriptionChange: (String?) -> Unit, + onClone: (customName: String?, customDescription: String?) -> Unit, + onDelete: () -> Unit, ) { val context = LocalContext.current Row( modifier = modifier - .clickable(onClick = onFollowSetClick), + .clickable(onClick = onClick), ) { Row( modifier = @@ -91,10 +92,14 @@ fun CustomSetItem( ) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, ) { - Text(followSet.title, fontWeight = FontWeight.Bold) - Spacer(modifier = StdHorzSpacer) - if (followSet.publicProfiles.isEmpty() && followSet.privateProfiles.isEmpty()) { + Icon( + painter = painterResource(R.drawable.format_list_bulleted_type), + contentDescription = stringRes(R.string.follow_set_icon_description), + ) + Text(peopleList.title, fontWeight = FontWeight.Bold) + if (peopleList.publicMembers.isEmpty() && peopleList.privateMembers.isEmpty()) { FilterChip( selected = true, onClick = {}, @@ -110,17 +115,8 @@ fun CustomSetItem( shape = ButtonBorder, ) } - if (followSet.publicProfiles.isNotEmpty()) { - val publicMemberSize = followSet.publicProfiles.size - val membersLabel = - stringRes( - context, - if (publicMemberSize == 1) { - R.string.follow_set_single_member_label - } else { - R.string.follow_set_multiple_member_label - }, - ) + if (peopleList.publicMembers.isNotEmpty()) { + val publicMemberSize = peopleList.publicMembers.size FilterChip( selected = true, onClick = {}, @@ -137,17 +133,8 @@ fun CustomSetItem( ) Spacer(modifier = StdHorzSpacer) } - if (followSet.privateProfiles.isNotEmpty()) { - val privateMemberSize = followSet.privateProfiles.size - val membersLabel = - stringRes( - context, - if (privateMemberSize == 1) { - R.string.follow_set_single_member_label - } else { - R.string.follow_set_multiple_member_label - }, - ) + if (peopleList.privateMembers.isNotEmpty()) { + val privateMemberSize = peopleList.privateMembers.size FilterChip( selected = true, onClick = {}, @@ -166,7 +153,7 @@ fun CustomSetItem( } Spacer(modifier = StdVertSpacer) Text( - followSet.description ?: "", + peopleList.description ?: "", fontWeight = FontWeight.Light, overflow = TextOverflow.Ellipsis, maxLines = 2, @@ -182,26 +169,26 @@ fun CustomSetItem( verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.End, ) { - SetOptionsButton( - followSetName = followSet.title, - followSetDescription = followSet.description, - onSetRename = onFollowSetRename, - onSetDescriptionChange = onFollowSetDescriptionChange, - onSetCloneCreate = onFollowSetClone, - onListDelete = onFollowSetDelete, + PeopleListOptionsButton( + peopleListName = peopleList.title, + peopleListDescription = peopleList.description, + onListRename = onRename, + onListDescriptionChange = onDescriptionChange, + onListCloneCreate = onClone, + onListDelete = onDelete, ) } } } @Composable -private fun SetOptionsButton( +private fun PeopleListOptionsButton( modifier: Modifier = Modifier, - followSetName: String, - followSetDescription: String?, - onSetRename: (String) -> Unit, - onSetDescriptionChange: (String?) -> Unit, - onSetCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, + peopleListName: String, + peopleListDescription: String?, + onListRename: (String) -> Unit, + onListDescriptionChange: (String?) -> Unit, + onListCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit, onListDelete: () -> Unit, ) { val isMenuOpen = remember { mutableStateOf(false) } @@ -211,28 +198,28 @@ private fun SetOptionsButton( ) { VerticalDotsIcon() - SetOptionsMenu( - setName = followSetName, - setDescription = followSetDescription, + ListOptionsMenu( + setName = peopleListName, + setDescription = peopleListDescription, isExpanded = isMenuOpen.value, onDismiss = { isMenuOpen.value = false }, - onSetRename = onSetRename, - onSetDescriptionChange = onSetDescriptionChange, - onSetClone = onSetCloneCreate, + onListRename = onListRename, + onListDescriptionChange = onListDescriptionChange, + onListClone = onListCloneCreate, onDelete = onListDelete, ) } } @Composable -private fun SetOptionsMenu( +private fun ListOptionsMenu( modifier: Modifier = Modifier, isExpanded: Boolean, setName: String, setDescription: String?, - onSetRename: (String) -> Unit, - onSetDescriptionChange: (String?) -> Unit, - onSetClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, + onListRename: (String) -> Unit, + onListDescriptionChange: (String?) -> Unit, + onListClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit, ) { @@ -287,7 +274,7 @@ private fun SetOptionsMenu( } if (isRenameDialogOpen.value) { - SetRenameDialog( + ListRenameDialog( currentName = setName, newName = renameString.value, onStringRenameChange = { @@ -295,21 +282,21 @@ private fun SetOptionsMenu( }, onDismissDialog = { isRenameDialogOpen.value = false }, onListRename = { - onSetRename(renameString.value) + onListRename(renameString.value) }, ) } if (isDescriptionModDialogOpen.value) { - SetModifyDescriptionDialog( + ListModifyDescriptionDialog( currentDescription = setDescription, onDismissDialog = { isDescriptionModDialogOpen.value = false }, - onModifyDescription = onSetDescriptionChange, + onModifyDescription = onListDescriptionChange, ) } if (isCopyDialogOpen.value) { - SetCloneDialog( + ListCloneDialog( optionalNewName = optionalCloneName.value, optionalNewDesc = optionalCloneDescription.value, onCloneNameChange = { @@ -319,7 +306,7 @@ private fun SetOptionsMenu( optionalCloneDescription.value = it }, onCloneCreate = { name, description -> - onSetClone(optionalCloneName.value, optionalCloneDescription.value) + onListClone(optionalCloneName.value, optionalCloneDescription.value) }, onDismiss = { isCopyDialogOpen.value = false }, ) @@ -327,7 +314,7 @@ private fun SetOptionsMenu( } @Composable -private fun SetRenameDialog( +private fun ListRenameDialog( modifier: Modifier = Modifier, currentName: String, newName: String, @@ -387,7 +374,7 @@ private fun SetRenameDialog( } @Composable -private fun SetModifyDescriptionDialog( +private fun ListModifyDescriptionDialog( modifier: Modifier = Modifier, currentDescription: String?, onDismissDialog: () -> Unit, @@ -450,7 +437,7 @@ private fun SetModifyDescriptionDialog( } @Composable -private fun SetCloneDialog( +private fun ListCloneDialog( modifier: Modifier = Modifier, optionalNewName: String?, optionalNewDesc: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/EditPeopleListScreen.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/EditPeopleListScreen.kt index ff6894fc7..172a3a2d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/followsets/FollowSetsManagementDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/memberEdit/EditPeopleListScreen.kt @@ -18,11 +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.followsets +package com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.memberEdit -import android.util.Log import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,16 +33,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.recalculateWindowInsets -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAddCheck -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonRemove import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -53,17 +49,14 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -74,91 +67,70 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserName import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -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.list.NewPeopleListCreationDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp 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.nip01Core.core.HexKey @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FollowSetsManagementDialog( - userHex: String, +fun EditPeopleListScreen( + userToAddOrRemove: HexKey, accountViewModel: AccountViewModel, - navigator: INav, + nav: INav, ) { - val followSetViewModel: FollowSetFeedViewModel = - viewModel( - key = "FollowSetFeedViewModel", - factory = FollowSetFeedViewModel.Factory(accountViewModel.account), - ) + var userBase by remember { mutableStateOf(LocalCache.getUserIfExists(userToAddOrRemove)) } - FollowSetsManagementDialog(userHex, followSetViewModel, accountViewModel.account, navigator) + if (userBase == null) { + LaunchedEffect(userToAddOrRemove) { + val newUserBase = LocalCache.checkGetOrCreateUser(userToAddOrRemove) + if (newUserBase != userBase) { + userBase = newUserBase + } + } + } + + userBase?.let { + EditPeopleListScreen(it, accountViewModel, nav) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable -fun FollowSetsManagementDialog( - userHex: String, - followSetsViewModel: FollowSetFeedViewModel, - account: Account, - navigator: INav, +fun EditPeopleListScreen( + userToAddOrRemove: User, + accountViewModel: AccountViewModel, + nav: INav, ) { - val followSetsState by followSetsViewModel.feedContent.collectAsState() - val userInfo by remember { derivedStateOf { LocalCache.getOrCreateUser(userHex) } } - Scaffold( modifier = Modifier .fillMaxSize() .recalculateWindowInsets(), - containerColor = AlertDialogDefaults.containerColor, topBar = { - TopAppBar( - title = { - Column { - Text( - text = stringRes(R.string.follow_set_man_dialog_title), - fontWeight = FontWeight.SemiBold, - ) - } - }, - navigationIcon = { - IconButton( - onClick = { navigator.popBack() }, - ) { - ArrowBackIcon() - } - }, - colors = - TopAppBarDefaults - .topAppBarColors( - containerColor = AlertDialogDefaults.containerColor, - ), - ) - }, - floatingActionButton = { - BackActionButton { navigator.popBack() } + TopBarWithBackButton(stringRes(id = R.string.follow_set_man_dialog_title), nav::popBack) }, ) { contentPadding -> Column( modifier = Modifier - .verticalScroll(rememberScrollState()) .padding( start = 10.dp, end = 10.dp, @@ -167,122 +139,77 @@ fun FollowSetsManagementDialog( ).consumeWindowInsets(contentPadding) .imePadding(), ) { - when (followSetsState) { - is FollowSetFeedState.Loaded -> { - val sets = (followSetsState as FollowSetFeedState.Loaded).feed - - sets.forEachIndexed { index, set -> - - Spacer(StdVertSpacer) - FollowSetItem( - modifier = Modifier.fillMaxWidth(), - listHeader = set.title, - setVisibility = set.visibility, - userName = userInfo.toBestDisplayName(), - userIsPrivateMember = set.privateProfiles.contains(userHex), - userIsPublicMember = set.publicProfiles.contains(userHex), - onRemoveUser = { - Log.d( - "Amethyst", - "ProfileActions: Removing item from list ...", - ) - followSetsViewModel.removeUserFromSet( - userHex, - userIsPrivate = set.privateProfiles.contains(userHex), - set, - account, - ) - Log.d( - "Amethyst", - "Updated List. Private profiles size: ${set.privateProfiles.size}," + - "Public profiles size: ${set.publicProfiles.size}", - ) - }, - onAddUserToList = { userShouldBePrivate -> - Log.d( - "Amethyst", - "ProfileActions: Adding item to list ...", - ) - followSetsViewModel.addUserToSet( - userHex, - set, - userShouldBePrivate, - account, - ) - Log.d( - "Amethyst", - "Updated List. Private profiles size: ${set.privateProfiles.size}," + - "Public profiles size: ${set.publicProfiles.size}", - ) - }, - ) - } - } - - FollowSetFeedState.Empty -> { - EmptyOrNoneFound { followSetsViewModel.refresh() } - } - - is FollowSetFeedState.FeedError -> { - val errorMsg = (followSetsState as FollowSetFeedState.FeedError).errorMessage - ErrorMessage(errorMsg) { followSetsViewModel.refresh() } - } - - FollowSetFeedState.Loading -> { - Loading() - } - } - - if (followSetsState != FollowSetFeedState.Loading) { - FollowSetsCreationMenu( - userName = userInfo.toBestDisplayName(), - onSetCreate = { setName, memberShouldBePrivate, description -> - followSetsViewModel.addFollowSet( - setName = setName, - setDescription = description, - firstMemberShouldBePrivate = memberShouldBePrivate, - optionalFirstMemberHex = userHex, - account = account, - ) - }, - ) - } + FollowSetManagementScreenBody(userToAddOrRemove, accountViewModel, nav) } } } @Composable -fun BackActionButton( - modifier: Modifier = Modifier, - onBack: () -> Unit, +private fun FollowSetManagementScreenBody( + userToAddOrRemove: User, + accountViewModel: AccountViewModel, + nav: INav, ) { - OutlinedButton( - onClick = onBack, - shape = ButtonBorder, - colors = ButtonDefaults.buttonColors(), - border = ButtonDefaults.outlinedButtonBorder(false), - elevation = ButtonDefaults.elevatedButtonElevation(defaultElevation = 6.0.dp), - ) { - Text(text = stringRes(R.string.back), fontWeight = FontWeight.Bold, color = Color.White) + val followSetsState by accountViewModel.account.peopleListsState.uiListFlow + .collectAsStateWithLifecycle() + + if (followSetsState.isEmpty()) { + EmptyOrNoneFound() + } else { + val userName by observeUserName(userToAddOrRemove, accountViewModel) + + LazyColumn(modifier = Modifier.fillMaxWidth()) { + itemsIndexed(followSetsState, key = { _, item -> item.identifierTag }) { _, list -> + FollowSetItem( + modifier = Modifier.fillMaxWidth(), + listHeader = list.title, + userName = userName, + userIsPrivateMember = list.privateMembers.contains(userToAddOrRemove), + userIsPublicMember = list.publicMembers.contains(userToAddOrRemove), + onRemoveUser = { + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.removeUserFromSet( + userToAddOrRemove, + isPrivate = list.privateMembers.contains(userToAddOrRemove), + list.identifierTag, + accountViewModel.account, + ) + } + }, + onAddUserToList = { userShouldBePrivate -> + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.addUserToSet( + userToAddOrRemove, + list.identifierTag, + userShouldBePrivate, + accountViewModel.account, + ) + } + }, + ) + HorizontalDivider(thickness = DividerThickness) + } + } } + + FollowSetsCreationMenu( + userName = userToAddOrRemove.toBestDisplayName(), + onSetCreate = { setName, memberShouldBePrivate, description -> + accountViewModel.runIOCatching { + accountViewModel.account.peopleListsState.addFollowList( + listName = setName, + listDescription = description, + isPrivate = memberShouldBePrivate, + member = userToAddOrRemove, + account = accountViewModel.account, + ) + } + }, + ) } @Composable -private fun Loading() { - Column( - Modifier - .fillMaxWidth() - .fillMaxHeight(0.5f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Text(stringRes(R.string.loading_feed)) - } -} - -@Composable -private fun EmptyOrNoneFound(onRefresh: () -> Unit) { +private fun EmptyOrNoneFound() { Column( Modifier .fillMaxWidth() @@ -292,25 +219,38 @@ private fun EmptyOrNoneFound(onRefresh: () -> Unit) { ) { Text(text = stringRes(R.string.follow_set_empty_dialog_msg)) Spacer(modifier = StdVertSpacer) - OutlinedButton(onClick = onRefresh) { Text(text = stringRes(R.string.refresh)) } } } +@Preview @Composable -private fun ErrorMessage( - errorMsg: String, - onRefresh: () -> Unit, -) { - Column( - Modifier - .fillMaxWidth() - .fillMaxHeight(0.5f), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Text(text = stringRes(R.string.follow_set_error_dialog_msg, errorMsg)) - Spacer(modifier = StdVertSpacer) - OutlinedButton(onClick = onRefresh) { Text(text = stringRes(R.string.refresh)) } +fun FollowSetItemMemberPreview() { + ThemeComparisonColumn { + FollowSetItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + userIsPrivateMember = true, + userIsPublicMember = true, + onAddUserToList = {}, + onRemoveUser = {}, + ) + } +} + +@Preview +@Composable +fun FollowSetItemNotMemberPreview() { + ThemeComparisonColumn { + FollowSetItem( + modifier = Modifier.fillMaxWidth(), + listHeader = "list title", + userName = "User", + userIsPrivateMember = false, + userIsPublicMember = false, + onAddUserToList = {}, + onRemoveUser = {}, + ) } } @@ -318,75 +258,63 @@ private fun ErrorMessage( fun FollowSetItem( modifier: Modifier = Modifier, listHeader: String, - setVisibility: SetVisibility, userName: String, userIsPrivateMember: Boolean, userIsPublicMember: Boolean, onAddUserToList: (shouldBePrivateMember: Boolean) -> Unit, onRemoveUser: () -> Unit, ) { - val context = LocalContext.current val isUserInList = userIsPrivateMember || userIsPublicMember Row( - modifier = - modifier - .border( - width = Dp.Hairline, - color = Color.Gray, - shape = RoundedCornerShape(percent = 20), - ).padding(all = 10.dp), + modifier = modifier.padding(all = 10.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy10dp, ) { Column( modifier = modifier.weight(1f), - verticalArrangement = Arrangement.Center, + verticalArrangement = SpacedBy5dp, ) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, ) { - Text(listHeader, fontWeight = FontWeight.Bold) - Spacer(modifier = StdHorzSpacer) Icon( painter = painterResource(R.drawable.format_list_bulleted_type), contentDescription = stringRes(R.string.follow_set_icon_description), ) + Text(listHeader, fontWeight = FontWeight.Bold) } - Spacer(modifier = StdVertSpacer) - Row { - FilterChip( - selected = isUserInList, - enabled = isUserInList, - onClick = {}, - label = { - Text( - text = - if (isUserInList) { - if (userIsPublicMember) { - stringRes(R.string.follow_set_public_presence_indicator, userName) - } else { - stringRes(R.string.follow_set_private_presence_indicator, userName) - } + FilterChip( + selected = true, + onClick = {}, + label = { + Text( + text = + if (isUserInList) { + if (userIsPublicMember) { + stringRes(R.string.follow_set_public_presence_indicator, userName) } else { - stringRes(R.string.follow_set_absence_indicator, userName) - }, - ) + stringRes(R.string.follow_set_private_presence_indicator, userName) + } + } else { + stringRes(R.string.follow_set_absence_indicator, userName) + }, + ) + }, + leadingIcon = + if (isUserInList) { + { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAddCheck, + contentDescription = null, + ) + } + } else { + null }, - leadingIcon = - if (isUserInList) { - { - Icon( - imageVector = Icons.AutoMirrored.Filled.PlaylistAddCheck, - contentDescription = null, - ) - } - } else { - null - }, - shape = ButtonBorder, - ) - Spacer(modifier = StdHorzSpacer) - } + shape = ButtonBorder, + ) } Column( @@ -416,22 +344,18 @@ fun FollowSetItem( ) { if (isUserInList) { Icon( - imageVector = Icons.Filled.Delete, + imageVector = Icons.Filled.PersonRemove, contentDescription = null, tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( - imageVector = Icons.Filled.Add, + imageVector = Icons.Filled.PersonAdd, contentDescription = null, tint = Color.White, ) } } - Text( - text = stringRes(if (isUserInList) R.string.remove else R.string.add), - color = MaterialTheme.colorScheme.onBackground, - ) UserAdditionOptionsMenu( isExpanded = isUserAddTapped.value, @@ -446,7 +370,6 @@ fun FollowSetItem( @Composable private fun UserAdditionOptionsMenu( - modifier: Modifier = Modifier, isExpanded: Boolean, onUserAdd: (asPrivateMember: Boolean) -> Unit, onDismiss: () -> Unit, @@ -518,7 +441,7 @@ fun FollowSetsCreationMenu( } if (isListAdditionDialogOpen.value) { - NewSetCreationDialog( + NewPeopleListCreationDialog( onDismiss = { isListAdditionDialogOpen.value = false isPrivateOptionTapped.value = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 4724b827f..2762ca840 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -41,7 +41,7 @@ fun NotificationTopBar( val list by accountViewModel.account.settings.defaultNotificationFollowList .collectAsStateWithLifecycle() - FollowLists( + TopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -51,8 +51,8 @@ fun NotificationTopBar( } @Composable -private fun FollowLists( - followListsModel: FollowListState, +private fun TopNavFilterBar( + followListsModel: TopNavFilterState, listName: String, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt index 2d7c7de83..ca645e0fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileActions.kt @@ -65,7 +65,7 @@ fun ProfileActions( } TextButton( - onClick = { nav.nav(Route.FollowSetManagement(baseUser.pubkeyHex)) }, + onClick = { nav.nav(Route.PeopleListManagement(baseUser.pubkeyHex)) }, shape = ButtonBorder.copy(topStart = CornerSize(0f), bottomStart = CornerSize(0f)), colors = ButtonDefaults.filledTonalButtonColors(), contentPadding = ZeroPadding, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 25f33b600..29efbe674 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -28,7 +28,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar import com.vitorpamplona.amethyst.ui.screen.FeedDefinition -import com.vitorpamplona.amethyst.ui.screen.FollowListState +import com.vitorpamplona.amethyst.ui.screen.TopNavFilterState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -41,7 +41,7 @@ fun StoriesTopBar( val list by accountViewModel.account.settings.defaultStoriesFollowList .collectAsStateWithLifecycle() - FollowList( + TopNavFilterBar( followListsModel = accountViewModel.feedStates.feedListOptions, listName = list, accountViewModel = accountViewModel, @@ -52,8 +52,8 @@ fun StoriesTopBar( } @Composable -private fun FollowList( - followListsModel: FollowListState, +private fun TopNavFilterBar( + followListsModel: TopNavFilterState, listName: String, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index 92671a5dd..129fe777d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.LocalCache.notes import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index 3afd5e115..9a0d02a04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -92,6 +92,7 @@ val Height100Modifier = Modifier.height(100.dp) val HalfDoubleVertSpacer = Modifier.height(7.dp) val Size0dp = 0.dp +val Size2dp = 2.dp val Size5dp = 5.dp val Size6dp = 6.dp val Size8dp = 8.dp @@ -364,3 +365,9 @@ val VoiceHeightModifier = Modifier.fillMaxWidth().height(100.dp) val PaddingHorizontal12Modifier = Modifier.padding(horizontal = 12.dp) val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = SmallestBorder) + +val SpacedBy2dp = Arrangement.spacedBy(Size2dp) +val SpacedBy5dp = Arrangement.spacedBy(Size5dp) +val SpacedBy10dp = Arrangement.spacedBy(Size10dp) + +val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 99c201726..4dc54cf4a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -137,6 +137,8 @@ Public Chat Feed Global Feed Search Feed + Search and add user + Add a User Add a Relay Name Display Name @@ -509,7 +511,7 @@ Global Mute List - Follow Sets + Follow Lists Labeled Bookmarks General Bookmarks Public @@ -520,7 +522,7 @@ \nTap below to refresh, or tap the add buttons to create a new one. New - Add author to follow set + Add author to follow list Add or remove user from lists, or create a new list with this user. Icon for follow set %1$s is a public member @@ -535,25 +537,25 @@ members No members %1$s is not in this list - Your Follow Sets - No follow sets were found, or you don\'t have any follow sets. Tap below to refresh, or use the menu to create one. + Your Lists + No follow lists were found, or you don\'t have any follow lists. Tap below to refresh, or use the menu to create one. There was a problem while fetching: %1$s Make New List New list with %1$s membership Creates a new follow set, and adds %1$s as a %2$s member. - New Follow Set - Copy/Clone Follow Set + New Follow List + Copy/Clone Follow List Modify description This list doesn\'t have a description Current description: - You can set a custom name/description for this clone set below. - Set name - (Original Set name) - Set description(optional) - (Original Set description) - Create set - Copy/Clone set - Rename set + Set a new name/description for the cloned list below. + List name + New List name + List description (optional) + New List description + Create list + Copy/Clone list + Rename list Modify You are renaming from to.. @@ -1308,6 +1310,8 @@ Event doesn\'t have enough information to build a magnet link My Lists/Sets + My Lists + Users Select a list to filter the feed Log off on device lock @@ -1335,4 +1339,8 @@ Send it This message will disappear in %1$d days Select Signer + + Already in the list + Private Members + Public Members diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt index 1676c2bc5..3af970d2a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/peopleList/PeopleListEvent.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArray import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.fastAny import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate @@ -73,9 +74,11 @@ class PeopleListEvent( fun countMutes() = tags.count(MuteTag::isTagged) - fun publicPeople(): List = tags.mapNotNull(MuteTag::parse) + fun publicMembers(): List = tags.mapNotNull(MuteTag::parse) - suspend fun privatePeople(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) + fun publicUsersIdSet(): Set = tags.mapNotNullTo(mutableSetOf(), UserTag::parseKey) + + suspend fun privateMembers(signer: NostrSigner): List? = privateTags(signer)?.mapNotNull(MuteTag::parse) companion object { const val KIND = 30000 @@ -98,8 +101,8 @@ class PeopleListEvent( if (isPrivate) { create( name = name, - publicPeople = emptyList(), - privatePeople = listOf(person), + publicMembers = emptyList(), + privateMembers = listOf(person), signer = signer, dTag = dTag, createdAt = createdAt, @@ -107,8 +110,8 @@ class PeopleListEvent( } else { create( name = name, - publicPeople = listOf(person), - privatePeople = emptyList(), + publicMembers = listOf(person), + privateMembers = emptyList(), signer = signer, dTag = dTag, createdAt = createdAt, @@ -139,6 +142,30 @@ class PeopleListEvent( ) } + suspend fun addFirst( + earlierVersion: PeopleListEvent, + person: UserTag, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + if (isPrivate) { + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + resign( + publicTags = earlierVersion.tags, + privateTags = arrayOf(person.toTagArray()) + privateTags, + signer = signer, + createdAt = createdAt, + ) + } else { + resign( + content = earlierVersion.content, + tags = arrayOf(person.toTagArray()) + earlierVersion.tags, + signer = signer, + createdAt = createdAt, + ) + } + suspend fun remove( earlierVersion: PeopleListEvent, person: MuteTag, @@ -211,34 +238,34 @@ class PeopleListEvent( @OptIn(ExperimentalUuidApi::class) suspend fun create( name: String, - publicPeople: List = emptyList(), - privatePeople: List = emptyList(), + publicMembers: List = emptyList(), + privateMembers: List = emptyList(), signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), ): PeopleListEvent { - val template = build(name, publicPeople, privatePeople, signer, dTag, createdAt) + val template = build(name, publicMembers, privateMembers, signer, dTag, createdAt) return signer.sign(template) } @OptIn(ExperimentalUuidApi::class) suspend fun build( name: String, - publicPeople: List = emptyList(), - privatePeople: List = emptyList(), + publicMembers: List = emptyList(), + privateMembers: List = emptyList(), signer: NostrSigner, dTag: String = Uuid.random().toString(), createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate( kind = KIND, - description = PrivateTagsInContent.encryptNip44(privatePeople.map { it.toTagArray() }.toTypedArray(), signer), + description = PrivateTagsInContent.encryptNip44(privateMembers.map { it.toTagArray() }.toTypedArray(), signer), createdAt = createdAt, ) { dTag(dTag) alt(ALT) name(name) - peoples(publicPeople) + peoples(publicMembers) initializer() } @@ -247,53 +274,39 @@ class PeopleListEvent( dTag: String, title: String, description: String? = null, - isPrivate: Boolean, - firstPublicMembers: List = emptyList(), - firstPrivateMembers: List = emptyList(), + publicMembers: List = emptyList(), + privateMembers: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { + ): PeopleListEvent { val newListTemplate = build( name = title, - publicPeople = - if (!isPrivate && firstPublicMembers.isNotEmpty()) { - firstPublicMembers.map { UserTag(pubKey = it) } - } else { - emptyList() - }, - privatePeople = - if (isPrivate && firstPrivateMembers.isNotEmpty()) { - firstPrivateMembers.map { UserTag(pubKey = it) } - } else { - emptyList() - }, + publicMembers = publicMembers, + privateMembers = privateMembers, signer = signer, dTag = dTag, createdAt = createdAt, ) { if (description != null) addUnique(DescriptionTag.assemble(description)) } - val newList = signer.sign(newListTemplate) - onReady(newList) + return signer.sign(newListTemplate) } suspend fun copy( dTag: String, title: String, description: String? = null, - firstPublicMembers: List = emptyList(), - firstPrivateMembers: List = emptyList(), + publicMembers: List = emptyList(), + privateMembers: List = emptyList(), signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { + ): PeopleListEvent { val cloneTemplate = build( name = title, - publicPeople = firstPublicMembers.map { UserTag(pubKey = it) }, - privatePeople = firstPrivateMembers.map { UserTag(pubKey = it) }, + publicMembers = publicMembers, + privateMembers = privateMembers, signer = signer, dTag = dTag, createdAt = createdAt, @@ -301,8 +314,7 @@ class PeopleListEvent( if (description != null) addUnique(DescriptionTag.assemble(description)) } - val listClone = signer.sign(cloneTemplate) - onReady(listClone) + return signer.sign(cloneTemplate) } suspend fun createListWithUser( @@ -311,37 +323,46 @@ class PeopleListEvent( 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) - } + ): PeopleListEvent = + create( + name = name, + person = UserTag(pubKey = pubKeyHex), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) suspend fun addUser( earlierVersion: PeopleListEvent, pubKeyHex: String, + relayHint: NormalizedRelayUrl?, 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) - } + ): PeopleListEvent = + add( + earlierVersion = earlierVersion, + person = UserTag(pubKey = pubKeyHex, relayHint), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) + + suspend fun addUserFirst( + earlierVersion: PeopleListEvent, + pubKeyHex: String, + relayHint: NormalizedRelayUrl?, + isPrivate: Boolean, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PeopleListEvent = + addFirst( + earlierVersion = earlierVersion, + person = UserTag(pubKey = pubKeyHex, relayHint), + isPrivate = isPrivate, + signer = signer, + createdAt = createdAt, + ) suspend fun removeUser( earlierVersion: PeopleListEvent, @@ -349,44 +370,36 @@ class PeopleListEvent( isUserPrivate: Boolean, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit, - ) { - val updatedList = - remove( - earlierVersion = earlierVersion, - person = UserTag(pubKey = pubKeyHex), - isPrivate = isUserPrivate, - signer = signer, - createdAt = createdAt, - ) - onReady(updatedList) - } + ): PeopleListEvent = + remove( + earlierVersion = earlierVersion, + person = UserTag(pubKey = pubKeyHex), + isPrivate = isUserPrivate, + signer = signer, + createdAt = createdAt, + ) suspend fun modifyListName( earlierVersion: PeopleListEvent, newName: String, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit = {}, - ) { + ): PeopleListEvent { 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) + TitleTag.assemble(newName) } - val modified = - resign( - publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag), - privateTags = privateTags.replaceAll(currentTitle, newTitleTag), - signer = signer, - createdAt = createdAt, - ) - onReady(modified) + return resign( + publicTags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag), + privateTags = privateTags.replaceAll(currentTitle, newTitleTag), + signer = signer, + createdAt = createdAt, + ) } suspend fun modifyDescription( @@ -394,43 +407,38 @@ class PeopleListEvent( newDescription: String?, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - onReady: (PeopleListEvent) -> Unit = {}, - ) { + ): PeopleListEvent? { val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME } val currentDescription = currentDescriptionTag?.get(1) if (currentDescription.equals(newDescription)) { // Do nothing - return + return null } else { if (newDescription == null || newDescription.isEmpty()) { - val modified = + return resign( + publicTags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME }, + privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME }, + signer = signer, + createdAt = createdAt, + ) + } else { + val newDescriptionTag = DescriptionTag.assemble(newDescription) + return if (currentDescriptionTag == null) { resign( - publicTags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME }, - privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME }, + publicTags = earlierVersion.tags.plusElement(newDescriptionTag), + privateTags = privateTags, signer = signer, createdAt = createdAt, ) - onReady(modified) - } else { - val newDescriptionTag = DescriptionTag.assemble(newDescription) - val modified = - if (currentDescriptionTag == null) { - resign( - publicTags = earlierVersion.tags.plusElement(newDescriptionTag), - privateTags = privateTags, - signer = signer, - createdAt = createdAt, - ) - } else { - resign( - publicTags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag), - privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag), - signer = signer, - createdAt = createdAt, - ) - } - onReady(modified) + } else { + resign( + publicTags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag), + privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag), + signer = signer, + createdAt = createdAt, + ) + } } } }