From 62ca0863027a410fda915ca4b0c40d32cefa44c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:28:29 +0000 Subject: [PATCH 1/3] feat: open a Share options bottom drawer from the reaction-row Share button Tapping Share in a note's reaction row now opens a bottom drawer with the same Copy & Share options as the 3-dot menu (Copy Text, Copy Author ID, Copy Note ID, Copy raw JSON, Share link, Share as Image, Share as Image URL) instead of jumping straight to the system share sheet. The seven rows are extracted into a shared ShareCopyActionRows composable so the 3-dot menu and the new ShareOptionsBottomSheet stay in sync. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk --- .../amethyst/ui/note/ReactionsRow.kt | 46 +++---- .../amethyst/ui/note/elements/DropDownMenu.kt | 80 ++--------- .../ui/note/elements/ShareCopyActions.kt | 129 ++++++++++++++++++ .../note/elements/ShareOptionsBottomSheet.kt | 91 ++++++++++++ 4 files changed, 250 insertions(+), 96 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 35ddc2ed2d..b489e735c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.ui.note import android.content.Context -import android.content.Intent import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibility @@ -148,6 +147,7 @@ import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorM import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeReplyTo +import com.vitorpamplona.amethyst.ui.note.elements.ShareOptionsBottomSheet import com.vitorpamplona.amethyst.ui.note.types.EditState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.PaymentTargetsDialog @@ -318,6 +318,9 @@ private fun InnerReactionRow( if (!isPrivateRumor) { ShareReaction( note = baseNote, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, grayTint = MaterialTheme.colorScheme.placeholderText, ) } @@ -338,38 +341,33 @@ private fun InnerReactionRow( @Composable fun ShareReaction( note: Note, + editState: State>?, + accountViewModel: AccountViewModel, + nav: INav, grayTint: Color, barChartModifier: Modifier = Size19Modifier, ) { - val context = LocalContext.current + var showShareSheet by remember { mutableStateOf(false) } ClickableBox( modifier = barChartModifier, - onClick = { - val sendIntent = - Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra( - Intent.EXTRA_TEXT, - externalLinkForNote(note), - ) - putExtra( - Intent.EXTRA_TITLE, - stringRes(context, R.string.quick_action_share_browser_link), - ) - } - - val shareIntent = - Intent.createChooser( - sendIntent, - stringRes(context, R.string.quick_action_share), - ) - context.startActivity(shareIntent) - }, + onClick = { showShareSheet = true }, ) { ShareIcon(barChartModifier, grayTint) } + + if (showShareSheet) { + ShareOptionsBottomSheet( + note = note, + // The reaction-row Share button is only rendered for non-private + // notes, so the image/link share rows are always offered here. + isPrivateRumor = false, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = { showShareSheet = false }, + ) + } } @Composable 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 20af25f5f1..558033bb21 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 @@ -20,17 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.note.elements -import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable 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.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -42,19 +38,16 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection -import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon -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.Size24Modifier import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -64,8 +57,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Composable fun MoreOptionsButton( @@ -157,10 +148,6 @@ fun NoteDropDownMenu( title = stringRes(R.string.note_actions_dialog_title), onDismiss = onDismiss, ) { - val clipboardManager = LocalClipboard.current - val actContext = LocalContext.current - val scope = rememberCoroutineScope() - // Unsealed rumors (private replies/posts received in gift wraps) are // unsigned and must never be referenced by a public event: hide every // action that would publish an e-tag of this note (broadcast, edit, @@ -191,65 +178,14 @@ fun NoteDropDownMenu( // Copy & Share section M3ActionSection { - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { - val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - scope.launch { - clipboardManager.setText(it) - } - } - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { - note.author?.let { - scope.launch(Dispatchers.IO) { - clipboardManager.setText("nostr:${it.pubkeyNpub()}") - onDismiss() - } - } - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(note.toNostrUri()) - onDismiss() - } - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) { - val event = note.event - if (event != null) { - scope.launch { - val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) } - clipboardManager.setText(json) - onDismiss() - } - } else { - onDismiss() - } - } - if (!isPrivateRumor) { - M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { - 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)) - actContext.startActivity(shareIntent) - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex - nav.nav(Route.ShareNoteAsImageFile(shareId)) - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex - nav.nav(Route.ShareNoteAsImage(shareId)) - onDismiss() - } - } + ShareCopyActionRows( + note = note, + isPrivateRumor = isPrivateRumor, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = onDismiss, + ) } // Edit & Broadcast section diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt new file mode 100644 index 0000000000..7f4a1ef2e5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt @@ -0,0 +1,129 @@ +/* + * 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.note.elements + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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.stringRes +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * The shared Copy & Share rows used both by the note's 3-dot + * [NoteDropDownMenu] and by the [ShareOptionsBottomSheet] opened from the + * reaction-row Share button. Extracted so both surfaces stay in sync — the + * single source of truth for what "sharing a note" offers. + * + * The Copy rows are always shown; the three Share rows (browser link, image + * file, image URL) are hidden for private rumors because each would publish + * an e-tag of an unsigned note to public relays. + */ +@Composable +fun ShareCopyActionRows( + note: Note, + isPrivateRumor: Boolean, + editState: State>?, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + val clipboardManager = LocalClipboard.current + val actContext = LocalContext.current + val scope = rememberCoroutineScope() + + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { + val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note + accountViewModel.decrypt(lastNoteVersion) { + scope.launch { + clipboardManager.setText(it) + } + } + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { + note.author?.let { + scope.launch(Dispatchers.IO) { + clipboardManager.setText("nostr:${it.pubkeyNpub()}") + onDismiss() + } + } + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(note.toNostrUri()) + onDismiss() + } + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) { + val event = note.event + if (event != null) { + scope.launch { + val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) } + clipboardManager.setText(json) + onDismiss() + } + } else { + onDismiss() + } + } + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + 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)) + actContext.startActivity(shareIntent) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + nav.nav(Route.ShareNoteAsImageFile(shareId)) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + nav.nav(Route.ShareNoteAsImage(shareId)) + onDismiss() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt new file mode 100644 index 0000000000..2b86beeb18 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt @@ -0,0 +1,91 @@ +/* + * 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.note.elements + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.GenericLoadable +import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.types.EditState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Bottom drawer opened from the reaction-row Share button. Surfaces the same + * Copy & Share options as the note's 3-dot menu via the shared + * [ShareCopyActionRows], so both entry points stay in sync. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareOptionsBottomSheet( + note: Note, + isPrivateRumor: Boolean, + editState: State>?, + accountViewModel: AccountViewModel, + nav: INav, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) { + Text( + text = stringRes(R.string.note_actions_dialog_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 8.dp), + ) + M3ActionSection { + ShareCopyActionRows( + note = note, + isPrivateRumor = isPrivateRumor, + editState = editState, + accountViewModel = accountViewModel, + nav = nav, + onDismiss = onDismiss, + ) + } + } + } +} From c06d8eb7f7a1afc81d7dd31659cc3459f6504028 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 16:49:52 +0000 Subject: [PATCH 2/3] refactor: make the Share drawer share-only; 3-dot menu keeps its copy rows Per review, the shared element is now just the three true Share options (browser link, image file, image URL) in ShareActionRows, surfaced by the ShareOptionsBottomSheet drawer. - The 3-dot menu keeps its four copy-to-clipboard rows inline, exactly as before; its share section is now a single "Share" row that opens the drawer instead of doing the share directly. - NoteDropDownMenu owns the drawer state and renders the sheet in place of the menu dialog, so the existing direct callers (MultiSetCompose, MessageSetCompose) need no changes. - The reaction-row Share button opens the same drawer. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk --- .../amethyst/ui/note/ReactionsRow.kt | 9 -- .../amethyst/ui/note/elements/DropDownMenu.kt | 77 +++++++++-- .../amethyst/ui/note/elements/ShareActions.kt | 77 +++++++++++ .../ui/note/elements/ShareCopyActions.kt | 129 ------------------ .../note/elements/ShareOptionsBottomSheet.kt | 20 +-- 5 files changed, 150 insertions(+), 162 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index b489e735c4..9ae6954ce8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -318,8 +318,6 @@ private fun InnerReactionRow( if (!isPrivateRumor) { ShareReaction( note = baseNote, - editState = editState, - accountViewModel = accountViewModel, nav = nav, grayTint = MaterialTheme.colorScheme.placeholderText, ) @@ -341,8 +339,6 @@ private fun InnerReactionRow( @Composable fun ShareReaction( note: Note, - editState: State>?, - accountViewModel: AccountViewModel, nav: INav, grayTint: Color, barChartModifier: Modifier = Size19Modifier, @@ -359,11 +355,6 @@ fun ShareReaction( if (showShareSheet) { ShareOptionsBottomSheet( note = note, - // The reaction-row Share button is only rendered for non-private - // notes, so the image/link share rows are always offered here. - isPrivateRumor = false, - editState = editState, - accountViewModel = accountViewModel, nav = nav, onDismiss = { showShareSheet = false }, ) 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 558033bb21..892b1f72c6 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 @@ -26,7 +26,9 @@ 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.LocalClipboard import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -38,6 +40,7 @@ import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionDialog import com.vitorpamplona.amethyst.ui.components.M3ActionRow import com.vitorpamplona.amethyst.ui.components.M3ActionSection +import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo @@ -48,6 +51,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.report.ReportNoteDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size24Modifier import com.vitorpamplona.quartz.experimental.music.track.MusicTrackEvent +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.tags.aTag.isTaggedAddressableNote import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent @@ -57,6 +61,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun MoreOptionsButton( @@ -107,6 +113,23 @@ fun NoteDropDownMenu( ) { var reportDialogShowing by remember { mutableStateOf(false) } var addLabelDialogShowing by remember { mutableStateOf(false) } + var showShareSheet by remember { mutableStateOf(false) } + + // Tapping "Share" hands the note's share options off to the shared Share + // drawer (ShareOptionsBottomSheet). We render it INSTEAD of the menu dialog + // — not on top of it — so the menu doesn't sit dimmed behind the sheet; + // dismissing the drawer closes the whole menu. + if (showShareSheet) { + ShareOptionsBottomSheet( + note = note, + nav = nav, + onDismiss = { + showShareSheet = false + onDismiss() + }, + ) + return + } val state by observeBookmarksFollowsAndAccount(note, accountViewModel).collectAsStateWithLifecycle( DropDownParams( @@ -148,6 +171,9 @@ fun NoteDropDownMenu( title = stringRes(R.string.note_actions_dialog_title), onDismiss = onDismiss, ) { + val clipboardManager = LocalClipboard.current + val scope = rememberCoroutineScope() + // Unsealed rumors (private replies/posts received in gift wraps) are // unsigned and must never be referenced by a public event: hide every // action that would publish an e-tag of this note (broadcast, edit, @@ -176,16 +202,49 @@ fun NoteDropDownMenu( } } - // Copy & Share section + // Copy & Share section. The copy-to-clipboard rows live here (and only + // here); the "Share" row hands off to the shared Share drawer. M3ActionSection { - ShareCopyActionRows( - note = note, - isPrivateRumor = isPrivateRumor, - editState = editState, - accountViewModel = accountViewModel, - nav = nav, - onDismiss = onDismiss, - ) + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { + val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note + accountViewModel.decrypt(lastNoteVersion) { + scope.launch { + clipboardManager.setText(it) + } + } + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { + note.author?.let { + scope.launch(Dispatchers.IO) { + clipboardManager.setText("nostr:${it.pubkeyNpub()}") + onDismiss() + } + } + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) { + scope.launch(Dispatchers.IO) { + clipboardManager.setText(note.toNostrUri()) + onDismiss() + } + } + M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) { + val event = note.event + if (event != null) { + scope.launch { + val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) } + clipboardManager.setText(json) + onDismiss() + } + } else { + onDismiss() + } + } + if (!isPrivateRumor) { + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + showShareSheet = true + } + } } // Edit & Broadcast section diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt new file mode 100644 index 0000000000..57f3ee9dbd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt @@ -0,0 +1,77 @@ +/* + * 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.note.elements + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.M3ActionRow +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.note.externalLinkForNote +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * The shared Share rows used by the [ShareOptionsBottomSheet] drawer (opened + * both from the reaction-row Share button and from the note's 3-dot menu). + * + * Only the true "send it somewhere" options live here — browser link, image + * file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so + * they are intentionally NOT part of this shared element. + * + * Callers only render this for non-private notes, since each row publishes an + * e-tag of the note (a browser link / an uploaded image referencing it). + */ +@Composable +fun ShareActionRows( + note: Note, + nav: INav, + onDismiss: () -> Unit, +) { + val actContext = LocalContext.current + + M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { + 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)) + actContext.startActivity(shareIntent) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + nav.nav(Route.ShareNoteAsImageFile(shareId)) + onDismiss() + } + M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex + nav.nav(Route.ShareNoteAsImage(shareId)) + onDismiss() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt deleted file mode 100644 index 7f4a1ef2e5..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareCopyActions.kt +++ /dev/null @@ -1,129 +0,0 @@ -/* - * 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.note.elements - -import android.content.Intent -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable -import com.vitorpamplona.amethyst.ui.components.M3ActionRow -import com.vitorpamplona.amethyst.ui.components.util.setText -import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.routes.Route -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.stringRes -import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * The shared Copy & Share rows used both by the note's 3-dot - * [NoteDropDownMenu] and by the [ShareOptionsBottomSheet] opened from the - * reaction-row Share button. Extracted so both surfaces stay in sync — the - * single source of truth for what "sharing a note" offers. - * - * The Copy rows are always shown; the three Share rows (browser link, image - * file, image URL) are hidden for private rumors because each would publish - * an e-tag of an unsigned note to public relays. - */ -@Composable -fun ShareCopyActionRows( - note: Note, - isPrivateRumor: Boolean, - editState: State>?, - accountViewModel: AccountViewModel, - nav: INav, - onDismiss: () -> Unit, -) { - val clipboardManager = LocalClipboard.current - val actContext = LocalContext.current - val scope = rememberCoroutineScope() - - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) { - val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note - accountViewModel.decrypt(lastNoteVersion) { - scope.launch { - clipboardManager.setText(it) - } - } - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) { - note.author?.let { - scope.launch(Dispatchers.IO) { - clipboardManager.setText("nostr:${it.pubkeyNpub()}") - onDismiss() - } - } - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_note_id)) { - scope.launch(Dispatchers.IO) { - clipboardManager.setText(note.toNostrUri()) - onDismiss() - } - } - M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_raw_json)) { - val event = note.event - if (event != null) { - scope.launch { - val json = withContext(Dispatchers.Default) { JacksonMapper.toJsonPretty(event) } - clipboardManager.setText(json) - onDismiss() - } - } else { - onDismiss() - } - } - if (!isPrivateRumor) { - M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { - 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)) - actContext.startActivity(shareIntent) - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex - nav.nav(Route.ShareNoteAsImageFile(shareId)) - onDismiss() - } - M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex - nav.nav(Route.ShareNoteAsImage(shareId)) - onDismiss() - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt index 2b86beeb18..f65c26bde4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareOptionsBottomSheet.kt @@ -29,31 +29,24 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.components.GenericLoadable import com.vitorpamplona.amethyst.ui.components.M3ActionSection import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.types.EditState -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes /** - * Bottom drawer opened from the reaction-row Share button. Surfaces the same - * Copy & Share options as the note's 3-dot menu via the shared - * [ShareCopyActionRows], so both entry points stay in sync. + * Bottom drawer that offers the note's Share options ([ShareActionRows]). + * Opened from the reaction-row Share button and from the note's 3-dot menu, + * so both entry points share the exact same list. */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ShareOptionsBottomSheet( note: Note, - isPrivateRumor: Boolean, - editState: State>?, - accountViewModel: AccountViewModel, nav: INav, onDismiss: () -> Unit, ) { @@ -67,7 +60,7 @@ fun ShareOptionsBottomSheet( .padding(bottom = 12.dp), ) { Text( - text = stringRes(R.string.note_actions_dialog_title), + text = stringRes(R.string.quick_action_share), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -77,11 +70,8 @@ fun ShareOptionsBottomSheet( .padding(horizontal = 20.dp, vertical = 8.dp), ) M3ActionSection { - ShareCopyActionRows( + ShareActionRows( note = note, - isPrivateRumor = isPrivateRumor, - editState = editState, - accountViewModel = accountViewModel, nav = nav, onDismiss = onDismiss, ) From b835819f97411e2f32ed0e351e9ff9d75dd501b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 19:37:00 +0000 Subject: [PATCH 3/3] refactor: de-duplicate shareId and correct ShareActionRows docs Hoist the AddressableNote-vs-id shareId computation out of the two image share rows, and fix the KDoc rationale: "Share as Image" shares a local PNG (no relay write); the rows are hidden on private rumors because they expose the note's content publicly, not because they publish an e-tag. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk --- .../amethyst/ui/note/elements/ShareActions.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt index 57f3ee9dbd..15693b2e7e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/ShareActions.kt @@ -41,8 +41,9 @@ import com.vitorpamplona.amethyst.ui.stringRes * file, image URL. The copy-to-clipboard options stay in the 3-dot menu, so * they are intentionally NOT part of this shared element. * - * Callers only render this for non-private notes, since each row publishes an - * e-tag of the note (a browser link / an uploaded image referencing it). + * Callers only render these for non-private notes: every option exposes the + * note publicly (a shareable web link, or an image of it), which must never + * happen for a private gift-wrapped rumor. */ @Composable fun ShareActionRows( @@ -51,6 +52,9 @@ fun ShareActionRows( onDismiss: () -> Unit, ) { val actContext = LocalContext.current + // AddressableNotes are shared by their replaceable address; everything else + // by event id. The two image routes resolve the note from this same id. + val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex M3ActionRow(icon = MaterialSymbols.Share, text = stringRes(R.string.quick_action_share)) { val sendIntent = @@ -65,12 +69,10 @@ fun ShareActionRows( onDismiss() } M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex nav.nav(Route.ShareNoteAsImageFile(shareId)) onDismiss() } M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) { - val shareId = if (note is AddressableNote) note.address.toValue() else note.idHex nav.nav(Route.ShareNoteAsImage(shareId)) onDismiss() }