Merge branch 'main' of https://github.com/vitorpamplona/amethyst
* 'main' of https://github.com/vitorpamplona/amethyst: (23 commits) Fix default permissions Add Bookmark group metadata edit to routes/nav. Make refactors to make use of it. Bookmark group metadata edit screen, with VM and string resources. Add methods to LabeledBookmarkListEvent and LabeledBookmarkListsState. Support image urls in LabeledBookmarkList. String resources. Remove unneeded Todo. Add bookmark list addition/removal to the note dropdown menu. Article Bookmark management screen. Add routes and nav entries for both screens. Post Bookmark management screen. Other Hashtag/Link related refactors. Remove HashtagListView and LinksListView due to spec changes, with their entries in BookmarkType. Use stringResources. Implement the necessary callbacks in BookmarkGroupScreen. Disable component stats display in BookmarkGroupItem until Link/Hashtag support is implemented. Bring ArticleListView up to speed. Fix callback naming mistake in BookmarkGroupItemOptions and refactor usages accordingly. First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView. Fix some methods in LabeledBookmarkListsState. Add useful methods to LabeledBookmarkList and BookmarkGroupViewModel. Introduce BookmarkGroupScreen, with it's associated ViewModel, and the different category-based views. Move BookmarkType to an enum instead, for serialization purposes. Slight function name refactor in LabeledBookmarkListsState, and inclusion of a method to reference a particular bookmark group. Add route for viewing a bookmark group, and wire UI accordingly. Change the UI of BookmarkGroupItem to add buttons for viewing parts of the bookmark group. Add support for LabeledBookmarkListEvent in the LocalCache, and EventFactory(to prevent crashes when creating one). Add bookmark groups option to side menu. Make them work by hooking them up in Account. Build out UI for the list of labeled bookmarks, or bookmark groups, borrowing some components from elsewhere. ...
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)
|
||||
|
||||
@@ -1813,6 +1815,7 @@ class Account(
|
||||
newNotesPreProcessor.runNew(newNotes)
|
||||
peopleLists.newNotes(newNotes)
|
||||
followLists.newNotes(newNotes)
|
||||
labeledBookmarkLists.newNotes(newNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1823,6 +1826,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?,
|
||||
@@ -2820,6 +2827,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>
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ class LoginRequest {
|
||||
Permission(CommandType.NIP04_ENCRYPT),
|
||||
Permission(CommandType.NIP04_DECRYPT),
|
||||
Permission(CommandType.NIP44_DECRYPT),
|
||||
Permission(CommandType.NIP44_DECRYPT),
|
||||
Permission(CommandType.NIP44_ENCRYPT),
|
||||
Permission(CommandType.DECRYPT_ZAP_EVENT),
|
||||
)
|
||||
|
||||
|
||||
+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