mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
Merge pull request #1586 from KotlinGeekDev/labeled-bookmarks
Bookmark Lists.
This commit is contained in:
@@ -64,6 +64,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.labeledBookmarkLists.LabeledBookmarkListsState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
|
||||
@@ -301,6 +302,7 @@ class Account(
|
||||
|
||||
val hiddenUsers = HiddenUsersState(muteList.flow, blockPeopleList.flow, scope, settings)
|
||||
|
||||
val labeledBookmarkLists = LabeledBookmarkListsState(signer, cache, scope)
|
||||
val bookmarkState = BookmarkListState(signer, cache, scope)
|
||||
val emoji = EmojiPackState(signer, cache, scope)
|
||||
|
||||
@@ -1807,6 +1809,7 @@ class Account(
|
||||
newNotesPreProcessor.runNew(newNotes)
|
||||
peopleLists.newNotes(newNotes)
|
||||
followLists.newNotes(newNotes)
|
||||
labeledBookmarkLists.newNotes(newNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1817,6 +1820,7 @@ class Account(
|
||||
newNotesPreProcessor.runDeleted(deletedNotes)
|
||||
peopleLists.deletedNotes(deletedNotes)
|
||||
followLists.deletedNotes(deletedNotes)
|
||||
labeledBookmarkLists.deletedNotes(deletedNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
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.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
@@ -863,6 +864,12 @@ object LocalCache : ILocalCache {
|
||||
return false
|
||||
}
|
||||
|
||||
fun consume(
|
||||
event: LabeledBookmarkListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
wasVerified: Boolean,
|
||||
) = consumeBaseReplaceable(event, relay, wasVerified)
|
||||
|
||||
fun consume(
|
||||
event: MuteListEvent,
|
||||
relay: NormalizedRelayUrl?,
|
||||
@@ -2818,6 +2825,7 @@ object LocalCache : ILocalCache {
|
||||
is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStorySceneEvent -> consume(event, relay, wasVerified)
|
||||
is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified)
|
||||
is LabeledBookmarkListEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesEvent -> consume(event, relay, wasVerified)
|
||||
is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified)
|
||||
is LnZapEvent -> consume(event, relay, wasVerified)
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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.labeledBookmarkLists
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
|
||||
@Stable
|
||||
data class LabeledBookmarkList(
|
||||
val identifier: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val image: String?,
|
||||
val privateBookmarks: Set<BookmarkIdTag> = emptySet(),
|
||||
val publicBookmarks: Set<BookmarkIdTag> = emptySet(),
|
||||
) {
|
||||
val privatePostBookmarks = privateBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
|
||||
val publicPostBookmarks = publicBookmarks.filter { it is EventBookmark }.map { bookmarkIdTag -> bookmarkIdTag as EventBookmark }
|
||||
|
||||
val privateArticleBookmarks = privateBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark }
|
||||
val publicArticleBookmarks = publicBookmarks.filter { it is AddressBookmark }.map { bookmarkIdTag -> bookmarkIdTag as AddressBookmark }
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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.labeledBookmarkLists
|
||||
|
||||
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.anyNotNullEvent
|
||||
import com.vitorpamplona.amethyst.model.eventIdSet
|
||||
import com.vitorpamplona.amethyst.model.events
|
||||
import com.vitorpamplona.amethyst.model.filter
|
||||
import com.vitorpamplona.amethyst.model.updateFlow
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.update
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.description
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.image
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.name
|
||||
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 LabeledBookmarkListsState(
|
||||
val signer: NostrSigner,
|
||||
val cache: LocalCache,
|
||||
val scope: CoroutineScope,
|
||||
) {
|
||||
val user = cache.getOrCreateUser(signer.pubKey)
|
||||
|
||||
fun existingLabeledBookmarkNotes() = cache.addressables.filter(LabeledBookmarkListEvent.KIND, user.pubkeyHex)
|
||||
|
||||
val labeledBookmarkListVersions = MutableStateFlow(0)
|
||||
|
||||
val labeledBookmarkListNotes =
|
||||
labeledBookmarkListVersions
|
||||
.map { existingLabeledBookmarkNotes() }
|
||||
.onStart { emit(existingLabeledBookmarkNotes()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
val labeledBookmarkListEventIds =
|
||||
labeledBookmarkListNotes
|
||||
.map { it.eventIdSet() }
|
||||
.onStart { emit(labeledBookmarkListNotes.value.eventIdSet()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptySet())
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val latestBookmarkLists: StateFlow<List<LabeledBookmarkListEvent>> =
|
||||
labeledBookmarkListNotes
|
||||
.transformLatest { emitAll(it.updateFlow<LabeledBookmarkListEvent>()) }
|
||||
.onStart { emit(labeledBookmarkListNotes.value.events()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
suspend fun LabeledBookmarkListEvent.toLabeledBookmarkList() =
|
||||
LabeledBookmarkList(
|
||||
identifier = dTag(),
|
||||
title = nameOrTitle() ?: dTag(),
|
||||
description = description(),
|
||||
image = image(),
|
||||
privateBookmarks = privateBookmarks(signer)?.toSet() ?: emptySet(),
|
||||
publicBookmarks = publicBookmarks().toSet(),
|
||||
)
|
||||
|
||||
suspend fun List<LabeledBookmarkListEvent>.toLabeledBookmarkListsFeed() = map { it.toLabeledBookmarkList() }.sortedBy { it.title }
|
||||
|
||||
val listFeedFlow =
|
||||
latestBookmarkLists
|
||||
.map { it.toLabeledBookmarkListsFeed() }
|
||||
.onStart { emit(latestBookmarkLists.value.toLabeledBookmarkListsFeed()) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun List<LabeledBookmarkList>.getList(bookmarkListId: String) =
|
||||
this.firstOrNull {
|
||||
it.identifier == bookmarkListId
|
||||
}
|
||||
|
||||
fun getBookmarkList(dTag: String) = listFeedFlow.value.getList(bookmarkListId = dTag)
|
||||
|
||||
fun DeletionEvent.hasAnyDeletedBookmarkLists() = deleteAddressesWithKind(LabeledBookmarkListEvent.KIND) || deletesAnyEventIn(labeledBookmarkListEventIds.value)
|
||||
|
||||
fun hasItemInNoteList(notes: Set<Note>): Boolean =
|
||||
notes.anyNotNullEvent { event ->
|
||||
if (event.pubKey == signer.pubKey) {
|
||||
event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun newNotes(newNotes: Set<Note>) {
|
||||
if (hasItemInNoteList(newNotes)) {
|
||||
forceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun deletedNotes(deletedNotes: Set<Note>) {
|
||||
if (hasItemInNoteList(deletedNotes)) {
|
||||
forceRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun forceRefresh() {
|
||||
labeledBookmarkListVersions.update { it + 1 }
|
||||
}
|
||||
|
||||
fun getLabeledBookmarkListNote(bookmarkIdentifier: String): AddressableNote? = existingLabeledBookmarkNotes().find { it.dTag() == bookmarkIdentifier }
|
||||
|
||||
fun getLabeledBookmarkListEvent(bookmarkIdentifier: String): LabeledBookmarkListEvent = getLabeledBookmarkListNote(bookmarkIdentifier)?.event as LabeledBookmarkListEvent
|
||||
|
||||
fun getLabeledBookmarkListFlow(bookmarkIdentifier: String) =
|
||||
listFeedFlow
|
||||
.map { it.getList(bookmarkIdentifier) }
|
||||
.onStart {
|
||||
emit(
|
||||
listFeedFlow.value.getList(bookmarkIdentifier),
|
||||
)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun addLabeledBookmarkList(
|
||||
listName: String,
|
||||
listDescription: String? = null,
|
||||
listImage: String? = null,
|
||||
firstBookmark: BookmarkIdTag? = null,
|
||||
isBookmarkPrivate: Boolean = false,
|
||||
account: Account,
|
||||
) {
|
||||
val newList =
|
||||
LabeledBookmarkListEvent.create(
|
||||
name = listName,
|
||||
description = listDescription,
|
||||
image = listImage,
|
||||
publicBookmarks = if (!isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
|
||||
privateBookmarks = if (isBookmarkPrivate && firstBookmark != null) listOf(firstBookmark) else emptyList(),
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(newList)
|
||||
}
|
||||
|
||||
suspend fun updateMetadata(
|
||||
listName: String?,
|
||||
listDescription: String?,
|
||||
listImage: String?,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
|
||||
val template =
|
||||
listEvent.update {
|
||||
if (listName != null) name(listName)
|
||||
if (listDescription != null) description(listDescription)
|
||||
if (listImage != null) image(listImage)
|
||||
}
|
||||
|
||||
val newList = signer.sign(template)
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(newList)
|
||||
}
|
||||
|
||||
suspend fun renameBookmarkList(
|
||||
newName: String,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
val renamedList =
|
||||
LabeledBookmarkListEvent.modifyName(
|
||||
earlierVersion = listEvent,
|
||||
newName = newName,
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(renamedList)
|
||||
}
|
||||
|
||||
suspend fun modifyListDescription(
|
||||
newDescription: String?,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkList.identifier)
|
||||
val modifiedList =
|
||||
LabeledBookmarkListEvent.modifyDescription(
|
||||
earlierVersion = listEvent,
|
||||
newDescription = newDescription,
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(modifiedList)
|
||||
}
|
||||
|
||||
suspend fun cloneBookmarkList(
|
||||
currentBookmarkList: LabeledBookmarkList,
|
||||
customCloneName: String?,
|
||||
customCloneDescription: String?,
|
||||
account: Account,
|
||||
) {
|
||||
val clonedList =
|
||||
LabeledBookmarkListEvent.create(
|
||||
name = customCloneName ?: currentBookmarkList.title,
|
||||
description = customCloneDescription ?: currentBookmarkList.description,
|
||||
publicBookmarks = currentBookmarkList.publicBookmarks.toList(),
|
||||
privateBookmarks = currentBookmarkList.privateBookmarks.toList(),
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(clonedList)
|
||||
}
|
||||
|
||||
suspend fun deleteBookmarkList(
|
||||
bookmarkListIdentifier: String,
|
||||
account: Account,
|
||||
) {
|
||||
val listEvent = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val deletionEventTemplate = DeletionEvent.build(listOf(listEvent))
|
||||
val deletionEvent = account.signer.sign(deletionEventTemplate)
|
||||
account.sendMyPublicAndPrivateOutbox(deletionEvent)
|
||||
}
|
||||
|
||||
suspend fun addBookmarkToList(
|
||||
bookmark: BookmarkIdTag,
|
||||
bookmarkListIdentifier: String,
|
||||
isBookmarkPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val updatedList =
|
||||
LabeledBookmarkListEvent.addBookmark(
|
||||
earlierVersion = currentBookmarkList,
|
||||
bookmarkIdTag = bookmark,
|
||||
isPrivate = isBookmarkPrivate,
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(updatedList)
|
||||
}
|
||||
|
||||
suspend fun removeBookmarkFromList(
|
||||
bookmark: BookmarkIdTag,
|
||||
bookmarkListIdentifier: String,
|
||||
isBookmarkPrivate: Boolean,
|
||||
account: Account,
|
||||
) {
|
||||
val currentBookmarkList = getLabeledBookmarkListEvent(bookmarkListIdentifier)
|
||||
val updatedList =
|
||||
LabeledBookmarkListEvent.removeBookmark(
|
||||
earlierVersion = currentBookmarkList,
|
||||
bookmarkIdTag = bookmark,
|
||||
isPrivate = isBookmarkPrivate,
|
||||
signer = account.signer,
|
||||
)
|
||||
account.sendMyPublicAndPrivateOutbox(updatedList)
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,11 @@ import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.display.BookmarkGroupScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.ListOfBookmarkGroupsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.metadata.BookmarkGroupMetadataScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.ArticleBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.membershipManagement.PostBookmarkListManagementScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomByAuthorScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.ChatroomScreen
|
||||
@@ -139,6 +144,12 @@ fun AppNavigation(
|
||||
composableFromBottomArgs<Route.PeopleListMetadataEdit> { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.FollowPackMetadataEdit> { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) }
|
||||
|
||||
composableFromEnd<Route.BookmarkGroups> { ListOfBookmarkGroupsScreen(accountViewModel, nav) }
|
||||
composableFromEndArgs<Route.BookmarkGroupView> { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) }
|
||||
composableFromBottomArgs<Route.BookmarkGroupMetadataEdit> { BookmarkGroupMetadataScreen(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) }
|
||||
|
||||
composableFromBottomArgs<Route.QRDisplay> { ShowQRScreen(it.pubkey, accountViewModel, nav) }
|
||||
|
||||
composableFromBottomArgs<Route.ManualZapSplitPayment> { PayViaIntentScreen(it.paymentId, accountViewModel, nav) }
|
||||
|
||||
+9
@@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material.icons.outlined.CloudUpload
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Drafts
|
||||
import androidx.compose.material.icons.outlined.GroupAdd
|
||||
import androidx.compose.material.icons.outlined.Key
|
||||
@@ -451,6 +452,14 @@ fun ListContent(
|
||||
route = Route.Bookmarks,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.bookmark_lists,
|
||||
icon = Icons.Outlined.CollectionsBookmark,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.BookmarkGroups,
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
title = R.string.drafts,
|
||||
icon = Icons.Outlined.Drafts,
|
||||
|
||||
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.ui.navigation.routes
|
||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.toRoute
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route.Community
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
@@ -47,6 +49,33 @@ sealed class Route {
|
||||
|
||||
@Serializable object Bookmarks : Route()
|
||||
|
||||
@Serializable object BookmarkGroups : Route()
|
||||
|
||||
@Serializable data class BookmarkGroupView(
|
||||
val dTag: String,
|
||||
val bookmarkType: BookmarkType,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class BookmarkGroupMetadataEdit(
|
||||
val dTag: String? = null,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class PostBookmarkManagement(
|
||||
val postId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ArticleBookmarkManagement(
|
||||
val kind: Int,
|
||||
val pubKeyHex: HexKey,
|
||||
val dTag: String,
|
||||
) : Route() {
|
||||
constructor(address: Address) : this(
|
||||
kind = address.kind,
|
||||
pubKeyHex = address.pubKeyHex,
|
||||
dTag = address.dTag,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable object Drafts : Route()
|
||||
|
||||
@Serializable object Settings : Route()
|
||||
|
||||
@@ -241,6 +241,7 @@ fun NoteCompose(
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
WatchNoteEvent(
|
||||
baseNote = baseNote,
|
||||
@@ -269,6 +270,7 @@ fun NoteCompose(
|
||||
parentBackgroundColor = parentBackgroundColor,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -288,6 +290,7 @@ fun AcceptableNote(
|
||||
parentBackgroundColor: MutableState<Color>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)?,
|
||||
) {
|
||||
if (isQuotedNote || isBoostedNote) {
|
||||
val noteEvent = baseNote.event
|
||||
@@ -333,6 +336,7 @@ fun AcceptableNote(
|
||||
accountViewModel = accountViewModel,
|
||||
showPopup = showPopup,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -379,6 +383,7 @@ fun AcceptableNote(
|
||||
accountViewModel = accountViewModel,
|
||||
showPopup = showPopup,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -438,6 +443,7 @@ private fun CheckNewAndRenderNote(
|
||||
accountViewModel: AccountViewModel,
|
||||
showPopup: () -> Unit,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val backgroundColor =
|
||||
calculateBackgroundColor(
|
||||
@@ -466,6 +472,7 @@ private fun CheckNewAndRenderNote(
|
||||
quotesLeft = quotesLeft,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -513,6 +520,7 @@ fun InnerNoteWithReactions(
|
||||
quotesLeft: Int,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
val notBoostedNorQuote = !isBoostedNote && !isQuotedNote
|
||||
val editState = observeEdits(baseNote = baseNote, accountViewModel = accountViewModel)
|
||||
@@ -556,6 +564,7 @@ fun InnerNoteWithReactions(
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
|
||||
RenderApprovalIfNeeded(baseNote, accountViewModel, nav)
|
||||
@@ -638,6 +647,7 @@ fun NoteBody(
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
FirstUserInfoRow(
|
||||
baseNote = baseNote,
|
||||
@@ -645,6 +655,7 @@ fun NoteBody(
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = moreOptions,
|
||||
)
|
||||
|
||||
if (showSecondRow) {
|
||||
@@ -1137,6 +1148,7 @@ fun FirstUserInfoRow(
|
||||
editState: State<GenericLoadable<EditState>>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
moreOptions: (@Composable () -> Unit)? = null,
|
||||
) {
|
||||
Row(verticalAlignment = CenterVertically, modifier = UserNameRowHeight) {
|
||||
val isRepost = baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent
|
||||
@@ -1187,7 +1199,11 @@ fun FirstUserInfoRow(
|
||||
|
||||
TimeAgo(baseNote)
|
||||
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
if (moreOptions == null) {
|
||||
MoreOptionsButton(baseNote, editState, accountViewModel, nav)
|
||||
} else {
|
||||
moreOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserBookmarks
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollows
|
||||
@@ -58,6 +59,7 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -297,6 +299,21 @@ fun NoteDropDownMenu(
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
note.let {
|
||||
val noteBookmarkType = if (note.event is LongTextNoteEvent) stringRes(R.string.article) else stringRes(R.string.post)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.manage_bookmark_label, noteBookmarkType)) },
|
||||
onClick = {
|
||||
if (note.event is LongTextNoteEvent) {
|
||||
val noteAddress = (note as AddressableNote).address
|
||||
nav.nav(Route.ArticleBookmarkManagement(noteAddress))
|
||||
} else {
|
||||
nav.nav(Route.PostBookmarkManagement(note.idHex))
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isPrivateBookmarkNote) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.remove_from_private_bookmarks)) },
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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.bookmarkgroups
|
||||
|
||||
enum class BookmarkType {
|
||||
PostBookmark,
|
||||
|
||||
ArticleBookmark,
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.display
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
|
||||
@Composable
|
||||
fun RenderArticleList(
|
||||
bookmarkGroupViewModel: BookmarkGroupViewModel,
|
||||
pagerState: PagerState,
|
||||
accountViewModel: AccountViewModel,
|
||||
deleteArticleBookmark: (articleAddress: Address, isPrivate: Boolean) -> Unit,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val privateArticles by bookmarkGroupViewModel.privateArticles().collectAsStateWithLifecycle()
|
||||
val publicArticles by bookmarkGroupViewModel.publicArticles().collectAsStateWithLifecycle()
|
||||
|
||||
HorizontalPager(pagerState, modifier) { page ->
|
||||
when (page) {
|
||||
0 ->
|
||||
ArticleList(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
articles = publicArticles,
|
||||
onDeleteArticleBookmark = { articleAddress ->
|
||||
deleteArticleBookmark(articleAddress, false)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
1 ->
|
||||
ArticleList(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
articles = privateArticles,
|
||||
onDeleteArticleBookmark = { articleAddress ->
|
||||
deleteArticleBookmark(articleAddress, true)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ArticleList(
|
||||
modifier: Modifier = Modifier,
|
||||
articles: List<AddressableNote>,
|
||||
onDeleteArticleBookmark: (Address) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(articles, key = { _, item -> item.toNAddr() }) { _, item ->
|
||||
NoteCompose(
|
||||
baseNote = item,
|
||||
modifier = Modifier.animateContentSize(),
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = {
|
||||
BookmarkGroupItemOptions(
|
||||
baseNote = item,
|
||||
onDeleteBookmarkItem = {
|
||||
onDeleteArticleBookmark(item.address)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.display
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.actions.EditPostView
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
|
||||
import com.vitorpamplona.amethyst.ui.note.elements.WatchBookmarksFollowsAndAccount
|
||||
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
|
||||
import com.vitorpamplona.amethyst.ui.note.types.EditState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size24Modifier
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupItemOptions(
|
||||
baseNote: Note,
|
||||
onDeleteBookmarkItem: () -> Unit,
|
||||
editState: State<GenericLoadable<EditState>>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val popupExpanded = remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
modifier = Size24Modifier,
|
||||
onClick = { popupExpanded.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
|
||||
if (popupExpanded.value) {
|
||||
BookmarkGroupItemOptionsMenu(
|
||||
note = baseNote,
|
||||
onDismiss = { popupExpanded.value = false },
|
||||
onDeleteBookmarkItem = onDeleteBookmarkItem,
|
||||
editState = editState,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupItemOptionsMenu(
|
||||
note: Note,
|
||||
onDismiss: () -> Unit,
|
||||
onDeleteBookmarkItem: () -> Unit,
|
||||
editState: State<GenericLoadable<EditState>>? = null,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
var reportDialogShowing by remember { mutableStateOf(false) }
|
||||
|
||||
var state by remember {
|
||||
mutableStateOf(
|
||||
DropDownParams(
|
||||
isFollowingAuthor = false,
|
||||
isPrivateBookmarkNote = false,
|
||||
isPublicBookmarkNote = false,
|
||||
isLoggedUser = false,
|
||||
isSensitive = false,
|
||||
showSensitiveContent = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val wantsToEditPost =
|
||||
remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (wantsToEditPost.value) {
|
||||
// avoids changing while drafting a note and a new event shows up.
|
||||
val versionLookingAt =
|
||||
remember {
|
||||
(editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value
|
||||
}
|
||||
|
||||
EditPostView(
|
||||
onClose = {
|
||||
onDismiss()
|
||||
wantsToEditPost.value = false
|
||||
},
|
||||
edit = note,
|
||||
versionLookingAt = versionLookingAt,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = true,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val appContext = LocalContext.current.applicationContext
|
||||
val actContext = LocalContext.current
|
||||
|
||||
WatchBookmarksFollowsAndAccount(note, accountViewModel) { newState ->
|
||||
if (state != newState) {
|
||||
state = newState
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.bookmark_remove_action_label)) },
|
||||
onClick = {
|
||||
onDeleteBookmarkItem()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
// TODO: Work on moving feature below
|
||||
// DropdownMenuItem(
|
||||
// text = { Text("Move to Public") },
|
||||
// onClick =
|
||||
// )
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
|
||||
if (!state.isFollowingAuthor) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.follow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
accountViewModel.follow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.unfollow)) },
|
||||
onClick = {
|
||||
val author = note.author ?: return@DropdownMenuItem
|
||||
accountViewModel.unfollow(author)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
// DropdownMenuItem(
|
||||
// text = { Text(text = stringRes(R.string.follow_set_add_author_from_note_action)) },
|
||||
// onClick = {
|
||||
// val authorHexKey = note.author?.pubkeyHex ?: return@DropdownMenuItem
|
||||
// nav.nav(Route.PeopleListManagement(authorHexKey))
|
||||
// onDismiss()
|
||||
// },
|
||||
// )
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_text)) },
|
||||
onClick = {
|
||||
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
|
||||
accountViewModel.decrypt(lastNoteVersion) {
|
||||
clipboardManager.setText(AnnotatedString(it))
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_user_pubkey)) },
|
||||
onClick = {
|
||||
note.author?.let {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString("nostr:${it.pubkeyNpub()}"))
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.copy_note_id)) },
|
||||
onClick = {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
clipboardManager.setText(AnnotatedString(note.toNostrUri()))
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.quick_action_share)) },
|
||||
onClick = {
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
externalLinkForNote(note),
|
||||
)
|
||||
putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
stringRes(actContext, R.string.quick_action_share_browser_link),
|
||||
)
|
||||
}
|
||||
|
||||
val shareIntent =
|
||||
Intent.createChooser(sendIntent, stringRes(actContext, R.string.quick_action_share))
|
||||
ContextCompat.startActivity(actContext, shareIntent, null)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser && note.isDraft()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_draft)) },
|
||||
onClick = {
|
||||
nav.nav {
|
||||
routeEditDraftTo(note, accountViewModel.account)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if (note.event is TextNoteEvent && !note.isDraft()) {
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.edit_post)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.propose_an_edit)) },
|
||||
onClick = {
|
||||
wantsToEditPost.value = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.broadcast)) },
|
||||
onClick = {
|
||||
accountViewModel.broadcast(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (accountViewModel.account.otsState.hasPendingAttestations(note)) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_pending)) },
|
||||
onClick = {
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.timestamp_it)) },
|
||||
onClick = {
|
||||
accountViewModel.timestamp(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
if (state.isLoggedUser) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.request_deletion)) },
|
||||
onClick = {
|
||||
accountViewModel.delete(note)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(R.string.block_report)) },
|
||||
onClick = { reportDialogShowing = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (reportDialogShowing) {
|
||||
ReportNoteDialog(note = note, accountViewModel = accountViewModel) {
|
||||
reportDialogShowing = false
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
+335
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.display
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.TabRowHeight
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupScreen(
|
||||
bookmarkIdentifier: String,
|
||||
bookmarkType: BookmarkType,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val bookmarkGroupViewModel: BookmarkGroupViewModel =
|
||||
viewModel(
|
||||
factory = BookmarkGroupViewModel.Initializer(accountViewModel.account, bookmarkIdentifier),
|
||||
)
|
||||
|
||||
BookmarkGroupScreenView(
|
||||
bookmarkGroupViewModel,
|
||||
bookmarkType,
|
||||
broadcastBookmarkGroup = {
|
||||
accountViewModel.launchSigner {
|
||||
val groupNote = accountViewModel.account.labeledBookmarkLists.getLabeledBookmarkListNote(bookmarkIdentifier)
|
||||
groupNote?.let {
|
||||
accountViewModel.broadcast(it)
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteBookmarkGroup = {
|
||||
accountViewModel.launchSigner {
|
||||
bookmarkGroupViewModel.deleteBookmarkGroup(bookmarkIdentifier)
|
||||
}
|
||||
nav.popBack()
|
||||
},
|
||||
accountViewModel,
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BookmarkGroupScreenView(
|
||||
bookmarkGroupViewModel: BookmarkGroupViewModel,
|
||||
bookmarkType: BookmarkType,
|
||||
broadcastBookmarkGroup: () -> Unit,
|
||||
deleteBookmarkGroup: () -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val pagerState = rememberPagerState { 2 }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
TitleAndDescription(bookmarkGroupViewModel)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(nav::popBack) {
|
||||
ArrowBackIcon()
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
BookmarkGroupActionsMenuButton(
|
||||
onBroadcastList = broadcastBookmarkGroup,
|
||||
onDeleteList = deleteBookmarkGroup,
|
||||
)
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
BookmarkGroupHeaderTabs(
|
||||
bookmarkGroupViewModel,
|
||||
bookmarkType,
|
||||
pagerState,
|
||||
)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
top = padding.calculateTopPadding(),
|
||||
bottom = padding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(padding)
|
||||
.imePadding(),
|
||||
) {
|
||||
when (bookmarkType) {
|
||||
BookmarkType.PostBookmark ->
|
||||
RenderPostList(
|
||||
bookmarkGroupViewModel,
|
||||
pagerState,
|
||||
accountViewModel,
|
||||
deletePostBookmark = { postId, isPrivate ->
|
||||
accountViewModel.launchSigner {
|
||||
bookmarkGroupViewModel.removePostBookmark(
|
||||
bookmarkGroupViewModel.bookmarkGroupIdentifier,
|
||||
postId,
|
||||
isPrivate,
|
||||
)
|
||||
}
|
||||
},
|
||||
nav,
|
||||
)
|
||||
BookmarkType.ArticleBookmark ->
|
||||
RenderArticleList(
|
||||
bookmarkGroupViewModel,
|
||||
pagerState,
|
||||
accountViewModel,
|
||||
deleteArticleBookmark = { articleAddress, isPrivate ->
|
||||
accountViewModel.launchSigner {
|
||||
bookmarkGroupViewModel.removeArticleBookmark(
|
||||
bookmarkGroupViewModel.bookmarkGroupIdentifier,
|
||||
articleAddress,
|
||||
isPrivate,
|
||||
)
|
||||
}
|
||||
},
|
||||
nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TitleAndDescription(viewModel: BookmarkGroupViewModel) {
|
||||
val selectedSetState = viewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle()
|
||||
selectedSetState.value?.let { bookmarkGroup ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = bookmarkGroup.title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
bookmarkGroup.description?.let { description ->
|
||||
Text(
|
||||
text = description,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupHeaderTabs(
|
||||
bookmarkGroupViewModel: BookmarkGroupViewModel,
|
||||
bookmarkType: BookmarkType,
|
||||
pagerState: PagerState,
|
||||
) {
|
||||
val bookmarkGroup by bookmarkGroupViewModel.selectedBookmarkGroupFlow.collectAsStateWithLifecycle()
|
||||
val privateItemTypeLabel =
|
||||
when (bookmarkType) {
|
||||
BookmarkType.PostBookmark ->
|
||||
bookmarkGroup?.let {
|
||||
stringRes(R.string.private_posts_count, it.privatePostBookmarks.size)
|
||||
} ?: stringRes(R.string.private_posts_label)
|
||||
BookmarkType.ArticleBookmark ->
|
||||
bookmarkGroup?.let {
|
||||
stringRes(R.string.private_articles_count, it.privateArticleBookmarks.size)
|
||||
} ?: stringRes(R.string.private_posts_label)
|
||||
}
|
||||
|
||||
val publicItemTypeLabel =
|
||||
when (bookmarkType) {
|
||||
BookmarkType.PostBookmark ->
|
||||
bookmarkGroup?.let {
|
||||
stringRes(R.string.public_posts_count, it.publicPostBookmarks.size)
|
||||
} ?: stringRes(R.string.public_posts_label)
|
||||
BookmarkType.ArticleBookmark ->
|
||||
bookmarkGroup?.let {
|
||||
stringRes(R.string.public_articles_count, it.publicArticleBookmarks.size)
|
||||
} ?: stringRes(R.string.public_articles_label)
|
||||
}
|
||||
|
||||
TabRow(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
modifier = TabRowHeight,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(0) } },
|
||||
text = { Text(text = publicItemTypeLabel) },
|
||||
)
|
||||
Tab(
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { scope.launch { pagerState.animateScrollToPage(1) } },
|
||||
text = { Text(text = privateItemTypeLabel) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupActionsMenuButton(
|
||||
onBroadcastList: () -> Unit,
|
||||
onDeleteList: () -> Unit,
|
||||
) {
|
||||
val isActionListOpen = remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
modifier =
|
||||
StdPadding
|
||||
.size(30.dp)
|
||||
.border(
|
||||
width = Dp.Hairline,
|
||||
color = ButtonDefaults.filledTonalButtonColors().containerColor,
|
||||
shape = ButtonBorder,
|
||||
).background(
|
||||
color = ButtonDefaults.filledTonalButtonColors().containerColor,
|
||||
shape = ButtonBorder,
|
||||
),
|
||||
onClick = { isActionListOpen.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
BookmarkGroupActionsMenu(
|
||||
onCloseMenu = { isActionListOpen.value = false },
|
||||
isOpen = isActionListOpen.value,
|
||||
onBroadcastList = onBroadcastList,
|
||||
onDeleteList = onDeleteList,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupActionsMenu(
|
||||
onCloseMenu: () -> Unit,
|
||||
isOpen: Boolean,
|
||||
onBroadcastList: () -> Unit,
|
||||
onDeleteList: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(
|
||||
expanded = isOpen,
|
||||
onDismissRequest = onCloseMenu,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringRes(R.string.bookmark_list_broadcast_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onBroadcastList()
|
||||
onCloseMenu()
|
||||
},
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(stringRes(R.string.bookmark_list_delete_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onDeleteList()
|
||||
onCloseMenu()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.display
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlin.collections.emptyList
|
||||
|
||||
@Stable
|
||||
class BookmarkGroupViewModel(
|
||||
val account: Account,
|
||||
val bookmarkGroupIdentifier: String,
|
||||
) : ViewModel() {
|
||||
val selectedBookmarkGroupFlow =
|
||||
account.labeledBookmarkLists
|
||||
.getLabeledBookmarkListFlow(bookmarkGroupIdentifier)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(2500), null)
|
||||
|
||||
fun publicPosts() =
|
||||
selectedBookmarkGroupFlow
|
||||
.filterNotNull()
|
||||
.map { group -> group.publicPostBookmarks.map { account.cache.getOrCreateNote(it.eventId) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun privatePosts() =
|
||||
selectedBookmarkGroupFlow
|
||||
.filterNotNull()
|
||||
.map { group -> group.privatePostBookmarks.map { account.cache.getOrCreateNote(it.eventId) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun publicArticles() =
|
||||
selectedBookmarkGroupFlow
|
||||
.filterNotNull()
|
||||
.map { group -> group.publicArticleBookmarks.map { account.cache.getOrCreateAddressableNote(it.address) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
fun privateArticles() =
|
||||
selectedBookmarkGroupFlow
|
||||
.filterNotNull()
|
||||
.map { group -> group.privateArticleBookmarks.map { account.cache.getOrCreateAddressableNote(it.address) } }
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
|
||||
|
||||
// TODO: Add implementations for Hashtag and Link bookmarks
|
||||
|
||||
suspend fun deleteBookmarkGroup(groupIdentifier: String) {
|
||||
account.labeledBookmarkLists.deleteBookmarkList(groupIdentifier, account)
|
||||
}
|
||||
|
||||
suspend fun addBookmarkToGroup(
|
||||
groupIdentifier: String = bookmarkGroupIdentifier,
|
||||
bookmark: BookmarkIdTag,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
account.labeledBookmarkLists.addBookmarkToList(
|
||||
bookmark,
|
||||
groupIdentifier,
|
||||
isPrivate,
|
||||
account,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removePostBookmark(
|
||||
groupIdentifier: String = bookmarkGroupIdentifier,
|
||||
bookmarkPostId: String,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
val eventBookmark = EventBookmark(bookmarkPostId)
|
||||
removeBookmarkFromGroup(
|
||||
groupIdentifier,
|
||||
eventBookmark,
|
||||
isPrivate,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeArticleBookmark(
|
||||
groupIdentifier: String = bookmarkGroupIdentifier,
|
||||
bookmarkArticleAddress: Address,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
val eventBookmark = AddressBookmark(bookmarkArticleAddress)
|
||||
removeBookmarkFromGroup(
|
||||
groupIdentifier,
|
||||
eventBookmark,
|
||||
isPrivate,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeBookmarkFromGroup(
|
||||
groupIdentifier: String = bookmarkGroupIdentifier,
|
||||
bookmark: BookmarkIdTag,
|
||||
isPrivate: Boolean,
|
||||
) {
|
||||
account.labeledBookmarkLists.removeBookmarkFromList(
|
||||
bookmark,
|
||||
groupIdentifier,
|
||||
isPrivate,
|
||||
account,
|
||||
)
|
||||
}
|
||||
|
||||
class Initializer(
|
||||
val account: Account,
|
||||
val bookmarkGroupIdentifier: String,
|
||||
) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = BookmarkGroupViewModel(account, bookmarkGroupIdentifier) as T
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.display
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
|
||||
@Composable
|
||||
fun RenderPostList(
|
||||
bookmarkGroupViewModel: BookmarkGroupViewModel,
|
||||
pagerState: PagerState,
|
||||
accountViewModel: AccountViewModel,
|
||||
deletePostBookmark: (postId: String, isPrivate: Boolean) -> Unit,
|
||||
nav: INav,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val privatePosts by bookmarkGroupViewModel.privatePosts().collectAsStateWithLifecycle()
|
||||
val publicPosts by bookmarkGroupViewModel.publicPosts().collectAsStateWithLifecycle()
|
||||
|
||||
HorizontalPager(pagerState, modifier) { page ->
|
||||
when (page) {
|
||||
0 ->
|
||||
PostList(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
posts = publicPosts,
|
||||
onDeletePostBookmark = { postId ->
|
||||
deletePostBookmark(postId, false)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
1 ->
|
||||
PostList(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
posts = privatePosts,
|
||||
onDeletePostBookmark = { postId ->
|
||||
deletePostBookmark(postId, true)
|
||||
},
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostList(
|
||||
modifier: Modifier = Modifier,
|
||||
posts: List<Note>,
|
||||
onDeletePostBookmark: (postId: String) -> Unit,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = FeedPadding,
|
||||
state = listState,
|
||||
) {
|
||||
itemsIndexed(posts, key = { _, item -> item.idHex }) { _, item ->
|
||||
NoteCompose(
|
||||
baseNote = item,
|
||||
modifier = Modifier.animateItem().animateContentSize(),
|
||||
quotesLeft = 3,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
moreOptions = {
|
||||
BookmarkGroupItemOptions(
|
||||
baseNote = item,
|
||||
onDeleteBookmarkItem = { onDeletePostBookmark(item.idHex) },
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.list
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Article
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableBox
|
||||
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Font10SP
|
||||
import com.vitorpamplona.amethyst.ui.theme.NoSoTinyBorders
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size5dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupItem(
|
||||
modifier: Modifier = Modifier,
|
||||
bookmarkList: LabeledBookmarkList,
|
||||
onClick: (bookmarkItemType: BookmarkType) -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDescriptionChange: () -> Unit,
|
||||
onClone: (customName: String?, customDescription: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = modifier.clickable(onClick = { isExpanded = !isExpanded }),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(bookmarkList.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
|
||||
Column(
|
||||
modifier = NoSoTinyBorders,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
BookmarkGroupOptionsButton(
|
||||
bookmarkGroupName = bookmarkList.title,
|
||||
bookmarkGroupDescription = bookmarkList.description,
|
||||
onGroupRename = onRename,
|
||||
onGroupDescriptionChange = onDescriptionChange,
|
||||
onGroupCloneCreate = onClone,
|
||||
onGroupDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
supportingContent = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
bookmarkList.description ?: "",
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
},
|
||||
leadingContent = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CollectionsBookmark,
|
||||
contentDescription = stringRes(R.string.bookmark_list_icon_label),
|
||||
modifier = Size40Modifier,
|
||||
)
|
||||
// TODO: Fix the component below, with per-category stats
|
||||
// Spacer(StdVertSpacer)
|
||||
// BookmarkMembershipStatusAndNumberDisplay(
|
||||
// modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
// privateBookmarksSize = bookmarkList.privateBookmarks.size,
|
||||
// publicBookmarksSize = bookmarkList.publicBookmarks.size,
|
||||
// )
|
||||
}
|
||||
},
|
||||
)
|
||||
if (isExpanded) {
|
||||
BookmarkGroupActions(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
openPostBookmarks = { onClick(BookmarkType.PostBookmark) },
|
||||
openArticleBookmarks = { onClick(BookmarkType.ArticleBookmark) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkGroupActions(
|
||||
modifier: Modifier = Modifier,
|
||||
openPostBookmarks: () -> Unit = {},
|
||||
openArticleBookmarks: () -> Unit = {},
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
maxLines = 2,
|
||||
maxItemsInEachRow = 2,
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = openPostBookmarks,
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.post),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(stringRes(R.string.bookmark_list_posts_btn_label))
|
||||
}
|
||||
FilledTonalButton(
|
||||
onClick = openArticleBookmarks,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Article,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(stringRes(R.string.bookmark_list_articles_btn_label))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkMembershipStatusAndNumberDisplay(
|
||||
modifier: Modifier,
|
||||
privateBookmarksSize: Int,
|
||||
publicBookmarksSize: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.offset(y = (-5).dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = SpacedBy5dp,
|
||||
) {
|
||||
if (privateBookmarksSize <= 0 && publicBookmarksSize <= 0) {
|
||||
Text(
|
||||
text = stringRes(R.string.follow_set_empty_label2),
|
||||
fontSize = Font10SP,
|
||||
)
|
||||
} else {
|
||||
if (privateBookmarksSize > 0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = SpacedBy2dp,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Lock,
|
||||
modifier = Size10Modifier,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = privateBookmarksSize.toString(),
|
||||
fontSize = Font10SP,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (publicBookmarksSize > 0) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = SpacedBy2dp,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Public,
|
||||
modifier = Size10Modifier,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = publicBookmarksSize.toString(),
|
||||
fontSize = Font10SP,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookmarkGroupOptionsButton(
|
||||
modifier: Modifier = Modifier,
|
||||
bookmarkGroupName: String,
|
||||
bookmarkGroupDescription: String?,
|
||||
onGroupRename: () -> Unit,
|
||||
onGroupDescriptionChange: () -> Unit,
|
||||
onGroupCloneCreate: (optionalName: String?, optionalDec: String?) -> Unit,
|
||||
onGroupDelete: () -> Unit,
|
||||
) {
|
||||
val isMenuOpen = remember { mutableStateOf(false) }
|
||||
|
||||
ClickableBox(
|
||||
onClick = { isMenuOpen.value = true },
|
||||
) {
|
||||
VerticalDotsIcon()
|
||||
|
||||
GroupOptionsMenu(
|
||||
groupName = bookmarkGroupName,
|
||||
groupDescription = bookmarkGroupDescription,
|
||||
isExpanded = isMenuOpen.value,
|
||||
onDismiss = { isMenuOpen.value = false },
|
||||
onGroupRename = onGroupRename,
|
||||
onGroupDescriptionChange = onGroupDescriptionChange,
|
||||
onGroupClone = onGroupCloneCreate,
|
||||
onDelete = onGroupDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupOptionsMenu(
|
||||
modifier: Modifier = Modifier,
|
||||
isExpanded: Boolean,
|
||||
groupName: String,
|
||||
groupDescription: String?,
|
||||
onGroupRename: () -> Unit,
|
||||
onGroupDescriptionChange: () -> Unit,
|
||||
onGroupClone: (optionalNewName: String?, optionalNewDesc: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val isCopyDialogOpen = remember { mutableStateOf(false) }
|
||||
val optionalCloneName = remember { mutableStateOf<String?>(null) }
|
||||
val optionalCloneDescription = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isExpanded,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_rename_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
onGroupRename()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_desc_modify_label))
|
||||
},
|
||||
onClick = {
|
||||
onGroupDescriptionChange()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_copy_action_btn_label))
|
||||
},
|
||||
onClick = {
|
||||
isCopyDialogOpen.value = true
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.quick_action_delete))
|
||||
},
|
||||
onClick = {
|
||||
onDelete()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isCopyDialogOpen.value) {
|
||||
GroupCloneDialog(
|
||||
optionalNewName = optionalCloneName.value,
|
||||
optionalNewDesc = optionalCloneDescription.value,
|
||||
onCloneNameChange = {
|
||||
optionalCloneName.value = it
|
||||
},
|
||||
onCloneDescChange = {
|
||||
optionalCloneDescription.value = it
|
||||
},
|
||||
onCloneCreate = { name, description ->
|
||||
onGroupClone(name, description)
|
||||
},
|
||||
onDismiss = { isCopyDialogOpen.value = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupCloneDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
optionalNewName: String?,
|
||||
optionalNewDesc: String?,
|
||||
onCloneNameChange: (String?) -> Unit,
|
||||
onCloneDescChange: (String?) -> Unit,
|
||||
onCloneCreate: (customName: String?, customDescription: String?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.bookmark_list_clone_btn_label),
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(Size5dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(R.string.follow_set_copy_indicator_description),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
// For the group clone name
|
||||
TextField(
|
||||
value = optionalNewName ?: "",
|
||||
onValueChange = onCloneNameChange,
|
||||
label = {
|
||||
Text(text = stringRes(R.string.follow_set_copy_name_label))
|
||||
},
|
||||
)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
// For the group clone description
|
||||
TextField(
|
||||
value = optionalNewDesc ?: "",
|
||||
onValueChange = onCloneDescChange,
|
||||
label = {
|
||||
Text(text = stringRes(R.string.follow_set_copy_desc_label))
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onCloneCreate(optionalNewName, optionalNewDesc)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text(stringRes(R.string.follow_set_copy_action_btn_label))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
fun ListOfBookmarkGroupsFeedView(
|
||||
groupListFeedSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
onOpenItem: (String, BookmarkType) -> Unit,
|
||||
onRenameItem: (targetBookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
onItemDescriptionChange: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
onItemClone: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit,
|
||||
onDeleteItem: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
) {
|
||||
val bookmarkGroupFeedState by groupListFeedSource.collectAsStateWithLifecycle()
|
||||
|
||||
if (bookmarkGroupFeedState.isEmpty()) {
|
||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
contentPadding = FeedPadding,
|
||||
) {
|
||||
itemsIndexed(
|
||||
bookmarkGroupFeedState,
|
||||
key = { _: Int, item: LabeledBookmarkList -> item.identifier },
|
||||
) { _, groupItem ->
|
||||
BookmarkGroupItem(
|
||||
modifier = Modifier.fillMaxSize().animateItem(),
|
||||
bookmarkList = groupItem,
|
||||
onClick = { bookmarkType -> onOpenItem(groupItem.identifier, bookmarkType) },
|
||||
onRename = { onRenameItem(groupItem) },
|
||||
onDescriptionChange = { onItemDescriptionChange(groupItem) },
|
||||
onClone = { cloneName, cloneDescription -> onItemClone(groupItem, cloneName, cloneDescription) },
|
||||
onDelete = { onDeleteItem(groupItem) },
|
||||
)
|
||||
HorizontalDivider(thickness = DividerThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupsFeedEmpty(message: String = stringRes(R.string.feed_is_empty)) {
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(horizontal = Size40dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(message)
|
||||
Spacer(modifier = StdVertSpacer)
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.list
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
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.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@Composable
|
||||
fun ListOfBookmarkGroupsScreen(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
ListOfBookmarkGroupsFeed(
|
||||
listSource = accountViewModel.account.labeledBookmarkLists.listFeedFlow,
|
||||
addBookmarkGroup = { nav.nav(Route.BookmarkGroupMetadataEdit()) },
|
||||
openBookmarkGroup = { identifier, bookmarkType ->
|
||||
nav.nav(Route.BookmarkGroupView(identifier, bookmarkType))
|
||||
},
|
||||
renameBookmarkGroup = { bookmarkGroup ->
|
||||
nav.nav(Route.BookmarkGroupMetadataEdit(bookmarkGroup.identifier))
|
||||
},
|
||||
changeBookmarkGroupDescription = { bookmarkGroup ->
|
||||
nav.nav(Route.BookmarkGroupMetadataEdit(bookmarkGroup.identifier))
|
||||
},
|
||||
cloneBookmarkGroup = { bookmarkGroup, customName, customDesc ->
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.cloneBookmarkList(
|
||||
currentBookmarkList = bookmarkGroup,
|
||||
customCloneName = customName,
|
||||
customCloneDescription = customDesc,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
deleteBookmarkGroup = { bookmarkGroup ->
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.deleteBookmarkList(
|
||||
bookmarkListIdentifier = bookmarkGroup.identifier,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
nav,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListOfBookmarkGroupsFeed(
|
||||
listSource: StateFlow<List<LabeledBookmarkList>>,
|
||||
addBookmarkGroup: () -> Unit,
|
||||
openBookmarkGroup: (identifier: String, bookmarkType: BookmarkType) -> Unit,
|
||||
renameBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
changeBookmarkGroupDescription: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
cloneBookmarkGroup: (bookmarkGroup: LabeledBookmarkList, customName: String?, customDesc: String?) -> Unit,
|
||||
deleteBookmarkGroup: (bookmarkGroup: LabeledBookmarkList) -> Unit,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopBarWithBackButton(caption = stringRes(R.string.bookmark_lists), nav::popBack)
|
||||
},
|
||||
floatingActionButton = {
|
||||
BookmarkGroupFab(onAddGroup = addBookmarkGroup)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = paddingValues.calculateBottomPadding(),
|
||||
).fillMaxHeight(),
|
||||
) {
|
||||
ListOfBookmarkGroupsFeedView(
|
||||
groupListFeedSource = listSource,
|
||||
onOpenItem = openBookmarkGroup,
|
||||
onRenameItem = renameBookmarkGroup,
|
||||
onItemDescriptionChange = changeBookmarkGroupDescription,
|
||||
onItemClone = cloneBookmarkGroup,
|
||||
onDeleteItem = deleteBookmarkGroup,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupFab(onAddGroup: () -> Unit) {
|
||||
ExtendedFloatingActionButton(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.follow_set_create_btn_label))
|
||||
},
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.PlaylistAdd,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
onClick = onAddGroup,
|
||||
shape = CircleShape,
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.list
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
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.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import kotlin.toString
|
||||
|
||||
@Composable
|
||||
fun NewBookmarkGroupCreationDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onDismiss: () -> Unit,
|
||||
onCreateGroup: (name: String, description: String?) -> Unit,
|
||||
) {
|
||||
val newGroupName = remember { mutableStateOf("") }
|
||||
val newGroupDescription = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
AlertDialog(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "New Bookmark Group",
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
// For the new bookmark group name
|
||||
TextField(
|
||||
value = newGroupName.value,
|
||||
onValueChange = { newGroupName.value = it },
|
||||
label = {
|
||||
Text(text = "Group name")
|
||||
},
|
||||
)
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
// For the group description
|
||||
TextField(
|
||||
value =
|
||||
(if (newGroupDescription.value != null) newGroupDescription.value else "").toString(),
|
||||
onValueChange = { newGroupDescription.value = it },
|
||||
label = {
|
||||
Text(text = "Group description(optional)")
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
onCreateGroup(newGroupName.value, newGroupDescription.value)
|
||||
onDismiss()
|
||||
},
|
||||
) {
|
||||
Text("Create Group")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+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.bookmarkgroups.list.metadata
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.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.platform.LocalContext
|
||||
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.actions.uploads.SelectSingleFromGallery
|
||||
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.screen.loggedIn.relays.SettingsCategory
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupMetadataScreen(
|
||||
bookmarkGroupIdentifier: String?,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel = viewModel()
|
||||
bookmarkGroupInfoViewModel.init(accountViewModel)
|
||||
|
||||
if (bookmarkGroupIdentifier != null) {
|
||||
LaunchedEffect(bookmarkGroupInfoViewModel) {
|
||||
bookmarkGroupInfoViewModel.load(bookmarkGroupIdentifier)
|
||||
}
|
||||
} else {
|
||||
LaunchedEffect(bookmarkGroupInfoViewModel) {
|
||||
bookmarkGroupInfoViewModel.new()
|
||||
}
|
||||
}
|
||||
|
||||
BookmarkGroupMetadataScaffold(bookmarkGroupInfoViewModel, accountViewModel, nav)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BookmarkGroupMetadataScaffold(
|
||||
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
BookmarkGroupMetadataTopBar(
|
||||
bookmarkGroupInfoViewModel = bookmarkGroupInfoViewModel,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
},
|
||||
) { pad ->
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
start = 10.dp,
|
||||
end = 10.dp,
|
||||
top = pad.calculateTopPadding(),
|
||||
bottom = pad.calculateBottomPadding(),
|
||||
).consumeWindowInsets(pad)
|
||||
.imePadding(),
|
||||
) {
|
||||
item {
|
||||
SettingsCategory(
|
||||
R.string.bookmark_list_edit_sub_title,
|
||||
R.string.bookmark_list_explainer,
|
||||
SettingsCategoryFirstModifier,
|
||||
)
|
||||
|
||||
ListName(bookmarkGroupInfoViewModel)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
Picture(bookmarkGroupInfoViewModel, accountViewModel)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
|
||||
Description(bookmarkGroupInfoViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupMetadataTopBar(
|
||||
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
if (bookmarkGroupInfoViewModel.isNewList) {
|
||||
CreatingTopBar(
|
||||
titleRes = R.string.bookmark_list_creation_screen_title,
|
||||
isActive = bookmarkGroupInfoViewModel::canPost,
|
||||
onCancel = {
|
||||
bookmarkGroupInfoViewModel.clear()
|
||||
nav.popBack()
|
||||
},
|
||||
onPost = {
|
||||
try {
|
||||
bookmarkGroupInfoViewModel.createOrUpdate()
|
||||
nav.popBack()
|
||||
} catch (e: 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.follow_set_edit_list_metadata,
|
||||
isActive = bookmarkGroupInfoViewModel::canPost,
|
||||
onCancel = {
|
||||
bookmarkGroupInfoViewModel.clear()
|
||||
nav.popBack()
|
||||
},
|
||||
onPost = {
|
||||
try {
|
||||
bookmarkGroupInfoViewModel.createOrUpdate()
|
||||
nav.popBack()
|
||||
} catch (e: 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 Description(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.follow_set_creation_desc_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = bookmarkGroupInfoViewModel.description.value,
|
||||
onValueChange = { bookmarkGroupInfoViewModel.description.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.about_us),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
minLines = 3,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Picture(
|
||||
bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.optional_picture_url)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = bookmarkGroupInfoViewModel.picture.value,
|
||||
onValueChange = { bookmarkGroupInfoViewModel.picture.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "http://mygroup.com/logo.jpg",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
val context = LocalContext.current
|
||||
SelectSingleFromGallery(
|
||||
isUploading = bookmarkGroupInfoViewModel.isUploadingImageForPicture,
|
||||
tint = MaterialTheme.colorScheme.placeholderText,
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
) {
|
||||
bookmarkGroupInfoViewModel.uploadForPicture(it, context, onError = accountViewModel.toastManager::toast)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListName(bookmarkGroupInfoViewModel: BookmarkGroupMetadataViewModel) {
|
||||
OutlinedTextField(
|
||||
label = { Text(text = stringRes(R.string.follow_set_creation_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = bookmarkGroupInfoViewModel.name.value,
|
||||
onValueChange = { bookmarkGroupInfoViewModel.name.value = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringRes(R.string.follow_set_copy_name_label),
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
),
|
||||
textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content),
|
||||
)
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.list.metadata
|
||||
|
||||
import android.content.Context
|
||||
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 androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@Stable
|
||||
class BookmarkGroupMetadataViewModel : ViewModel() {
|
||||
private lateinit var accountViewModel: AccountViewModel
|
||||
private lateinit var account: Account
|
||||
|
||||
var bookmarkGroup by mutableStateOf<LabeledBookmarkList?>(null)
|
||||
val isNewList by derivedStateOf { bookmarkGroup == null }
|
||||
|
||||
val name = mutableStateOf(TextFieldValue())
|
||||
val picture = mutableStateOf(TextFieldValue())
|
||||
val description = mutableStateOf(TextFieldValue())
|
||||
|
||||
var isUploadingImageForPicture by mutableStateOf(false)
|
||||
|
||||
val canPost by derivedStateOf {
|
||||
name.value.text.isNotBlank()
|
||||
}
|
||||
|
||||
fun init(accountViewModel: AccountViewModel) {
|
||||
this.accountViewModel = accountViewModel
|
||||
this.account = accountViewModel.account
|
||||
}
|
||||
|
||||
fun new() {
|
||||
bookmarkGroup = null
|
||||
clear()
|
||||
}
|
||||
|
||||
fun load(dTag: String) {
|
||||
bookmarkGroup = account.labeledBookmarkLists.getBookmarkList(dTag)
|
||||
name.value = TextFieldValue(bookmarkGroup?.title ?: "")
|
||||
picture.value = TextFieldValue(bookmarkGroup?.image ?: "")
|
||||
description.value = TextFieldValue(bookmarkGroup?.description ?: "")
|
||||
}
|
||||
|
||||
fun isNewChannel() = bookmarkGroup == null
|
||||
|
||||
fun createOrUpdate() {
|
||||
accountViewModel.launchSigner {
|
||||
val bookmarkGroup = bookmarkGroup
|
||||
if (bookmarkGroup == null) {
|
||||
accountViewModel.account.labeledBookmarkLists.addLabeledBookmarkList(
|
||||
listName = name.value.text,
|
||||
listDescription = description.value.text,
|
||||
listImage = picture.value.text,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
} else {
|
||||
accountViewModel.account.labeledBookmarkLists.updateMetadata(
|
||||
listName = name.value.text,
|
||||
listDescription = description.value.text,
|
||||
listImage = picture.value.text,
|
||||
bookmarkList = bookmarkGroup,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
|
||||
clear()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
name.value = TextFieldValue()
|
||||
picture.value = TextFieldValue()
|
||||
description.value = TextFieldValue()
|
||||
}
|
||||
|
||||
fun uploadForPicture(
|
||||
uri: SelectedMedia,
|
||||
context: Context,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
upload(
|
||||
uri,
|
||||
context,
|
||||
onUploading = { isUploadingImageForPicture = it },
|
||||
onUploaded = { picture.value = TextFieldValue(it) },
|
||||
onError = onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun upload(
|
||||
galleryUri: SelectedMedia,
|
||||
context: Context,
|
||||
onUploading: (Boolean) -> Unit,
|
||||
onUploaded: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
) {
|
||||
onUploading(true)
|
||||
|
||||
val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
|
||||
try {
|
||||
val result =
|
||||
if (account.settings.defaultFileServer.type == ServerType.NIP96) {
|
||||
Nip96Uploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
onProgress = {},
|
||||
httpAuth = account::createHTTPAuthorization,
|
||||
context = context,
|
||||
)
|
||||
} else {
|
||||
BlossomUploader().upload(
|
||||
uri = compResult.uri,
|
||||
contentType = compResult.contentType,
|
||||
size = compResult.size,
|
||||
alt = null,
|
||||
sensitiveContent = null,
|
||||
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
|
||||
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
|
||||
httpAuth = account::createBlossomUploadAuth,
|
||||
context = context,
|
||||
)
|
||||
}
|
||||
|
||||
if (result.url != null) {
|
||||
onUploading(false)
|
||||
onUploaded(result.url)
|
||||
} else {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading))
|
||||
}
|
||||
} catch (_: SignerExceptions.ReadOnlyException) {
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onUploading(false)
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.membershipManagement
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.layout.recalculateWindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
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.note.LoadAddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
|
||||
@Composable
|
||||
fun ArticleBookmarkListManagementScreen(
|
||||
articleAddress: Address,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadAddressableNote(address = articleAddress, accountViewModel = accountViewModel) {
|
||||
it?.let {
|
||||
ListManagementView(
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListManagementView(
|
||||
modifier: Modifier = Modifier,
|
||||
note: AddressableNote,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopBarWithBackButton(caption = stringRes(R.string.article_bookmark_management_title), nav::popBack)
|
||||
},
|
||||
floatingActionButton = {
|
||||
NewListButton { nav.nav(Route.BookmarkGroupMetadataEdit()) }
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
top = contentPadding.calculateTopPadding(),
|
||||
bottom = contentPadding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(contentPadding)
|
||||
.imePadding(),
|
||||
) {
|
||||
if (bookmarkGroups.isEmpty()) {
|
||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
|
||||
val maybePublicBookmark = bookmarkList.publicArticleBookmarks.firstOrNull { it.address == note.address }
|
||||
val maybePrivateBookmark = bookmarkList.privateArticleBookmarks.firstOrNull { it.address == note.address }
|
||||
BookmarkGroupManagementItem(
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
listTitle = bookmarkList.title,
|
||||
isPublicMemberBookmark = maybePublicBookmark != null,
|
||||
isPrivateMemberBookmark = maybePrivateBookmark != null,
|
||||
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.ArticleBookmark)) },
|
||||
onAddBookmarkToGroup = { shouldBePrivate ->
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
|
||||
bookmark = AddressBookmark(address = note.address, relayHint = note.relayHintUrl()),
|
||||
bookmarkListIdentifier = bookmarkList.identifier,
|
||||
isBookmarkPrivate = shouldBePrivate,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
onRemoveBookmarkFromGroup = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
|
||||
bookmark = AddressBookmark(address = note.address),
|
||||
bookmarkListIdentifier = bookmarkList.identifier,
|
||||
isBookmarkPrivate = maybePrivateBookmark != null,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.membershipManagement
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BookmarkAdd
|
||||
import androidx.compose.material.icons.filled.BookmarkRemove
|
||||
import androidx.compose.material.icons.outlined.CollectionsBookmark
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material.icons.outlined.Public
|
||||
import androidx.compose.material.icons.outlined.RemoveCircleOutline
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size50Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp
|
||||
|
||||
@Composable
|
||||
fun BookmarkGroupManagementItem(
|
||||
modifier: Modifier = Modifier,
|
||||
listTitle: String,
|
||||
isPrivateMemberBookmark: Boolean,
|
||||
isPublicMemberBookmark: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onAddBookmarkToGroup: (shouldBookmarkBePrivate: Boolean) -> Unit,
|
||||
onRemoveBookmarkFromGroup: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = listTitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
BookmarkStatusInList(isPublicMemberBookmark, isPrivateMemberBookmark)
|
||||
},
|
||||
leadingContent = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.CollectionsBookmark,
|
||||
contentDescription = stringRes(R.string.bookmark_list_icon_label),
|
||||
modifier = Size50Modifier,
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
val isBookmarkInGroup = isPrivateMemberBookmark || isPublicMemberBookmark
|
||||
BookmarkManagementOptions(
|
||||
isBookmarkInList = isBookmarkInGroup,
|
||||
onAddBookmark = onAddBookmarkToGroup,
|
||||
onRemoveBookmark = onRemoveBookmarkFromGroup,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkStatusInList(
|
||||
isPublicMemberBookmark: Boolean,
|
||||
isPrivateMemberBookmark: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = HalfHalfVertPadding,
|
||||
horizontalArrangement = SpacedBy5dp,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val text =
|
||||
if (isPublicMemberBookmark) {
|
||||
stringRes(R.string.public_bookmark_presence_indicator)
|
||||
} else if (isPrivateMemberBookmark) {
|
||||
stringRes(R.string.private_bookmark_presence_indicator)
|
||||
} else {
|
||||
stringRes(R.string.bookmark_absence_indicator)
|
||||
}
|
||||
|
||||
val icon =
|
||||
if (isPublicMemberBookmark) {
|
||||
Icons.Outlined.Public
|
||||
} else if (isPrivateMemberBookmark) {
|
||||
Icons.Outlined.Lock
|
||||
} else {
|
||||
Icons.Outlined.RemoveCircleOutline
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
modifier = Size15Modifier,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BookmarkManagementOptions(
|
||||
isBookmarkInList: Boolean,
|
||||
onAddBookmark: (shouldBePrivate: Boolean) -> Unit,
|
||||
onRemoveBookmark: () -> Unit,
|
||||
) {
|
||||
val isBookmarkAddTapped = remember { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isBookmarkInList) {
|
||||
onRemoveBookmark()
|
||||
} else {
|
||||
isBookmarkAddTapped.value = true
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.background(
|
||||
color =
|
||||
if (isBookmarkInList) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
shape = RoundedCornerShape(percent = 80),
|
||||
),
|
||||
) {
|
||||
if (isBookmarkInList) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BookmarkRemove,
|
||||
contentDescription = stringRes(R.string.bookmark_remove_action_desc),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BookmarkAdd,
|
||||
contentDescription = stringRes(R.string.bookmark_add_action_desc),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = isBookmarkAddTapped.value,
|
||||
onDismissRequest = { isBookmarkAddTapped.value = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.public_bookmark_add_action_label))
|
||||
},
|
||||
onClick = {
|
||||
onAddBookmark(false)
|
||||
isBookmarkAddTapped.value = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(text = stringRes(R.string.private_bookmark_add_action_label))
|
||||
},
|
||||
onClick = {
|
||||
onAddBookmark(true)
|
||||
isBookmarkAddTapped.value = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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.bookmarkgroups.membershipManagement
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.layout.recalculateWindowInsets
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkList
|
||||
import com.vitorpamplona.amethyst.ui.components.LoadNote
|
||||
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.screen.loggedIn.bookmarkgroups.BookmarkType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.list.BookmarkGroupsFeedEmpty
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.NewListButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
|
||||
@Composable
|
||||
fun PostBookmarkListManagementScreen(
|
||||
postId: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
LoadNote(baseNoteHex = postId, accountViewModel = accountViewModel) {
|
||||
it?.let {
|
||||
ListManagementView(
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets(),
|
||||
note = it,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListManagementView(
|
||||
modifier: Modifier = Modifier,
|
||||
note: Note,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val bookmarkGroups by accountViewModel.account.labeledBookmarkLists.listFeedFlow
|
||||
.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopBarWithBackButton(caption = stringRes(R.string.post_bookmark_management_title), nav::popBack)
|
||||
},
|
||||
floatingActionButton = {
|
||||
NewListButton { nav.nav(Route.BookmarkGroupMetadataEdit()) }
|
||||
},
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(
|
||||
top = contentPadding.calculateTopPadding(),
|
||||
bottom = contentPadding.calculateBottomPadding(),
|
||||
).consumeWindowInsets(contentPadding)
|
||||
.imePadding(),
|
||||
) {
|
||||
if (bookmarkGroups.isEmpty()) {
|
||||
BookmarkGroupsFeedEmpty(message = stringRes(R.string.bookmark_list_feed_empty_msg))
|
||||
} else {
|
||||
LazyColumn(
|
||||
state = rememberLazyListState(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
itemsIndexed(items = bookmarkGroups, key = { _: Int, item: LabeledBookmarkList -> item.identifier }) { _, bookmarkList ->
|
||||
val maybePublicBookmark = bookmarkList.publicPostBookmarks.firstOrNull { it.eventId == note.idHex }
|
||||
val maybePrivateBookmark = bookmarkList.privatePostBookmarks.firstOrNull { it.eventId == note.idHex }
|
||||
BookmarkGroupManagementItem(
|
||||
modifier = Modifier.fillMaxWidth().animateItem(),
|
||||
listTitle = bookmarkList.title,
|
||||
isPublicMemberBookmark = maybePublicBookmark != null,
|
||||
isPrivateMemberBookmark = maybePrivateBookmark != null,
|
||||
onClick = { nav.nav(Route.BookmarkGroupView(bookmarkList.identifier, BookmarkType.PostBookmark)) },
|
||||
onAddBookmarkToGroup = { shouldBePrivate ->
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.addBookmarkToList(
|
||||
bookmark = EventBookmark(eventId = note.idHex, relay = note.relayHintUrl(), author = note.author?.pubkeyHex),
|
||||
bookmarkListIdentifier = bookmarkList.identifier,
|
||||
isBookmarkPrivate = shouldBePrivate,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
onRemoveBookmarkFromGroup = {
|
||||
accountViewModel.launchSigner {
|
||||
accountViewModel.account.labeledBookmarkLists.removeBookmarkFromList(
|
||||
bookmark = EventBookmark(eventId = note.idHex),
|
||||
bookmarkListIdentifier = bookmarkList.identifier,
|
||||
isBookmarkPrivate = maybePrivateBookmark != null,
|
||||
account = accountViewModel.account,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!-- drawable/post.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M3 3V21H21V3H3M18 18H6V17H18V18M18 16H6V15H18V16M18 12H6V6H18V12Z" /></vector>
|
||||
@@ -112,10 +112,12 @@
|
||||
|
||||
<string name="error_parsing_preview_for">"Error parsing preview for %1$s : %2$s"</string>
|
||||
<string name="preview_card_image_for">"Preview Card Image for %1$s"</string>
|
||||
<string name="article">Article</string>
|
||||
<string name="new_channel">New Channel</string>
|
||||
<string name="channel_name">Channel Name</string>
|
||||
<string name="my_awesome_group">My Awesome Group</string>
|
||||
<string name="picture_url">Picture Url</string>
|
||||
<string name="optional_picture_url">Picture Url(Optional)</string>
|
||||
<string name="description">Description</string>
|
||||
<string name="no_description">Description not found</string>
|
||||
<string name="about_us">"About us.. "</string>
|
||||
@@ -388,6 +390,39 @@
|
||||
<string name="remove_from_private_bookmarks">Remove from Private Bookmarks</string>
|
||||
<string name="remove_from_public_bookmarks">Remove from Public Bookmarks</string>
|
||||
|
||||
<string name="bookmark_lists">Bookmark Lists</string>
|
||||
<string name="bookmark_list_icon_label">Icon for bookmark list</string>
|
||||
<string name="bookmark_list_creation_screen_title">New Bookmark List</string>
|
||||
<string name="bookmark_list_edit_sub_title">Bookmark List Metadata</string>
|
||||
<string name="bookmark_list_clone_btn_label">Clone Bookmark List</string>
|
||||
<string name="bookmark_list_broadcast_btn_label">Broadcast Bookmark List</string>
|
||||
<string name="bookmark_list_delete_btn_label">Delete Bookmark List</string>
|
||||
<string name="bookmark_list_posts_btn_label">View Posts</string>
|
||||
<string name="bookmark_list_articles_btn_label">View Articles</string>
|
||||
<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="private_posts_label">Private Posts</string>
|
||||
<string name="private_posts_count">Private Posts(%1$s)</string>
|
||||
<string name="public_posts_label">Public Posts</string>
|
||||
<string name="public_posts_count">Public Posts(%1$s)</string>
|
||||
<string name="private_articles_label">Private Articles</string>
|
||||
<string name="private_articles_count">Private Articles(%1$s)</string>
|
||||
<string name="public_articles_label">Public Articles</string>
|
||||
<string name="public_articles_count">Public Articles(%1$s)</string>
|
||||
<string name="manage_bookmark_label">Manage %1$s in Bookmark Lists</string>
|
||||
<string name="post_bookmark_management_title">Bookmark this post</string>
|
||||
<string name="article_bookmark_management_title">Bookmark this article</string>
|
||||
<string name="public_bookmark_presence_indicator">is a public bookmark here</string>
|
||||
<string name="private_bookmark_presence_indicator">is a private bookmark here</string>
|
||||
<string name="bookmark_absence_indicator">is not a bookmark here</string>
|
||||
<string name="bookmark_remove_action_desc">Remove bookmark from list</string>
|
||||
<string name="bookmark_add_action_desc">Add bookmark to list</string>
|
||||
<string name="public_bookmark_add_action_label">Add as public bookmark</string>
|
||||
<string name="private_bookmark_add_action_label">Add as private bookmark</string>
|
||||
<string name="bookmark_remove_action_label">Remove from Bookmark List</string>
|
||||
<string name="bookmark_list_explainer">Bookmark lists metadata can be seen by anyone on Nostr. Only your private members are encrypted.</string>
|
||||
|
||||
<string name="wallet_connect_service">Wallet Connect Service</string>
|
||||
<string name="wallet_connect_service_explainer">Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible</string>
|
||||
<string name="wallet_connect_service_pubkey">Wallet Connect Pubkey</string>
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 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.quartz.nip51Lists.labeledBookmarkList
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastAny
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip31Alts.alt
|
||||
import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.AddressBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.EventBookmark
|
||||
import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent
|
||||
import com.vitorpamplona.quartz.nip51Lists.remove
|
||||
import com.vitorpamplona.quartz.nip51Lists.replaceAll
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.TitleTag
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
@Immutable
|
||||
class LabeledBookmarkListEvent(
|
||||
id: HexKey,
|
||||
pubKey: HexKey,
|
||||
createdAt: Long,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
sig: HexKey,
|
||||
) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig),
|
||||
EventHintProvider,
|
||||
AddressHintProvider {
|
||||
override fun eventHints() = tags.mapNotNull(EventBookmark::parseAsHint)
|
||||
|
||||
override fun linkedEventIds() = tags.mapNotNull(EventBookmark::parseId)
|
||||
|
||||
override fun addressHints() = tags.mapNotNull(AddressBookmark::parseAsHint)
|
||||
|
||||
override fun linkedAddressIds() = tags.mapNotNull(AddressBookmark::parseAddressId)
|
||||
|
||||
fun name() = tags.firstNotNullOfOrNull(NameTag::parse)
|
||||
|
||||
fun title() = tags.firstNotNullOfOrNull(TitleTag::parse)
|
||||
|
||||
fun nameOrTitle() = name() ?: title()
|
||||
|
||||
fun description() = tags.firstNotNullOfOrNull(DescriptionTag::parse)
|
||||
|
||||
fun image() = tags.firstNotNullOfOrNull(ImageTag::parse)
|
||||
|
||||
fun countBookmarks() = tags.count(BookmarkIdTag::isTagged)
|
||||
|
||||
fun publicBookmarks(): List<BookmarkIdTag> = tags.mapNotNull(BookmarkIdTag::parse)
|
||||
|
||||
suspend fun privateBookmarks(signer: NostrSigner): List<BookmarkIdTag>? = privateTags(signer)?.mapNotNull(BookmarkIdTag::parse)
|
||||
|
||||
companion object {
|
||||
const val KIND = 30003
|
||||
|
||||
const val ALT = "A labeled list of bookmarks"
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, Uuid.random().toString())
|
||||
|
||||
suspend fun create(
|
||||
name: String = "",
|
||||
bookmarkIdTag: BookmarkIdTag,
|
||||
isPrivate: Boolean,
|
||||
optionalListDescription: String? = null,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent =
|
||||
if (isPrivate) {
|
||||
create(
|
||||
name = name,
|
||||
description = optionalListDescription,
|
||||
publicBookmarks = emptyList(),
|
||||
privateBookmarks = listOf(bookmarkIdTag),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
create(
|
||||
name = name,
|
||||
description = optionalListDescription,
|
||||
publicBookmarks = listOf(bookmarkIdTag),
|
||||
privateBookmarks = emptyList(),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun addBookmark(
|
||||
earlierVersion: LabeledBookmarkListEvent,
|
||||
bookmarkIdTag: BookmarkIdTag,
|
||||
isPrivate: Boolean,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent =
|
||||
if (isPrivate) {
|
||||
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
resign(
|
||||
tags = earlierVersion.tags,
|
||||
privateTags = privateTags.plus(bookmarkIdTag.toTagArray()),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
resign(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.plus(bookmarkIdTag.toTagArray()),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun removeBookmark(
|
||||
earlierVersion: LabeledBookmarkListEvent,
|
||||
bookmarkIdTag: BookmarkIdTag,
|
||||
isPrivate: Boolean,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent =
|
||||
if (isPrivate) {
|
||||
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
resign(
|
||||
privateTags = privateTags.remove(bookmarkIdTag.toTagIdOnly()),
|
||||
tags = earlierVersion.tags,
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
resign(
|
||||
content = earlierVersion.content,
|
||||
tags = earlierVersion.tags.remove(bookmarkIdTag.toTagIdOnly()),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun modifyName(
|
||||
earlierVersion: LabeledBookmarkListEvent,
|
||||
newName: String,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent {
|
||||
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
val currentTitle = earlierVersion.tags.first { it[0] == NameTag.TAG_NAME || it[0] == TitleTag.TAG_NAME }
|
||||
val newTitleTag =
|
||||
if (currentTitle[0] == NameTag.TAG_NAME) {
|
||||
NameTag.assemble(newName)
|
||||
} else {
|
||||
TitleTag.assemble(newName)
|
||||
}
|
||||
|
||||
return resign(
|
||||
tags = earlierVersion.tags.replaceAll(currentTitle, newTitleTag),
|
||||
privateTags = privateTags.replaceAll(currentTitle, newTitleTag),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun modifyDescription(
|
||||
earlierVersion: LabeledBookmarkListEvent,
|
||||
newDescription: String?,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent? {
|
||||
val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException()
|
||||
val currentDescriptionTag = earlierVersion.tags.firstOrNull { it[0] == DescriptionTag.TAG_NAME }
|
||||
val currentDescription = currentDescriptionTag?.get(1)
|
||||
if (currentDescription.equals(newDescription)) {
|
||||
// Do nothing
|
||||
return null
|
||||
} else {
|
||||
if (newDescription == null || newDescription.isEmpty()) {
|
||||
return resign(
|
||||
tags = earlierVersion.tags.remove { it[0] == DescriptionTag.TAG_NAME },
|
||||
privateTags = privateTags.remove { it[0] == DescriptionTag.TAG_NAME },
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
val newDescriptionTag = DescriptionTag.assemble(newDescription)
|
||||
return if (currentDescriptionTag == null) {
|
||||
resign(
|
||||
tags = earlierVersion.tags.plusElement(newDescriptionTag),
|
||||
privateTags = privateTags,
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
} else {
|
||||
resign(
|
||||
tags = earlierVersion.tags.replaceAll(currentDescriptionTag, newDescriptionTag),
|
||||
privateTags = privateTags.replaceAll(currentDescriptionTag, newDescriptionTag),
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resign(
|
||||
tags: TagArray,
|
||||
privateTags: TagArray,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
) = resign(
|
||||
content = PrivateTagsInContent.encryptNip44(privateTags, signer),
|
||||
tags = tags,
|
||||
signer = signer,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
suspend fun resign(
|
||||
content: String,
|
||||
tags: TagArray,
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent {
|
||||
val newTags =
|
||||
if (tags.fastAny(AltTag::match)) {
|
||||
tags
|
||||
} else {
|
||||
tags + AltTag.assemble(ALT)
|
||||
}
|
||||
|
||||
return signer.sign(createdAt, KIND, newTags, content)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
suspend fun create(
|
||||
name: String = "",
|
||||
description: String? = null,
|
||||
image: String? = null,
|
||||
publicBookmarks: List<BookmarkIdTag> = emptyList(),
|
||||
privateBookmarks: List<BookmarkIdTag> = emptyList(),
|
||||
dTag: String = Uuid.random().toString(),
|
||||
signer: NostrSigner,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): LabeledBookmarkListEvent {
|
||||
val template =
|
||||
build(name, publicBookmarks, privateBookmarks, signer, dTag, createdAt) {
|
||||
if (description != null) description(description)
|
||||
if (image != null) image(image)
|
||||
}
|
||||
return signer.sign(template)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalUuidApi::class)
|
||||
suspend fun build(
|
||||
name: String = "",
|
||||
publicBookmarks: List<BookmarkIdTag> = emptyList(),
|
||||
privateBookmarks: List<BookmarkIdTag> = emptyList(),
|
||||
signer: NostrSigner,
|
||||
dTag: String = Uuid.random().toString(),
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<LabeledBookmarkListEvent>.() -> Unit = {},
|
||||
) = eventTemplate(
|
||||
kind = KIND,
|
||||
description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer),
|
||||
createdAt = createdAt,
|
||||
) {
|
||||
dTag(dTag)
|
||||
alt(ALT)
|
||||
name(name)
|
||||
bookmarks(publicBookmarks)
|
||||
|
||||
initializer()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 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.quartz.nip51Lists.labeledBookmarkList
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.tags.BookmarkIdTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.DescriptionTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.ImageTag
|
||||
import com.vitorpamplona.quartz.nip51Lists.tags.NameTag
|
||||
|
||||
fun TagArrayBuilder<LabeledBookmarkListEvent>.name(name: String) = addUnique(NameTag.assemble(name))
|
||||
|
||||
fun TagArrayBuilder<LabeledBookmarkListEvent>.bookmarks(bookmarks: List<BookmarkIdTag>) = addAll(bookmarks.map { it.toTagArray() })
|
||||
|
||||
fun TagArrayBuilder<LabeledBookmarkListEvent>.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription))
|
||||
|
||||
fun TagArrayBuilder<LabeledBookmarkListEvent>.image(url: String) = addUnique(ImageTag.assemble(url))
|
||||
@@ -81,6 +81,7 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
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.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent
|
||||
@@ -249,6 +250,7 @@ class EventFactory {
|
||||
InteractiveStoryPrologueEvent.KIND -> InteractiveStoryPrologueEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
InteractiveStorySceneEvent.KIND -> InteractiveStorySceneEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
InteractiveStoryReadingStateEvent.KIND -> InteractiveStoryReadingStateEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
|
||||
|
||||
Reference in New Issue
Block a user