Merge pull request #3312 from vitorpamplona/claude/share-options-drawer-70nrq4

Extract share options to reusable bottom sheet
This commit is contained in:
Vitor Pamplona
2026-06-21 10:27:36 -04:00
committed by GitHub
4 changed files with 193 additions and 49 deletions
@@ -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,7 @@ private fun InnerReactionRow(
if (!isPrivateRumor) {
ShareReaction(
note = baseNote,
nav = nav,
grayTint = MaterialTheme.colorScheme.placeholderText,
)
}
@@ -338,38 +339,26 @@ private fun InnerReactionRow(
@Composable
fun ShareReaction(
note: Note,
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,
nav = nav,
onDismiss = { showShareSheet = false },
)
}
}
@Composable
@@ -20,7 +20,6 @@
*/
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
@@ -30,7 +29,6 @@ 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
@@ -47,7 +45,6 @@ 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
@@ -116,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(
@@ -158,7 +172,6 @@ fun NoteDropDownMenu(
onDismiss = onDismiss,
) {
val clipboardManager = LocalClipboard.current
val actContext = LocalContext.current
val scope = rememberCoroutineScope()
// Unsealed rumors (private replies/posts received in gift wraps) are
@@ -189,7 +202,8 @@ 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 {
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
@@ -228,26 +242,7 @@ fun NoteDropDownMenu(
}
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()
showShareSheet = true
}
}
}
@@ -0,0 +1,79 @@
/*
* 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 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(
note: Note,
nav: INav,
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 =
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)) {
nav.nav(Route.ShareNoteAsImageFile(shareId))
onDismiss()
}
M3ActionRow(icon = MaterialSymbols.Image, text = stringRes(R.string.share_as_image_url)) {
nav.nav(Route.ShareNoteAsImage(shareId))
onDismiss()
}
}
@@ -0,0 +1,81 @@
/*
* 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.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.M3ActionSection
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.stringRes
/**
* 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,
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.quick_action_share),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 8.dp),
)
M3ActionSection {
ShareActionRows(
note = note,
nav = nav,
onDismiss = onDismiss,
)
}
}
}
}