Merge pull request #2456 from vitorpamplona/claude/nip51-interest-sets-p0f6r
Add Interest Sets feature for organizing hashtags
This commit is contained in:
@@ -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)
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
+269
@@ -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()
|
||||
}
|
||||
+7
@@ -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())
|
||||
}
|
||||
|
||||
+53
@@ -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) }
|
||||
|
||||
|
||||
+9
@@ -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(
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
+4
@@ -282,6 +282,10 @@ fun HomeScreenFloatingButton(
|
||||
NewHashtagPostButton(filter.tag, accountViewModel, nav)
|
||||
}
|
||||
|
||||
is TopFilter.InterestSet -> {
|
||||
NewNoteButton(nav)
|
||||
}
|
||||
|
||||
else -> {
|
||||
NewNoteButton(nav)
|
||||
}
|
||||
|
||||
+247
@@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+146
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -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,
|
||||
)
|
||||
}
|
||||
+168
@@ -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),
|
||||
)
|
||||
}
|
||||
+85
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user