diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBar.kt new file mode 100644 index 0000000000..4bf2ae349e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBar.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.signal.core.ui.compose.IconButtons +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.R + +/** + * A consistent ActionMode top-bar for dealing with multiselect scenarios. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ActionModeTopBar( + title: String, + onCloseClick: () -> Unit, + toolbarColor: Color? = null, + windowInsets: WindowInsets = TopAppBarDefaults.windowInsets +) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = toolbarColor ?: MaterialTheme.colorScheme.surface + ), + navigationIcon = { + IconButtons.IconButton(onClick = onCloseClick) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24), + contentDescription = stringResource(R.string.CallScreenTopBar__go_back) + ) + } + }, + title = { + Text(text = title) + }, + windowInsets = windowInsets + ) +} + +@PreviewLightDark +@Composable +fun ActionModeTopBarPreview() { + Previews.Preview { + ActionModeTopBar( + title = "1 selected", + onCloseClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBarView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBarView.kt new file mode 100644 index 0000000000..d3cb86bb81 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ActionModeTopBarView.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose + +import android.content.Context +import android.util.AttributeSet +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.AbstractComposeView +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.util.DynamicTheme + +/** + * A View wrapper for [ActionModeTopBar] so that we can use the same UI element in View and Compose land. + */ +class ActionModeTopBarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : AbstractComposeView(context, attrs, defStyleAttr) { + + var title by mutableStateOf("") + var onCloseClick: () -> Unit by mutableStateOf({}) + + @Composable + override fun Content() { + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(context)) { + Surface( + color = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + ActionModeTopBar( + title = title, + toolbarColor = Color.Transparent, + onCloseClick = onCloseClick, + windowInsets = WindowInsets() + ) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index 83b05dc9d0..4fff0ec4bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -78,12 +78,8 @@ public final class ConversationReactionOverlay extends FrameLayout { private boolean downIsOurs; private int selected = -1; private int customEmojiIndex; - private int originalStatusBarColor; - private int originalNavigationBarColor; private View dropdownAnchor; - private View toolbarShade; - private View inputShade; private View conversationItem; private View backgroundView; private ConstraintLayout foregroundView; @@ -121,8 +117,6 @@ public final class ConversationReactionOverlay extends FrameLayout { super.onFinishInflate(); dropdownAnchor = findViewById(R.id.dropdown_anchor); - toolbarShade = findViewById(R.id.toolbar_shade); - inputShade = findViewById(R.id.input_shade); conversationItem = findViewById(R.id.conversation_item); backgroundView = findViewById(R.id.conversation_reaction_scrubber_background); foregroundView = findViewById(R.id.conversation_reaction_scrubber_foreground); @@ -214,9 +208,6 @@ public final class ConversationReactionOverlay extends FrameLayout { @NonNull ConversationMessage conversationMessage, @NonNull PointF lastSeenDownPoint, boolean isMessageOnLeft) { - updateToolbarShade(); - updateInputShade(); - contextMenu = new ConversationContextMenu(dropdownAnchor, getMenuActionItems(conversationMessage)); conversationItem.setX(selectedConversationModel.getSnapshotMetrics().getSnapshotOffset()); @@ -397,18 +388,6 @@ public final class ConversationReactionOverlay extends FrameLayout { return Math.max(reactionStartingPoint - reactionBarOffset - reactionBarHeight, spaceNeededBetweenTopOfScreenAndTopOfReactionBar); } - private void updateToolbarShade() { - LayoutParams layoutParams = (LayoutParams) toolbarShade.getLayoutParams(); - layoutParams.height = 0; - toolbarShade.setLayoutParams(layoutParams); - } - - private void updateInputShade() { - LayoutParams layoutParams = (LayoutParams) inputShade.getLayoutParams(); - layoutParams.height = 0; - inputShade.setLayoutParams(layoutParams); - } - /** * Returns true when the device is in a configuration where the navigation bar doesn't take up * space at the bottom of the screen. @@ -448,9 +427,6 @@ public final class ConversationReactionOverlay extends FrameLayout { @Override public void onAnimationEnd(Animator animation) { animatorSet.removeListener(this); - toolbarShade.setVisibility(INVISIBLE); - inputShade.setVisibility(INVISIBLE); - if (onHideListener != null) { onHideListener.onHide(); } @@ -872,22 +848,6 @@ public final class ConversationReactionOverlay extends FrameLayout { itemYAnim.setDuration(duration); animators.add(itemYAnim); - if (activity != null) { - ValueAnimator statusBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalStatusBarColor); - statusBarAnim.setDuration(duration); - statusBarAnim.addUpdateListener(animation -> { - WindowUtil.setStatusBarColor(activity.getWindow(), (int) animation.getAnimatedValue()); - }); - animators.add(statusBarAnim); - - ValueAnimator navigationBarAnim = ValueAnimator.ofArgb(activity.getWindow().getStatusBarColor(), originalNavigationBarColor); - navigationBarAnim.setDuration(duration); - navigationBarAnim.addUpdateListener(animation -> { - WindowUtil.setNavigationBarColor(activity, (int) animation.getAnimatedValue()); - }); - animators.add(navigationBarAnim); - } - return animators; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index e19cf842a1..810e48bfa6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -45,8 +45,6 @@ import androidx.activity.OnBackPressedCallback import androidx.activity.result.ActivityResultLauncher import androidx.annotation.MainThread import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityOptionsCompat @@ -135,6 +133,7 @@ import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.SendButton import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog import org.thoughtcrime.securesms.components.emoji.EmojiEventListener import org.thoughtcrime.securesms.components.emoji.MediaKeyboard @@ -535,7 +534,6 @@ class ConversationFragment : private lateinit var optionsMenuCallback: ConversationOptionsMenuCallback private var animationsAllowed = false - private var actionMode: ActionMode? = null private var pinnedShortcutReceiver: BroadcastReceiver? = null private var searchMenuItem: MenuItem? = null @@ -562,10 +560,6 @@ class ConversationFragment : private val motionEventRelay: MotionEventRelay by viewModels(ownerProducer = { requireActivity() }) - private val actionModeCallback by lazy { - ActionModeCallback() - } - private val container: InputAwareConstraintLayout get() = requireView() as InputAwareConstraintLayout @@ -587,6 +581,9 @@ class ConversationFragment : private val searchNav: ConversationSearchBottomBar get() = binding.conversationSearchBottomBar.root + private val actionModeTopBarView: ActionModeTopBarView + get() = binding.actionModeTopBar + private val scheduledMessagesStub: Stub by lazy { Stub(binding.scheduledMessagesStub) } private val reactionDelegate: ConversationReactionDelegate by lazy(LazyThreadSafetyMode.NONE) { @@ -929,13 +926,52 @@ class ConversationFragment : } override fun onFinishForwardAction() { - actionMode?.finish() + finishActionMode() } override fun onDismissForwardSheet() = Unit //endregion + private fun startActionMode() { + actionModeTopBarView.isVisible = true + actionModeTopBarView.onCloseClick = this::finishActionMode + actionModeTopBarView.title = calculateSelectedItemCount() + + searchMenuItem?.collapseActionView() + binding.toolbar.isInvisible = true + if (scheduledMessagesStub.isVisible) { + reShowScheduleMessagesBar = true + scheduledMessagesStub.visibility = View.GONE + } + + setCorrectActionModeMenuVisibility() + binding.conversationItemRecycler.invalidateItemDecorations() + } + + private fun setActionModeTitle(title: String) { + actionModeTopBarView.title = title + } + + private fun isActionModeStarted(): Boolean { + return actionModeTopBarView.isVisible + } + + private fun finishActionMode() { + actionModeTopBarView.isVisible = false + + adapter.clearSelection() + setBottomActionBarVisibility(false) + + binding.toolbar.isInvisible = false + if (reShowScheduleMessagesBar) { + scheduledMessagesStub.visibility = View.VISIBLE + reShowScheduleMessagesBar = false + } + + binding.conversationItemRecycler.invalidateItemDecorations() + } + private fun createGroupSubtitleString(members: List): String { return members.joinToString(", ") { r -> if (r.isSelf) getString(R.string.ConversationTitleView_you) else r.getDisplayName(requireContext()) } } @@ -2164,13 +2200,11 @@ class ConversationFragment : private fun setCorrectActionModeMenuVisibility() { val selectedParts = adapter.selectedItems - if (actionMode != null && selectedParts.isEmpty()) { - actionMode?.finish() + if (isActionModeStarted() && selectedParts.isEmpty()) { + finishActionMode() return } - setBottomActionBarVisibility(true) - val recipient = viewModel.recipientSnapshot ?: return val menuState = MenuState.getMenuState( recipient, @@ -2186,7 +2220,7 @@ class ConversationFragment : ActionItem(R.drawable.symbol_reply_24, resources.getString(R.string.conversation_selection__menu_reply)) { maybeShowSwipeToReplyTooltip() handleReplyToMessage(getSelectedConversationMessage()) - actionMode?.finish() + finishActionMode() } ) } @@ -2195,7 +2229,7 @@ class ConversationFragment : items.add( ActionItem(R.drawable.symbol_edit_24, resources.getString(R.string.conversation_selection__menu_edit)) { handleEditMessage(getSelectedConversationMessage()) - actionMode?.finish() + finishActionMode() } ) } @@ -2212,7 +2246,7 @@ class ConversationFragment : items.add( ActionItem(R.drawable.symbol_save_android_24, getResources().getString(R.string.conversation_selection__menu_save)) { handleSaveAttachment(getSelectedConversationMessage().messageRecord as MmsMessageRecord) - actionMode?.finish() + finishActionMode() } ) } @@ -2221,7 +2255,7 @@ class ConversationFragment : items.add( ActionItem(R.drawable.symbol_copy_android_24, getResources().getString(R.string.conversation_selection__menu_copy)) { handleCopyMessage(selectedParts) - actionMode?.finish() + finishActionMode() } ) } @@ -2230,7 +2264,7 @@ class ConversationFragment : items.add( ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details)) { handleDisplayDetails(getSelectedConversationMessage()) - actionMode?.finish() + finishActionMode() } ) } @@ -2239,12 +2273,15 @@ class ConversationFragment : items.add( ActionItem(R.drawable.symbol_trash_24, getResources().getString(R.string.conversation_selection__menu_delete)) { handleDeleteMessages(selectedParts) - actionMode?.finish() + finishActionMode() } ) } bottomActionBar.setItems(items) + bottomActionBar.doAfterNextLayout { + setBottomActionBarVisibility(true) + } } private fun setBottomActionBarVisibility(isVisible: Boolean) { @@ -2266,12 +2303,13 @@ class ConversationFragment : bottomActionBar.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { override fun onPreDraw(): Boolean { - if (bottomActionBar.height == 0 && bottomActionBar.visible) { + if (bottomActionBar.measuredHeight == 0 && bottomActionBar.visible) { return false } + bottomActionBar.viewTreeObserver.removeOnPreDrawListener(this) - val bottomPadding = bottomActionBar.height + ((bottomActionBar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 18.dp) + val bottomPadding = bottomActionBar.measuredHeight + ((bottomActionBar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin ?: 18.dp) ViewUtil.setPaddingBottom(binding.conversationItemRecycler, bottomPadding) binding.conversationItemRecycler.scrollBy(0, -(bottomPadding - additionalScrollOffset)) animationsAllowed = true @@ -2540,8 +2578,7 @@ class ConversationFragment : private fun handleEnterMultiselect(conversationMessage: ConversationMessage) { val parts = conversationMessage.multiselectCollection.toSet() parts.forEach { adapter.toggleSelection(it) } - binding.conversationItemRecycler.invalidateItemDecorations() - actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback) + startActionMode() } private fun handleViewPaymentDetails(conversationMessage: ConversationMessage) { @@ -2621,7 +2658,7 @@ class ConversationFragment : override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean { val recipient = viewModel.recipientSnapshot ?: return false - return actionMode == null && + return !isActionModeStarted() && MenuState.canReplyToMessage( recipient, MenuState.isActionMessage(conversationMessage.messageRecord), @@ -2833,7 +2870,7 @@ class ConversationFragment : } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { - if (actionMode == null) { + if (!isActionModeStarted()) { return } @@ -2845,9 +2882,9 @@ class ConversationFragment : adapter.removeFromSelection(expired) if (adapter.selectedItems.isEmpty()) { - actionMode?.finish() + finishActionMode() } else { - actionMode?.setTitle(calculateSelectedItemCount()) + setActionModeTitle(calculateSelectedItemCount()) } } @@ -3311,22 +3348,22 @@ class ConversationFragment : } override fun onItemClick(item: MultiselectPart) { - if (actionMode != null) { + if (isActionModeStarted()) { adapter.toggleSelection(item) binding.conversationItemRecycler.invalidateItemDecorations() if (adapter.selectedItems.isEmpty()) { - actionMode?.finish() + finishActionMode() } else { setCorrectActionModeMenuVisibility() - actionMode?.title = calculateSelectedItemCount() + setActionModeTitle(calculateSelectedItemCount()) } } } override fun onItemLongClick(itemView: View, item: MultiselectPart) { Log.d(TAG, "onItemLongClick") - if (actionMode != null) { + if (isActionModeStarted()) { return } @@ -3466,9 +3503,7 @@ class ConversationFragment : } else { clearFocusedItem() adapter.toggleSelection(item) - binding.conversationItemRecycler.invalidateItemDecorations() - - actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(actionModeCallback) + startActionMode() } } @@ -3838,40 +3873,6 @@ class ConversationFragment : } } } - - inner class ActionModeCallback : ActionMode.Callback { - override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.title = calculateSelectedItemCount() - - searchMenuItem?.collapseActionView() - binding.toolbar.isInvisible = true - if (scheduledMessagesStub.isVisible) { - reShowScheduleMessagesBar = true - scheduledMessagesStub.visibility = View.GONE - } - - setCorrectActionModeMenuVisibility() - return true - } - - override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean = false - - override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = false - - override fun onDestroyActionMode(mode: ActionMode) { - adapter.clearSelection() - setBottomActionBarVisibility(false) - - binding.toolbar.isInvisible = false - if (reShowScheduleMessagesBar) { - scheduledMessagesStub.visibility = View.VISIBLE - reShowScheduleMessagesBar = false - } - - binding.conversationItemRecycler.invalidateItemDecorations() - actionMode = null - } - } // endregion Conversation Callbacks //region Activity Results Callbacks diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index de2413310f..ee2a91962e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.contentDescription @@ -76,9 +77,9 @@ import org.signal.core.ui.compose.circularReveal import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.avatar.AvatarImage import org.thoughtcrime.securesms.calls.log.CallLogFilter +import org.thoughtcrime.securesms.components.compose.ActionModeTopBar import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter -import org.thoughtcrime.securesms.dependencies.GooglePlayBillingDependencies.context import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.rememberRecipientField @@ -226,23 +227,10 @@ private fun ActionModeToolbar( state: MainToolbarState, callback: MainToolbarCallback ) { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors( - containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface - ), - navigationIcon = { - IconButtons.IconButton(onClick = { - callback.onCloseActionModeClick() - }) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24), - contentDescription = stringResource(R.string.CallScreenTopBar__go_back) - ) - } - }, - title = { - Text(text = context.resources.getQuantityString(R.plurals.ConversationListFragment_s_selected, state.actionModeCount, state.actionModeCount)) - } + ActionModeTopBar( + title = pluralStringResource(R.plurals.ConversationListFragment_s_selected, state.actionModeCount, state.actionModeCount), + onCloseClick = callback::onCloseActionModeClick, + toolbarColor = state.toolbarColor ) } diff --git a/app/src/main/res/layout/conversation_reaction_scrubber.xml b/app/src/main/res/layout/conversation_reaction_scrubber.xml index 375dade0df..5dbdc0accc 100644 --- a/app/src/main/res/layout/conversation_reaction_scrubber.xml +++ b/app/src/main/res/layout/conversation_reaction_scrubber.xml @@ -21,21 +21,6 @@ android:layout_gravity="left" tools:ignore="RtlHardcoded" /> - - - - + +