From 808030d17682060069da0f7373f941b60a1534e7 Mon Sep 17 00:00:00 2001 From: KotlinGeekDev Date: Fri, 21 Nov 2025 14:20:57 +0100 Subject: [PATCH] First category-based screen: PostListView. Modify NoteCompose to use a different note options menu when in the PostListView. --- .../amethyst/ui/note/NoteCompose.kt | 18 +- .../display/BookmarkGroupOptions.kt | 323 ++++++++++++++++++ .../bookmarkgroups/display/PostListView.kt | 30 +- 3 files changed, 362 insertions(+), 9 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt 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 8442ffeed..30522d87d 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) { @@ -1135,6 +1146,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 @@ -1185,7 +1197,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/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.kt new file mode 100644 index 000000000..2a51815da --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupOptions.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, + onDeleteBookmarkGroup: () -> 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 }, + onDeleteBookmarkGroup = onDeleteBookmarkGroup, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun BookmarkGroupItemOptionsMenu( + note: Note, + onDismiss: () -> Unit, + onDeleteBookmarkGroup: () -> 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("Remove from Bookmark List") }, + onClick = { + onDeleteBookmarkGroup() + 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/PostListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/PostListView.kt index fd7b11fb3..4346fdf24 100644 --- 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 @@ -28,6 +28,7 @@ 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 @@ -41,27 +42,32 @@ fun RenderPostList( bookmarkGroupViewModel: BookmarkGroupViewModel, pagerState: PagerState, accountViewModel: AccountViewModel, + deletePostBookmark: (postId: String, isPrivate: Boolean) -> Unit, nav: INav, modifier: Modifier = Modifier, ) { - val privatePosts = bookmarkGroupViewModel.privatePosts().collectAsStateWithLifecycle() - val publicPosts = bookmarkGroupViewModel.publicPosts().collectAsStateWithLifecycle() + 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.value, - isPrivate = false, + posts = publicPosts, + onDeletePostBookmark = { postId -> + deletePostBookmark(postId, false) + }, accountViewModel = accountViewModel, nav = nav, ) 1 -> PostList( modifier = Modifier.fillMaxSize(), - posts = privatePosts.value, - isPrivate = true, + posts = privatePosts, + onDeletePostBookmark = { postId -> + deletePostBookmark(postId, true) + }, accountViewModel = accountViewModel, nav = nav, ) @@ -73,7 +79,7 @@ fun RenderPostList( private fun PostList( modifier: Modifier = Modifier, posts: List, - isPrivate: Boolean, + onDeletePostBookmark: (postId: String) -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { @@ -88,10 +94,18 @@ private fun PostList( // TODO: Find a way to integrate bookmark group callbacks into the note below NoteCompose( baseNote = item, - modifier = Modifier.animateContentSize(), + modifier = Modifier.animateItem().animateContentSize(), quotesLeft = 3, accountViewModel = accountViewModel, nav = nav, + moreOptions = { + BookmarkGroupItemOptions( + baseNote = item, + onDeleteBookmarkGroup = { onDeletePostBookmark(item.idHex) }, + accountViewModel = accountViewModel, + nav = nav, + ) + }, ) } }