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, + ) + } + } + } +}