Merge pull request #2456 from vitorpamplona/claude/nip51-interest-sets-p0f6r

Add Interest Sets feature for organizing hashtags
This commit is contained in:
Vitor Pamplona
2026-04-20 10:17:31 -04:00
committed by GitHub
19 changed files with 1266 additions and 4 deletions
@@ -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
@@ -363,6 +364,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)
@@ -443,6 +445,7 @@ class Account(
scope = scope,
favoriteAlgoFeedsOrchestrator = favoriteAlgoFeedsOrchestrator,
favoriteAlgoFeedAddresses = favoriteAlgoFeedsList.flow,
interestSetHashtags = interestSets.hashtagsByIdentifier,
).flow
// App-ready Feeds
@@ -2860,6 +2863,7 @@ class Account(
peopleLists.newNotes(newNotes)
followLists.newNotes(newNotes)
labeledBookmarkLists.newNotes(newNotes)
interestSets.newNotes(newNotes)
}
}
}
@@ -2871,6 +2875,7 @@ class Account(
peopleLists.deletedNotes(deletedNotes)
followLists.deletedNotes(deletedNotes)
labeledBookmarkLists.deletedNotes(deletedNotes)
interestSets.deletedNotes(deletedNotes)
}
}
}
@@ -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
@@ -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)
@@ -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<String> = emptySet(),
val privateHashtags: Set<String> = emptySet(),
) {
val allHashtags: Set<String> get() = publicHashtags + privateHashtags
}
@@ -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<List<InterestSetEvent>> =
interestSetNotes
.transformLatest { emitAll(it.updateFlow<InterestSetEvent>()) }
.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<InterestSetEvent>.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<InterestSet>.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<Note>): Boolean =
notes.anyNotNullEvent { event ->
if (event.pubKey == signer.pubKey) {
event is InterestSetEvent || (event is DeletionEvent && event.hasAnyDeletedInterestSets())
} else {
false
}
}
fun newNotes(newNotes: Set<Note>) {
if (hasItemInNoteList(newNotes)) {
forceRefresh()
}
}
fun deletedNotes(deletedNotes: Set<Note>) {
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<Map<String, Set<String>>> =
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<String> = hashtagsByIdentifier.value[identifier].orEmpty()
}
@@ -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<Set<Address>>,
val interestSetHashtags: StateFlow<Map<String, Set<String>>> = 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())
}
@@ -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<String>,
val outboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun buildFilter(relays: Set<NormalizedRelayUrl>) = HashtagTopNavFilter(hashtags, relays)
override fun flow(): Flow<IFeedTopNavFilter> =
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<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -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<Route.BookmarkGroups> { ListOfBookmarkGroupsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.BookmarkGroupView> { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) }
composableFromBottomArgs<Route.BookmarkGroupMetadataEdit> { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromEnd<Route.InterestSets> { ListOfInterestSetsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.InterestSetView> { InterestSetScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.InterestSetMetadataEdit> { InterestSetMetadataScreen(it.dTag, accountViewModel, nav) }
composableFromBottomArgs<Route.PostBookmarkManagement> { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) }
composableFromBottomArgs<Route.ArticleBookmarkManagement> { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -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,
@@ -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(
@@ -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<FeedDefinition>): Map<Fe
FeedGroup.DVMS
}
is InterestSetName -> {
FeedGroup.INTEREST_SETS
}
is ResourceName -> {
when (entry.item.code) {
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
@@ -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
@@ -154,6 +156,7 @@ class TopNavFilterState(
communityList: List<AddressableNote>,
relayList: Set<NormalizedRelayUrl>,
favoriteAlgoFeedsList: List<AddressableNote>,
interestSetList: List<InterestSet>,
): List<FeedDefinition> {
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,
@@ -282,6 +282,10 @@ fun HomeScreenFloatingButton(
NewHashtagPostButton(filter.tag, accountViewModel, nav)
}
is TopFilter.InterestSet -> {
NewNoteButton(nav)
}
else -> {
NewNoteButton(nav)
}
@@ -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),
)
}
},
)
}
@@ -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
}
}
}
}
}
@@ -0,0 +1,158 @@
/*
* 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.fillMaxWidth
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.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
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.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
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(
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()) {
EmptyInterestSets()
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = FeedPadding,
) {
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)) },
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,
)
}
},
)
HorizontalDivider(thickness = DividerThickness)
}
}
}
}
}
}
@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(
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,
)
}
@@ -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),
)
}
@@ -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<InterestSet?>(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()
}
}
+16
View File
@@ -485,6 +485,21 @@
<string name="bookmark_list_links_btn_label">View Links</string>
<string name="bookmark_list_hashtags_btn_label">View Hashtags</string>
<string name="bookmark_list_feed_empty_msg">You do not have any bookmark lists yet. Tap the new button below to make one.</string>
<string name="interest_sets_title">Interest Sets</string>
<string name="interest_sets_empty">You do not have any interest sets yet. Tap the new button below to make one.</string>
<string name="interest_set_create_btn_label">New Interest Set</string>
<string name="interest_set_creation_screen_title">New Interest Set</string>
<string name="interest_set_name_label">Set Name</string>
<string name="interest_set_name_placeholder">My interests</string>
<string name="interest_set_hashtag_add_placeholder">Add hashtag</string>
<string name="interest_set_hashtag_private_toggle">Private</string>
<string name="interest_set_hashtag_count">%1$d hashtag(s)</string>
<string name="interest_set_actions_dialog_title">Interest Set Actions</string>
<string name="interest_set_rename">Rename</string>
<string name="interest_set_clone">Clone</string>
<string name="interest_set_add_hashtag">Add hashtag</string>
<string name="interest_set_toggle_visibility">Toggle public/private</string>
<string name="private_posts_label">Private Posts</string>
<string name="private_posts_count">Private Posts(%1$s)</string>
<string name="public_posts_label">Public Posts</string>
@@ -1764,6 +1779,7 @@
<string name="select_list_to_filter">Select an option to filter the feed</string>
<string name="feed_group_feeds">Feeds</string>
<string name="feed_group_hashtags">Hashtags</string>
<string name="feed_group_interest_sets">Interest Sets</string>
<string name="feed_group_locations">Locations</string>
<string name="feed_group_communities">Communities</string>
<string name="feed_group_lists">Lists</string>