diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index aeb225269..aedf23b60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 1bc689b39..b259eb5bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt new file mode 100644 index 000000000..74333236f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkList.kt @@ -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 = emptySet(), + val publicBookmarks: Set = 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 } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt new file mode 100644 index 000000000..e753fc751 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/labeledBookmarkLists/LabeledBookmarkListsState.kt @@ -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> = + labeledBookmarkListNotes + .transformLatest { emitAll(it.updateFlow()) } + .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.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.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): Boolean = + notes.anyNotNullEvent { event -> + if (event.pubKey == signer.pubKey) { + event is LabeledBookmarkListEvent || (event is DeletionEvent && event.hasAnyDeletedBookmarkLists()) + } else { + false + } + } + + fun newNotes(newNotes: Set) { + if (hasItemInNoteList(newNotes)) { + forceRefresh() + } + } + + fun deletedNotes(deletedNotes: Set) { + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 259dc3446..b1557de3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { PeopleListMetadataScreen(it.dTag, accountViewModel, nav) } composableFromBottomArgs { FollowPackMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromEnd { ListOfBookmarkGroupsScreen(accountViewModel, nav) } + composableFromEndArgs { BookmarkGroupScreen(it.dTag, it.bookmarkType, accountViewModel, nav) } + composableFromBottomArgs { BookmarkGroupMetadataScreen(it.dTag, accountViewModel, nav) } + composableFromBottomArgs { PostBookmarkListManagementScreen(it.postId, accountViewModel, nav) } + composableFromBottomArgs { ArticleBookmarkListManagementScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } + composableFromBottomArgs { ShowQRScreen(it.pubkey, accountViewModel, nav) } composableFromBottomArgs { PayViaIntentScreen(it.paymentId, accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 10fd98294..dabc99504 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 147b1711e..a30068dbe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 85d2b2a6d..6d152f6ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -241,6 +241,7 @@ fun NoteCompose( parentBackgroundColor: MutableState? = 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? = 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>, 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>, 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() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt index 98043a7b8..a15f4c944 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DropDownMenu.kt @@ -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)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt new file mode 100644 index 000000000..2631e84f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/BookmarkType.kt @@ -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, +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt new file mode 100644 index 000000000..11317e928 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/ArticleListView.kt @@ -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, + 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, + ) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt new file mode 100644 index 000000000..e734d78d9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -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>? = 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>? = 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() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt new file mode 100644 index 000000000..e06eaea47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupScreen.kt @@ -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() + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt new file mode 100644 index 000000000..a0cd966be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupViewModel.kt @@ -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 create(modelClass: Class): T = BookmarkGroupViewModel(account, bookmarkGroupIdentifier) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt new file mode 100644 index 000000000..d6976df7e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt @@ -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, + 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, + ) + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt new file mode 100644 index 000000000..787241b0b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/BookmarkGroupItem.kt @@ -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(null) } + val optionalCloneDescription = remember { mutableStateOf(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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt new file mode 100644 index 000000000..9e603d023 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsFeedView.kt @@ -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>, + 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt new file mode 100644 index 000000000..8903b1b48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/ListOfBookmarkGroupsScreen.kt @@ -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>, + 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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt new file mode 100644 index 000000000..ae744bdcf --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/NewBookmarkGroupCreationDialog.kt @@ -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(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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt new file mode 100644 index 000000000..7025a70ae --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataScreen.kt @@ -0,0 +1,247 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.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), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt new file mode 100644 index 000000000..9a345e783 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/list/metadata/BookmarkGroupMetadataViewModel.kt @@ -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(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) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt new file mode 100644 index 000000000..82f697fb0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/ArticleBookmarkListManagementScreen.kt @@ -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, + ) + } + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt new file mode 100644 index 000000000..ae084ac4d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/BookmarkGroupManagementItem.kt @@ -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 + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt new file mode 100644 index 000000000..74fbcd494 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/membershipManagement/PostBookmarkListManagementScreen.kt @@ -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, + ) + } + }, + ) + } + } + } + } + } +} diff --git a/amethyst/src/main/res/drawable/post.xml b/amethyst/src/main/res/drawable/post.xml new file mode 100644 index 000000000..e7e168441 --- /dev/null +++ b/amethyst/src/main/res/drawable/post.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 86328a8ec..e09c8d68a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -112,10 +112,12 @@ "Error parsing preview for %1$s : %2$s" "Preview Card Image for %1$s" + Article New Channel Channel Name My Awesome Group Picture Url + Picture Url(Optional) Description Description not found "About us.. " @@ -388,6 +390,39 @@ Remove from Private Bookmarks Remove from Public Bookmarks + Bookmark Lists + Icon for bookmark list + New Bookmark List + Bookmark List Metadata + Clone Bookmark List + Broadcast Bookmark List + Delete Bookmark List + View Posts + View Articles + View Links + View Hashtags + You do not have any bookmark lists yet. Tap the new button below to make one. + Private Posts + Private Posts(%1$s) + Public Posts + Public Posts(%1$s) + Private Articles + Private Articles(%1$s) + Public Articles + Public Articles(%1$s) + Manage %1$s in Bookmark Lists + Bookmark this post + Bookmark this article + is a public bookmark here + is a private bookmark here + is not a bookmark here + Remove bookmark from list + Add bookmark to list + Add as public bookmark + Add as private bookmark + Remove from Bookmark List + Bookmark lists metadata can be seen by anyone on Nostr. Only your private members are encrypted. + Wallet Connect Service Authorizes a Nostr Secret to pay zaps without leaving the app. Keep the secret safe and use a private relay if possible Wallet Connect Pubkey diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt index 9da2f0f3c..8626e4099 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/api/foreground/intents/requests/LoginRequest.kt @@ -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), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt new file mode 100644 index 000000000..73b5684f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -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>, + 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 = tags.mapNotNull(BookmarkIdTag::parse) + + suspend fun privateBookmarks(signer: NostrSigner): List? = 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 = emptyList(), + privateBookmarks: List = 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 = emptyList(), + privateBookmarks: List = emptyList(), + signer: NostrSigner, + dTag: String = Uuid.random().toString(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateBookmarks.map { it.toTagArray() }.toTypedArray(), signer), + createdAt = createdAt, + ) { + dTag(dTag) + alt(ALT) + name(name) + bookmarks(publicBookmarks) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt new file mode 100644 index 000000000..8f42feca5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/TagArrayBuilderExt.kt @@ -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.name(name: String) = addUnique(NameTag.assemble(name)) + +fun TagArrayBuilder.bookmarks(bookmarks: List) = addAll(bookmarks.map { it.toTagArray() }) + +fun TagArrayBuilder.description(listDescription: String) = addUnique(DescriptionTag.assemble(listDescription)) + +fun TagArrayBuilder.image(url: String) = addUnique(ImageTag.assemble(url)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 08ba4bd55..43e304b18 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -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)