From 198e950f37375fda2db5f2179e3a8400dc93c558 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 13:28:51 +0000 Subject: [PATCH 1/2] feat(interest-sets): NIP-51 Interest Sets (kind 30015) UI + TopNav chips Add Android UI for managing NIP-51 Interest Sets and surface them as chips in the TopNav feed filter alongside followed hashtags. InterestSetEvent was already implemented in Quartz; this wires it into the app. - InterestSetsState mirrors LabeledBookmarkListsState: listFeedFlow with versioned refresh, decrypted hashtag cache keyed by dTag for the feed filter pipeline, and suspend helpers for create/rename/delete/clone/add hashtag/remove hashtag/move (public<->private). - New screens under ui/screen/loggedIn/interestSets/: list, metadata edit (create/rename, title-only since InterestSetEvent has no image field), and display screen with add/remove + public/private toggle per hashtag. - TopFilter.InterestSet(address) feeds into a new MultiHashtagFeedFlow that reuses HashtagTopNavFilter with Set. - TopNavFilterState.mergeInterests emits interest-set chips; FeedFilterSpinner groups them under a new "Interest Sets" category. - Drawer entry + HomeScreen FAB branch. --- .../vitorpamplona/amethyst/model/Account.kt | 5 + .../amethyst/model/AccountSettings.kt | 5 + .../nip51Lists/interestSets/InterestSet.kt | 33 +++ .../interestSets/InterestSetsState.kt | 269 ++++++++++++++++++ .../topNavFeeds/FeedTopNavFilterState.kt | 7 + .../hashtag/MultiHashtagFeedFlow.kt | 53 ++++ .../amethyst/ui/navigation/AppNavigation.kt | 7 + .../ui/navigation/drawer/DrawerContent.kt | 9 + .../amethyst/ui/navigation/routes/Routes.kt | 10 + .../navigation/topbars/FeedFilterSpinner.kt | 14 + .../amethyst/ui/screen/TopNavFilterState.kt | 36 ++- .../ui/screen/loggedIn/home/HomeScreen.kt | 4 + .../interestSets/display/InterestSetScreen.kt | 247 ++++++++++++++++ .../interestSets/list/InterestSetItem.kt | 146 ++++++++++ .../list/ListOfInterestSetsScreen.kt | 131 +++++++++ .../metadata/InterestSetMetadataScreen.kt | 168 +++++++++++ .../metadata/InterestSetMetadataViewModel.kt | 85 ++++++ .../loggedIn/settings/AllSettingsScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 16 ++ 19 files changed, 1240 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSet.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/MultiHashtagFeedFlow.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/display/InterestSetScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/InterestSetItem.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataViewModel.kt 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 43376d5d6..185f30cc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecry import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState @@ -360,6 +361,7 @@ class Account( val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings) val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope) + val interestSets = InterestSetsState(signer, cache, scope) val oldBookmarkState = OldBookmarkListState(signer, cache, scope) val bookmarkState = BookmarkListState(signer, cache, scope) val pinState = PinListState(signer, cache, scope) @@ -440,6 +442,7 @@ class Account( scope = scope, favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator, favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow, + interestSetHashtags = interestSets.hashtagsByIdentifier, ).flow // App-ready Feeds @@ -2850,6 +2853,7 @@ class Account( peopleLists.newNotes(newNotes) followLists.newNotes(newNotes) labeledBookmarkLists.newNotes(newNotes) + interestSets.newNotes(newNotes) } } } @@ -2861,6 +2865,7 @@ class Account( peopleLists.deletedNotes(deletedNotes) followLists.deletedNotes(deletedNotes) labeledBookmarkLists.deletedNotes(deletedNotes) + interestSets.deletedNotes(deletedNotes) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 5397730e8..e1d9ac714 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -165,6 +165,11 @@ sealed class TopFilter( ) : TopFilter("FavoriteAlgoFeed/${address.toValue()}") @Serializable object AllFavoriteAlgoFeeds : TopFilter(" All Favourite DVMs ") + + @Serializable + class InterestSet( + val address: Address, + ) : TopFilter("InterestSet/${address.toValue()}") } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSet.kt new file mode 100644 index 000000000..f8f86f088 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSet.kt @@ -0,0 +1,33 @@ +/* + * 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.interestSets + +import androidx.compose.runtime.Stable + +@Stable +data class InterestSet( + val identifier: String, + val title: String, + val publicHashtags: Set = emptySet(), + val privateHashtags: Set = emptySet(), +) { + val allHashtags: Set get() = publicHashtags + privateHashtags +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt new file mode 100644 index 000000000..baeff9cd6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/interestSets/InterestSetsState.kt @@ -0,0 +1,269 @@ +/* + * 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.interestSets + +import com.vitorpamplona.amethyst.commons.model.anyNotNullEvent +import com.vitorpamplona.amethyst.commons.model.eventIdSet +import com.vitorpamplona.amethyst.commons.model.events +import com.vitorpamplona.amethyst.commons.model.updateFlow +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.filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent +import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag +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 + +class InterestSetsState( + val signer: NostrSigner, + val cache: LocalCache, + val scope: CoroutineScope, +) { + val user = cache.getOrCreateUser(signer.pubKey) + + fun existingInterestSetNotes() = cache.addressables.filter(InterestSetEvent.KIND, user.pubkeyHex) + + val interestSetVersions = MutableStateFlow(0) + + val interestSetNotes = + interestSetVersions + .map { existingInterestSetNotes() } + .onStart { emit(existingInterestSetNotes()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + val interestSetEventIds = + interestSetNotes + .map { it.eventIdSet() } + .onStart { emit(interestSetNotes.value.eventIdSet()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptySet()) + + @OptIn(ExperimentalCoroutinesApi::class) + val latestInterestSets: StateFlow> = + interestSetNotes + .transformLatest { emitAll(it.updateFlow()) } + .onStart { emit(interestSetNotes.value.events()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + suspend fun InterestSetEvent.toInterestSet(): InterestSet { + val title = tags.firstNotNullOfOrNull(TitleTag::parse) ?: dTag() + val publics = publicHashtags().toSet() + val privates = privateHashtags(signer)?.toSet() ?: emptySet() + return InterestSet( + identifier = dTag(), + title = title, + publicHashtags = publics, + privateHashtags = privates, + ) + } + + suspend fun List.toInterestSetsFeed() = map { it.toInterestSet() }.sortedBy { it.title } + + val listFeedFlow = + latestInterestSets + .map { it.toInterestSetsFeed() } + .onStart { emit(latestInterestSets.value.toInterestSetsFeed()) } + .flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + fun List.getSet(identifier: String) = firstOrNull { it.identifier == identifier } + + fun getInterestSet(dTag: String) = listFeedFlow.value.getSet(dTag) + + fun DeletionEvent.hasAnyDeletedInterestSets() = deleteAddressesWithKind(InterestSetEvent.KIND) || deletesAnyEventIn(interestSetEventIds.value) + + fun hasItemInNoteList(notes: Set): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is InterestSetEvent || (event is DeletionEvent && event.hasAnyDeletedInterestSets()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + if (hasItemInNoteList(deletedNotes)) { + forceRefresh() + } + } + + fun forceRefresh() { + interestSetVersions.update { it + 1 } + } + + fun getInterestSetNote(identifier: String): AddressableNote? = existingInterestSetNotes().find { it.dTag() == identifier } + + fun getInterestSetEvent(identifier: String): InterestSetEvent = getInterestSetNote(identifier)?.event as InterestSetEvent + + suspend fun createInterestSet( + title: String, + firstHashtag: String? = null, + isHashtagPrivate: Boolean = false, + account: Account, + ) { + val publicHashtags = if (firstHashtag != null && !isHashtagPrivate) listOf(firstHashtag.lowercase()) else emptyList() + val privateHashtags = if (firstHashtag != null && isHashtagPrivate) listOf(firstHashtag.lowercase()) else emptyList() + + val newSet = + InterestSetEvent.create( + title = title, + publicHashtags = publicHashtags, + privateHashtags = privateHashtags, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(newSet) + } + + suspend fun renameInterestSet( + newName: String, + set: InterestSet, + account: Account, + ) { + val template = + InterestSetEvent.build( + title = newName, + publicHashtags = set.publicHashtags.toList(), + privateHashtags = set.privateHashtags.toList(), + dTag = set.identifier, + signer = account.signer, + ) + val renamed = account.signer.sign(template) + account.sendMyPublicAndPrivateOutbox(renamed) + } + + suspend fun deleteInterestSet( + identifier: String, + account: Account, + ) { + val event = getInterestSetEvent(identifier) + val template = DeletionEvent.build(listOf(event)) + val deletionEvent = account.signer.sign(template) + account.sendMyPublicAndPrivateOutbox(deletionEvent) + } + + suspend fun cloneInterestSet( + source: InterestSet, + customName: String?, + account: Account, + ) { + val cloned = + InterestSetEvent.create( + title = customName ?: source.title, + publicHashtags = source.publicHashtags.toList(), + privateHashtags = source.privateHashtags.toList(), + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(cloned) + } + + suspend fun addHashtagToSet( + hashtag: String, + identifier: String, + isPrivate: Boolean, + account: Account, + ) { + val event = getInterestSetEvent(identifier) + val updated = + InterestSetEvent.add( + earlierVersion = event, + hashtag = hashtag.lowercase(), + isPrivate = isPrivate, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(updated) + } + + suspend fun removeHashtagFromSet( + hashtag: String, + identifier: String, + account: Account, + ) { + val event = getInterestSetEvent(identifier) + val updated = + InterestSetEvent.remove( + earlierVersion = event, + hashtag = hashtag, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(updated) + } + + suspend fun moveHashtagInSet( + hashtag: String, + identifier: String, + isCurrentlyPrivate: Boolean, + account: Account, + ) { + val event = getInterestSetEvent(identifier) + val removed = + InterestSetEvent.remove( + earlierVersion = event, + hashtag = hashtag, + signer = account.signer, + ) + val moved = + InterestSetEvent.add( + earlierVersion = removed, + hashtag = hashtag.lowercase(), + isPrivate = !isCurrentlyPrivate, + signer = account.signer, + ) + account.sendMyPublicAndPrivateOutbox(moved) + } + + /** + * Cached decrypted hashtags per set identifier. Used by the TopNav feed filter + * to avoid blocking decryption on hot flows. Populated from [listFeedFlow]. + */ + val hashtagsByIdentifier: StateFlow>> = + listFeedFlow + .map { list -> + list.associate { it.identifier to it.allHashtags } + }.onStart { + emit(listFeedFlow.value.associate { it.identifier to it.allHashtags }) + }.flowOn(Dispatchers.IO) + .stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + fun hashtagsFor(identifier: String): Set = hashtagsByIdentifier.value[identifier].orEmpty() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index bc523eec8..2b8cb6248 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.AllFavorit import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.MultiHashtagFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow import com.vitorpamplona.amethyst.service.location.LocationState @@ -68,6 +69,7 @@ class FeedTopNavFilterState( val scope: CoroutineScope, val favoriteAlgoFeedsOrchestrator: FavoriteAlgoFeedsOrchestrator, val favoriteAlgoFeedAddresses: StateFlow>, + val interestSetHashtags: StateFlow>> = MutableStateFlow(emptyMap()), ) { fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { @@ -149,6 +151,11 @@ class FeedTopNavFilterState( HashtagFeedFlow(listName.tag, followsRelays, proxyRelays) } + is TopFilter.InterestSet -> { + val hashtags = interestSetHashtags.value[listName.address.dTag].orEmpty() + MultiHashtagFeedFlow(hashtags, followsRelays, proxyRelays) + } + is TopFilter.Relay -> { RelayFeedFlow(listName.url.normalizeRelayUrl()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/MultiHashtagFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/MultiHashtagFeedFlow.kt new file mode 100644 index 000000000..a407825c3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/hashtag/MultiHashtagFeedFlow.kt @@ -0,0 +1,53 @@ +/* + * 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.topNavFeeds.hashtag + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +class MultiHashtagFeedFlow( + val hashtags: Set, + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + fun buildFilter(relays: Set) = HashtagTopNavFilter(hashtags, relays) + + override fun flow(): Flow = + combine(outboxRelays, proxyRelays) { outbox, proxy -> + if (proxy.isNotEmpty()) buildFilter(proxy) else buildFilter(outbox) + } + + override fun startValue(): HashtagTopNavFilter = + if (proxyRelays.value.isNotEmpty()) { + buildFilter(proxyRelays.value) + } else { + buildFilter(outboxRelays.value) + } + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } +} 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 70461364d..90b1944a1 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 @@ -108,6 +108,9 @@ 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.home.VoiceReplyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.nip75Goals.NewGoalScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.interestSets.display.InterestSetScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.interestSets.list.ListOfInterestSetsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.interestSets.list.metadata.InterestSetMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen @@ -247,6 +250,10 @@ fun BuildNavigation( composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } + + composableFromEnd { ListOfInterestSetsScreen(accountViewModel, nav) } + composableFromEndArgs { InterestSetScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { InterestSetMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), 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 3e04757e4..000b5da78 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 @@ -66,6 +66,7 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.SettingsInputAntenna import androidx.compose.material.icons.outlined.SmartDisplay import androidx.compose.material.icons.outlined.Storefront +import androidx.compose.material.icons.outlined.Tag import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -561,6 +562,14 @@ fun ListContent( route = Route.BookmarkGroups, ) + NavigationRow( + title = R.string.interest_sets_title, + icon = Icons.Outlined.Tag, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.InterestSets, + ) + NavigationRow( title = R.string.web_bookmarks, icon = Icons.Outlined.Language, 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 5e33e0053..96b1ad6a3 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 @@ -106,6 +106,16 @@ sealed class Route { @Serializable object BookmarkGroups : Route() + @Serializable object InterestSets : Route() + + @Serializable data class InterestSetView( + val dTag: String, + ) : Route() + + @Serializable data class InterestSetMetadataEdit( + val dTag: String? = null, + ) : Route() + @Serializable object ImportFollowsSelectUser : Route() @Serializable data class ImportFollowsPickFollows( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index 9d01c8a12..cc178d3e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName +import com.vitorpamplona.amethyst.ui.screen.InterestSetName import com.vitorpamplona.amethyst.ui.screen.Name import com.vitorpamplona.amethyst.ui.screen.PeopleListName import com.vitorpamplona.amethyst.ui.screen.RelayName @@ -374,6 +375,14 @@ fun RenderOption( color = MaterialTheme.colorScheme.onSurface, ) } + + is InterestSetName -> { + Text( + text = option.name(), + fontSize = Font14SP, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } @@ -388,6 +397,7 @@ private enum class FeedGroup( ) { FEEDS(R.string.feed_group_feeds), HASHTAGS(R.string.feed_group_hashtags), + INTEREST_SETS(R.string.feed_group_interest_sets), COMMUNITIES(R.string.feed_group_communities), LOCATIONS(R.string.feed_group_locations), LISTS(R.string.feed_group_lists), @@ -423,6 +433,10 @@ private fun groupFeedDefinitions(options: ImmutableList): Map { + FeedGroup.INTEREST_SETS + } + is ResourceName -> { when (entry.item.code) { is TopFilter.AroundMe -> FeedGroup.LOCATIONS diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index fd2252b3f..d008ad627 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -27,12 +27,14 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.TopFilter +import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.Log @@ -101,8 +103,8 @@ class TopNavFilterState( FeedDefinition( code = TopFilter.Mine, name = ResourceName(R.string.follow_list_mine), - ) - + ) + val allFavoriteAlgoFeedsFollow = FeedDefinition( code = TopFilter.AllFavoriteAlgoFeeds, @@ -154,6 +156,7 @@ class TopNavFilterState( communityList: List, relayList: Set, favoriteAlgoFeedsList: List, + interestSetList: List, ): List { val hashtags = hashtagList.map { @@ -206,7 +209,15 @@ class TopNavFilterState( val allFavorites = if (favoriteAlgoFeeds.isNotEmpty()) listOf(allFavoriteAlgoFeedsFollow) else emptyList() - return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds).sortedBy { it.name.name() } + val interestSets = + interestSetList.map { set -> + FeedDefinition( + TopFilter.InterestSet(InterestSetEvent.createAddress(account.signer.pubKey, set.identifier)), + InterestSetName(set), + ) + } + + return (communities + hashtags + geotags + relays + allFavorites + favoriteAlgoFeeds + interestSets).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -216,9 +227,14 @@ class TopNavFilterState( account.geohashList.flow, account.communityList.flowNotes, account.relayFeedsList.flow, - account.favoriteAlgoFeedsList.flowNotes, - ::mergeInterests, - ).onStart { + combine( + account.favoriteAlgoFeedsList.flowNotes, + account.interestSets.listFeedFlow, + ::Pair, + ), + ) { hashtagList, geotagList, communityList, relayList, favAndInterest -> + mergeInterests(hashtagList, geotagList, communityList, relayList, favAndInterest.first, favAndInterest.second) + }.onStart { emit( mergeInterests( account.hashtagList.flow.value, @@ -226,6 +242,7 @@ class TopNavFilterState( account.communityList.flowNotes.value, account.relayFeedsList.flow.value, account.favoriteAlgoFeedsList.flowNotes.value, + account.interestSets.listFeedFlow.value, ), ) } @@ -359,6 +376,13 @@ class FavoriteAlgoFeedName( ?: note.dTag() } +@Stable +class InterestSetName( + val set: InterestSet, +) : Name() { + override fun name() = "⁂ ${set.title}" +} + @Immutable class FeedDefinition( val code: TopFilter, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index fb2ee8914..25a39e1cc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -282,6 +282,10 @@ fun HomeScreenFloatingButton( NewHashtagPostButton(filter.tag, accountViewModel, nav) } + is TopFilter.InterestSet -> { + NewNoteButton(nav) + } + else -> { NewNoteButton(nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/display/InterestSetScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/display/InterestSetScreen.kt new file mode 100644 index 000000000..d09d04dad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/display/InterestSetScreen.kt @@ -0,0 +1,247 @@ +/* + * 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.interestSets.display + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +@Composable +fun InterestSetScreen( + dTag: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val sets by accountViewModel.account.interestSets.listFeedFlow + .collectAsStateWithLifecycle() + val set = sets.firstOrNull { it.identifier == dTag } + + Scaffold( + topBar = { + TopBarWithBackButton(caption = set?.title ?: stringRes(R.string.interest_sets_title), nav::popBack) + }, + ) { paddingValues -> + if (set == null) { + Column( + modifier = Modifier.fillMaxSize().padding(paddingValues), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringRes(R.string.interest_sets_empty)) + } + } else { + InterestSetContent( + set = set, + paddingValues = paddingValues, + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +private fun InterestSetContent( + set: InterestSet, + paddingValues: PaddingValues, + accountViewModel: AccountViewModel, +) { + var newHashtag by remember { mutableStateOf("") } + var isPrivate by remember { mutableStateOf(false) } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = newHashtag, + onValueChange = { newHashtag = it.trimStart('#').trim() }, + label = { Text(stringRes(R.string.interest_set_hashtag_add_placeholder)) }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + ), + ) + IconButton( + onClick = { + val tag = newHashtag.trim() + if (tag.isNotEmpty()) { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.addHashtagToSet( + hashtag = tag, + identifier = set.identifier, + isPrivate = isPrivate, + account = accountViewModel.account, + ) + } + newHashtag = "" + } + }, + enabled = newHashtag.isNotBlank(), + ) { + Icon(Icons.Default.Add, contentDescription = stringRes(R.string.interest_set_add_hashtag)) + } + } + + FilterChip( + selected = isPrivate, + onClick = { isPrivate = !isPrivate }, + label = { Text(stringRes(R.string.interest_set_hashtag_private_toggle)) }, + leadingIcon = { + Icon( + imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + ) + }, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(top = 10.dp), + ) { + val publics = set.publicHashtags.sorted() + val privates = set.privateHashtags.sorted() + + items(publics, key = { "public-$it" }) { tag -> + HashtagRow( + hashtag = tag, + isPrivate = false, + onDelete = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.removeHashtagFromSet( + hashtag = tag, + identifier = set.identifier, + account = accountViewModel.account, + ) + } + }, + onToggle = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.moveHashtagInSet( + hashtag = tag, + identifier = set.identifier, + isCurrentlyPrivate = false, + account = accountViewModel.account, + ) + } + }, + ) + } + + items(privates, key = { "private-$it" }) { tag -> + HashtagRow( + hashtag = tag, + isPrivate = true, + onDelete = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.removeHashtagFromSet( + hashtag = tag, + identifier = set.identifier, + account = accountViewModel.account, + ) + } + }, + onToggle = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.moveHashtagInSet( + hashtag = tag, + identifier = set.identifier, + isCurrentlyPrivate = true, + account = accountViewModel.account, + ) + } + }, + ) + } + } + } +} + +@Composable +private fun HashtagRow( + hashtag: String, + isPrivate: Boolean, + onDelete: () -> Unit, + onToggle: () -> Unit, +) { + ListItem( + headlineContent = { Text("#$hashtag") }, + leadingContent = { + IconButton(onClick = onToggle) { + Icon( + imageVector = if (isPrivate) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = stringRes(R.string.interest_set_toggle_visibility), + ) + } + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = stringRes(R.string.quick_action_delete), + ) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/InterestSetItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/InterestSetItem.kt new file mode 100644 index 000000000..c96996102 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/InterestSetItem.kt @@ -0,0 +1,146 @@ +/* + * 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.interestSets.list + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet +import com.vitorpamplona.amethyst.ui.components.ClickableBox +import com.vitorpamplona.amethyst.ui.components.M3ActionDialog +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders +import com.vitorpamplona.amethyst.ui.theme.Size40Modifier + +@Composable +fun InterestSetItem( + modifier: Modifier = Modifier, + interestSet: InterestSet, + onClick: () -> Unit, + onRename: () -> Unit, + onClone: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = modifier.clickable(onClick = onClick), + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ListItem( + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(interestSet.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + + Column( + modifier = NoSoTinyBorders, + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.End, + ) { + InterestSetOptionsButton( + onRename = onRename, + onClone = onClone, + onDelete = onDelete, + ) + } + } + }, + supportingContent = { + Text( + text = stringRes(R.string.interest_set_hashtag_count, interestSet.allHashtags.size), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tag, + contentDescription = null, + modifier = Size40Modifier, + ) + }, + ) + } + } +} + +@Composable +private fun InterestSetOptionsButton( + onRename: () -> Unit, + onClone: () -> Unit, + onDelete: () -> Unit, +) { + val isMenuOpen = remember { mutableStateOf(false) } + + ClickableBox( + onClick = { isMenuOpen.value = true }, + ) { + VerticalDotsIcon() + } + + if (isMenuOpen.value) { + M3ActionDialog( + title = stringRes(R.string.interest_set_actions_dialog_title), + onDismiss = { isMenuOpen.value = false }, + ) { + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Edit, text = stringRes(R.string.interest_set_rename)) { + onRename() + isMenuOpen.value = false + } + M3ActionRow(icon = Icons.Outlined.ContentCopy, text = stringRes(R.string.interest_set_clone)) { + onClone() + isMenuOpen.value = false + } + } + M3ActionSection { + M3ActionRow(icon = Icons.Outlined.Delete, text = stringRes(R.string.quick_action_delete), isDestructive = true) { + onDelete() + isMenuOpen.value = false + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt new file mode 100644 index 000000000..f803b1f05 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt @@ -0,0 +1,131 @@ +/* + * 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.interestSets.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.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 + +@Composable +fun ListOfInterestSetsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val sets by accountViewModel.account.interestSets.listFeedFlow + .collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopBarWithBackButton(caption = stringRes(R.string.interest_sets_title), nav::popBack) + }, + floatingActionButton = { + InterestSetFab(onAdd = { nav.nav(Route.InterestSetMetadataEdit()) }) + }, + ) { paddingValues -> + Column( + Modifier + .padding( + top = paddingValues.calculateTopPadding(), + bottom = paddingValues.calculateBottomPadding(), + ).fillMaxHeight(), + ) { + if (sets.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(stringRes(R.string.interest_sets_empty)) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(sets, key = { it.identifier }) { set -> + InterestSetItem( + interestSet = set, + onClick = { nav.nav(Route.InterestSetView(set.identifier)) }, + onRename = { nav.nav(Route.InterestSetMetadataEdit(set.identifier)) }, + onClone = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.cloneInterestSet( + source = set, + customName = null, + account = accountViewModel.account, + ) + } + }, + onDelete = { + accountViewModel.launchSigner { + accountViewModel.account.interestSets.deleteInterestSet( + identifier = set.identifier, + account = accountViewModel.account, + ) + } + }, + ) + } + } + } + } + } +} + +@Composable +fun InterestSetFab(onAdd: () -> Unit) { + ExtendedFloatingActionButton( + text = { + Text(text = stringRes(R.string.interest_set_create_btn_label)) + }, + icon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = null, + ) + }, + onClick = onAdd, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataScreen.kt new file mode 100644 index 000000000..46de03f62 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataScreen.kt @@ -0,0 +1,168 @@ +/* + * 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.interestSets.list.metadata + +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.CreatingTopBar +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions + +@Composable +fun InterestSetMetadataScreen( + identifier: String?, + accountViewModel: AccountViewModel, + nav: INav, +) { + val viewModel: InterestSetMetadataViewModel = viewModel() + viewModel.init(accountViewModel) + + if (identifier != null) { + LaunchedEffect(viewModel) { viewModel.load(identifier) } + } else { + LaunchedEffect(viewModel) { viewModel.new() } + } + + InterestSetMetadataScaffold(viewModel, accountViewModel, nav) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun InterestSetMetadataScaffold( + viewModel: InterestSetMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + Scaffold( + topBar = { + InterestSetMetadataTopBar(viewModel, accountViewModel, nav) + }, + ) { pad -> + LazyColumn( + Modifier + .fillMaxSize() + .padding( + start = 10.dp, + end = 10.dp, + top = pad.calculateTopPadding(), + bottom = pad.calculateBottomPadding(), + ).consumeWindowInsets(pad) + .imePadding(), + ) { + item { + ListName(viewModel) + } + } + } +} + +@Composable +fun InterestSetMetadataTopBar( + viewModel: InterestSetMetadataViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (viewModel.isNewList) { + CreatingTopBar( + titleRes = R.string.interest_set_creation_screen_title, + isActive = viewModel::canPost, + onCancel = { + viewModel.clear() + nav.popBack() + }, + onPost = { + try { + viewModel.createOrUpdate() + nav.popBack() + } catch (_: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } else { + SavingTopBar( + titleRes = R.string.interest_set_rename, + isActive = viewModel::canPost, + onCancel = { + viewModel.clear() + nav.popBack() + }, + onPost = { + try { + viewModel.createOrUpdate() + nav.popBack() + } catch (_: SignerExceptions.ReadOnlyException) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_sign_events, + ) + } + }, + ) + } +} + +@Composable +private fun ListName(viewModel: InterestSetMetadataViewModel) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.interest_set_name_label)) }, + modifier = Modifier.fillMaxWidth(), + value = viewModel.name.value, + onValueChange = { viewModel.name.value = it }, + placeholder = { + Text( + text = stringRes(R.string.interest_set_name_placeholder), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataViewModel.kt new file mode 100644 index 000000000..9b77a396e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/metadata/InterestSetMetadataViewModel.kt @@ -0,0 +1,85 @@ +/* + * 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.interestSets.list.metadata + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSet +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Stable +class InterestSetMetadataViewModel : ViewModel() { + private lateinit var accountViewModel: AccountViewModel + private lateinit var account: Account + + var interestSet by mutableStateOf(null) + val isNewList by derivedStateOf { interestSet == null } + + val name = mutableStateOf(TextFieldValue()) + + val canPost by derivedStateOf { + name.value.text.isNotBlank() + } + + fun init(accountViewModel: AccountViewModel) { + this.accountViewModel = accountViewModel + this.account = accountViewModel.account + } + + fun new() { + interestSet = null + clear() + } + + fun load(dTag: String) { + interestSet = account.interestSets.getInterestSet(dTag) + name.value = TextFieldValue(interestSet?.title ?: "") + } + + fun createOrUpdate() { + accountViewModel.launchSigner { + val set = interestSet + if (set == null) { + account.interestSets.createInterestSet( + title = name.value.text, + account = account, + ) + } else { + account.interestSets.renameInterestSet( + newName = name.value.text, + set = set, + account = account, + ) + } + clear() + } + } + + fun clear() { + name.value = TextFieldValue() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt index f277f5de9..f040d037e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -130,7 +130,7 @@ fun AllSettingsScreen( tint = tint, onClick = { nav.nav(Route.ProfileBadges) }, ) - HorizontalDivider() + HorizontalDivider() SettingsNavigationRow( title = R.string.favorite_dvms_title, icon = Icons.Outlined.AutoAwesome, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b375737b9..510936a64 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -485,6 +485,21 @@ View Links View Hashtags You do not have any bookmark lists yet. Tap the new button below to make one. + + Interest Sets + You do not have any interest sets yet. Tap the new button below to make one. + New Interest Set + New Interest Set + Set Name + My interests + Add hashtag + Private + %1$d hashtag(s) + Interest Set Actions + Rename + Clone + Add hashtag + Toggle public/private Private Posts Private Posts(%1$s) Public Posts @@ -1764,6 +1779,7 @@ Select an option to filter the feed Feeds Hashtags + Interest Sets Locations Communities Lists From 4c82011a25d06f69bc2667945e8d53607dcd74eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 14:11:06 +0000 Subject: [PATCH 2/2] fix(interest-sets): consume kind 30015 locally + empty-state polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalCache.consume dispatcher now handles InterestSetEvent via consumeBaseReplaceable, so the event created by the user is stored and flows through newEventBundles → interestSets.newNotes → listFeedFlow refresh. Without this, the just-signed event sat in the sendMyPublicAndPrivateOutbox call but the UI never saw the update. - List screen: icon + centered text for the empty state and dividers between rows. --- .../amethyst/model/LocalCache.kt | 2 + .../list/ListOfInterestSetsScreen.kt | 45 +++++++++++++++---- 2 files changed, 38 insertions(+), 9 deletions(-) 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 aed8edf69..8512b8d51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -153,6 +153,7 @@ import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFee import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.interestSet.InterestSetEvent import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent @@ -2642,6 +2643,7 @@ object LocalCache : ILocalCache, ICacheProvider { is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified) is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is InterestSetEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt index f803b1f05..005d45881 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/interestSets/list/ListOfInterestSetsScreen.kt @@ -24,13 +24,17 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.outlined.Tag import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -39,6 +43,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -46,6 +52,8 @@ 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.DividerThickness +import com.vitorpamplona.amethyst.ui.theme.FeedPadding @Composable fun ListOfInterestSetsScreen( @@ -71,19 +79,15 @@ fun ListOfInterestSetsScreen( ).fillMaxHeight(), ) { if (sets.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(stringRes(R.string.interest_sets_empty)) - } + EmptyInterestSets() } else { LazyColumn( modifier = Modifier.fillMaxSize(), + contentPadding = FeedPadding, ) { - items(sets, key = { it.identifier }) { set -> + itemsIndexed(sets, key = { _, it -> it.identifier }) { _, set -> InterestSetItem( + modifier = Modifier.fillMaxSize().animateItem(), interestSet = set, onClick = { nav.nav(Route.InterestSetView(set.identifier)) }, onRename = { nav.nav(Route.InterestSetMetadataEdit(set.identifier)) }, @@ -105,6 +109,7 @@ fun ListOfInterestSetsScreen( } }, ) + HorizontalDivider(thickness = DividerThickness) } } } @@ -112,6 +117,28 @@ fun ListOfInterestSetsScreen( } } +@Composable +private fun EmptyInterestSets() { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Outlined.Tag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(64.dp), + ) + Text( + text = stringRes(R.string.interest_sets_empty), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) + } +} + @Composable fun InterestSetFab(onAdd: () -> Unit) { ExtendedFloatingActionButton(