Bug 1882106 - Show undo snackbar when tab is closed via tab strip

fenix/125.0
rahulsainani 4 months ago committed by mergify[bot]
parent 7d42525477
commit 8a2d4cf665

@ -161,6 +161,7 @@ import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.secure import org.mozilla.fenix.ext.secure
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.tabClosedUndoMessage
import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.home.HomeScreenViewModel
import org.mozilla.fenix.home.SharedViewModel import org.mozilla.fenix.home.SharedViewModel
import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel import org.mozilla.fenix.library.bookmarks.BookmarksSharedViewModel
@ -399,23 +400,7 @@ abstract class BaseBrowserFragment :
}, },
onCloseTab = { closedSession -> onCloseTab = { closedSession ->
val closedTab = store.state.findTab(closedSession.id) ?: return@DefaultBrowserToolbarController val closedTab = store.state.findTab(closedSession.id) ?: return@DefaultBrowserToolbarController
showUndoSnackbar(requireContext().tabClosedUndoMessage(closedTab.content.private))
val snackbarMessage = if (closedTab.content.private) {
requireContext().getString(R.string.snackbar_private_tab_closed)
} else {
requireContext().getString(R.string.snackbar_tab_closed)
}
viewLifecycleOwner.lifecycleScope.allowUndo(
binding.dynamicSnackbarContainer,
snackbarMessage,
requireContext().getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
},
paddedForBottomToolbar = true,
operation = { },
)
}, },
) )
val browserToolbarMenuController = DefaultBrowserToolbarMenuController( val browserToolbarMenuController = DefaultBrowserToolbarMenuController(
@ -1017,6 +1002,19 @@ abstract class BaseBrowserFragment :
initializeEngineView(toolbarHeight) initializeEngineView(toolbarHeight)
} }
protected fun showUndoSnackbar(message: String) {
viewLifecycleOwner.lifecycleScope.allowUndo(
binding.dynamicSnackbarContainer,
message,
requireContext().getString(R.string.snackbar_deleted_undo),
{
requireComponents.useCases.tabsUseCases.undo.invoke()
},
paddedForBottomToolbar = true,
operation = { },
)
}
/** /**
* Show a [Snackbar] when data is set to the device clipboard. To avoid duplicate displays of * Show a [Snackbar] when data is set to the device clipboard. To avoid duplicate displays of
* information only show a [Snackbar] for Android 12 and lower. * information only show a [Snackbar] for Android 12 and lower.

@ -53,6 +53,7 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.runIfFragmentIsAttached
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.tabClosedUndoMessage
import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.home.HomeFragment
import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.nimbus.FxNimbus
import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.getCookieBannerUIMode import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.getCookieBannerUIMode
@ -268,12 +269,18 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
), ),
) )
}, },
onLastTabClose = { onLastTabClose = { isPrivate ->
requireComponents.appStore.dispatch(
AppAction.TabStripAction.UpdateLastTabClosed(isPrivate),
)
findNavController().navigate( findNavController().navigate(
BrowserFragmentDirections.actionGlobalHome(), BrowserFragmentDirections.actionGlobalHome(),
) )
}, },
onSelectedTabClick = {}, onSelectedTabClick = {},
onCloseTabClick = { isPrivate ->
showUndoSnackbar(requireContext().tabClosedUndoMessage(isPrivate))
},
) )
} }
} }

@ -75,6 +75,7 @@ private val addTabIconSize = 20.dp
* @param appStore The [AppStore] instance used to observe browsing mode. * @param appStore The [AppStore] instance used to observe browsing mode.
* @param tabsUseCases The [TabsUseCases] instance to perform tab actions. * @param tabsUseCases The [TabsUseCases] instance to perform tab actions.
* @param onAddTabClick Invoked when the add tab button is clicked. * @param onAddTabClick Invoked when the add tab button is clicked.
* @param onCloseTabClick Invoked when a tab is closed.
* @param onLastTabClose Invoked when the last remaining open tab is closed. * @param onLastTabClose Invoked when the last remaining open tab is closed.
* @param onSelectedTabClick Invoked when a tab is selected. * @param onSelectedTabClick Invoked when a tab is selected.
*/ */
@ -85,7 +86,8 @@ fun TabStrip(
appStore: AppStore = components.appStore, appStore: AppStore = components.appStore,
tabsUseCases: TabsUseCases = components.useCases.tabsUseCases, tabsUseCases: TabsUseCases = components.useCases.tabsUseCases,
onAddTabClick: () -> Unit, onAddTabClick: () -> Unit,
onLastTabClose: () -> Unit, onCloseTabClick: (isPrivate: Boolean) -> Unit,
onLastTabClose: (isPrivate: Boolean) -> Unit,
onSelectedTabClick: () -> Unit, onSelectedTabClick: () -> Unit,
) { ) {
val isPrivateMode by appStore.observeAsState(false) { it.mode.isPrivate } val isPrivateMode by appStore.observeAsState(false) { it.mode.isPrivate }
@ -96,11 +98,12 @@ fun TabStrip(
TabStripContent( TabStripContent(
state = state, state = state,
onAddTabClick = onAddTabClick, onAddTabClick = onAddTabClick,
onCloseTabClick = { onCloseTabClick = { id, isPrivate ->
if (state.tabs.size == 1) { if (state.tabs.size == 1) {
onLastTabClose() onLastTabClose(isPrivate)
} }
tabsUseCases.removeTab(it) tabsUseCases.removeTab(id)
onCloseTabClick(isPrivate)
}, },
onSelectedTabClick = { onSelectedTabClick = {
tabsUseCases.selectTab(it) tabsUseCases.selectTab(it)
@ -118,7 +121,7 @@ fun TabStrip(
private fun TabStripContent( private fun TabStripContent(
state: TabStripState, state: TabStripState,
onAddTabClick: () -> Unit, onAddTabClick: () -> Unit,
onCloseTabClick: (id: String) -> Unit, onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit, onSelectedTabClick: (id: String) -> Unit,
onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit, onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit,
) { ) {
@ -153,7 +156,7 @@ private fun TabStripContent(
private fun TabsList( private fun TabsList(
state: TabStripState, state: TabStripState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCloseTabClick: (id: String) -> Unit, onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit, onSelectedTabClick: (id: String) -> Unit,
onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit, onMove: (tabId: String, targetId: String, placeAfter: Boolean) -> Unit,
) { ) {
@ -246,7 +249,7 @@ private fun LazyListState.isItemPartiallyVisible(itemInfo: LazyListItemInfo?) =
private fun TabItem( private fun TabItem(
state: TabStripItem, state: TabStripItem,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onCloseTabClick: (id: String) -> Unit, onCloseTabClick: (id: String, isPrivate: Boolean) -> Unit,
onSelectedTabClick: (id: String) -> Unit, onSelectedTabClick: (id: String) -> Unit,
) { ) {
TabStripCard( TabStripCard(
@ -297,7 +300,7 @@ private fun TabItem(
) )
} }
IconButton(onClick = { onCloseTabClick(state.id) }) { IconButton(onClick = { onCloseTabClick(state.id, state.isPrivate) }) {
Icon( Icon(
painter = painterResource(R.drawable.mozac_ic_cross_20), painter = painterResource(R.drawable.mozac_ic_cross_20),
tint = FirefoxTheme.colors.iconPrimary, tint = FirefoxTheme.colors.iconPrimary,
@ -411,7 +414,7 @@ private fun TabStripContentPreview(tabs: List<TabStripItem>) {
tabs = tabs, tabs = tabs,
), ),
onAddTabClick = {}, onAddTabClick = {},
onCloseTabClick = {}, onCloseTabClick = { _, _ -> },
onSelectedTabClick = {}, onSelectedTabClick = {},
onMove = { _, _, _ -> }, onMove = { _, _, _ -> },
) )
@ -441,6 +444,7 @@ private fun TabStripPreview() {
browserStore.dispatch(TabListAction.AddTabAction(tab)) browserStore.dispatch(TabListAction.AddTabAction(tab))
}, },
onLastTabClose = {}, onLastTabClose = {},
onCloseTabClick = {},
onSelectedTabClick = {}, onSelectedTabClick = {},
) )
} }

@ -267,4 +267,16 @@ sealed class AppAction : Action {
val key: ShoppingState.ProductRecommendationImpressionKey, val key: ShoppingState.ProductRecommendationImpressionKey,
) : ShoppingAction() ) : ShoppingAction()
} }
/**
* [AppAction]s related to the tab strip.
*/
sealed class TabStripAction : AppAction() {
/**
* [TabStripAction] used to update whether the last remaining tab that was closed was private.
* Null means the state should reset and no snackbar should be shown.
*/
data class UpdateLastTabClosed(val private: Boolean?) : TabStripAction()
}
} }

@ -56,6 +56,8 @@ import org.mozilla.fenix.wallpapers.WallpaperState
* @property wallpaperState The [WallpaperState] to display in the [HomeFragment]. * @property wallpaperState The [WallpaperState] to display in the [HomeFragment].
* @property standardSnackbarError A snackbar error message to display. * @property standardSnackbarError A snackbar error message to display.
* @property shoppingState Holds state for shopping feature that's required to live the lifetime of a session. * @property shoppingState Holds state for shopping feature that's required to live the lifetime of a session.
* @property wasLastTabClosedPrivate Whether the last remaining tab that was closed in private mode. This is used to
* display an undo snackbar message relevant to the browsing mode. If null, no snackbar is shown.
*/ */
data class AppState( data class AppState(
val isForeground: Boolean = true, val isForeground: Boolean = true,
@ -81,4 +83,5 @@ data class AppState(
val wallpaperState: WallpaperState = WallpaperState.default, val wallpaperState: WallpaperState = WallpaperState.default,
val standardSnackbarError: StandardSnackbarError? = null, val standardSnackbarError: StandardSnackbarError? = null,
val shoppingState: ShoppingState = ShoppingState(), val shoppingState: ShoppingState = ShoppingState(),
val wasLastTabClosedPrivate: Boolean? = null,
) : State ) : State

@ -239,6 +239,10 @@ internal object AppStoreReducer {
) )
is AppAction.ShoppingAction -> ShoppingStateReducer.reduce(state, action) is AppAction.ShoppingAction -> ShoppingStateReducer.reduce(state, action)
is AppAction.TabStripAction.UpdateLastTabClosed -> state.copy(
wasLastTabClosedPrivate = action.private,
)
} }
} }

@ -17,6 +17,7 @@ import android.view.accessibility.AccessibilityManager
import androidx.annotation.StringRes import androidx.annotation.StringRes
import mozilla.components.support.locale.LocaleManager import mozilla.components.support.locale.LocaleManager
import org.mozilla.fenix.FenixApplication import org.mozilla.fenix.FenixApplication
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.components.metrics.MetricController
import org.mozilla.fenix.settings.advanced.getSelectedLocale import org.mozilla.fenix.settings.advanced.getSelectedLocale
@ -133,3 +134,14 @@ inline fun Context.startExternalActivitySafe(intent: Intent, onActivityNotPresen
*/ */
fun Context.isSystemInDarkTheme(): Boolean = fun Context.isSystemInDarkTheme(): Boolean =
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES
/**
* Returns the message to be shown when a tab is closed based on whether the tab was private or not.
* @param private true if the tab was private, false otherwise.
*/
fun Context.tabClosedUndoMessage(private: Boolean): String =
if (private) {
getString(R.string.snackbar_private_tab_closed)
} else {
getString(R.string.snackbar_tab_closed)
}

@ -103,6 +103,7 @@ import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.scaleToBottomOfView import org.mozilla.fenix.ext.scaleToBottomOfView
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.ext.tabClosedUndoMessage
import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory
import org.mozilla.fenix.home.privatebrowsing.controller.DefaultPrivateBrowsingController import org.mozilla.fenix.home.privatebrowsing.controller.DefaultPrivateBrowsingController
@ -636,6 +637,11 @@ class HomeFragment : Fragment() {
homeViewModel.sessionToDelete = null homeViewModel.sessionToDelete = null
requireComponents.appStore.state.wasLastTabClosedPrivate?.also {
showUndoSnackbar(requireContext().tabClosedUndoMessage(it))
requireComponents.appStore.dispatch(AppAction.TabStripAction.UpdateLastTabClosed(null))
}
tabCounterView?.update(requireComponents.core.store.state) tabCounterView?.update(requireComponents.core.store.state)
if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) { if (bundleArgs.getBoolean(FOCUS_ON_ADDRESS_BAR)) {
@ -707,6 +713,9 @@ class HomeFragment : Fragment() {
(requireActivity() as HomeActivity).openToBrowser(BrowserDirection.FromHome) (requireActivity() as HomeActivity).openToBrowser(BrowserDirection.FromHome)
}, },
onLastTabClose = {}, onLastTabClose = {},
onCloseTabClick = { isPrivate ->
showUndoSnackbar(requireContext().tabClosedUndoMessage(isPrivate))
},
) )
} }
} }
@ -765,18 +774,14 @@ class HomeFragment : Fragment() {
private fun removeTabAndShowSnackbar(sessionId: String) { private fun removeTabAndShowSnackbar(sessionId: String) {
val tab = store.state.findTab(sessionId) ?: return val tab = store.state.findTab(sessionId) ?: return
requireComponents.useCases.tabsUseCases.removeTab(sessionId) requireComponents.useCases.tabsUseCases.removeTab(sessionId)
showUndoSnackbar(requireContext().tabClosedUndoMessage(tab.content.private))
}
val snackbarMessage = if (tab.content.private) { private fun showUndoSnackbar(message: String) {
requireContext().getString(R.string.snackbar_private_tab_closed)
} else {
requireContext().getString(R.string.snackbar_tab_closed)
}
viewLifecycleOwner.lifecycleScope.allowUndo( viewLifecycleOwner.lifecycleScope.allowUndo(
requireView(), requireView(),
snackbarMessage, message,
requireContext().getString(R.string.snackbar_deleted_undo), requireContext().getString(R.string.snackbar_deleted_undo),
{ {
requireComponents.useCases.tabsUseCases.undo.invoke() requireComponents.useCases.tabsUseCases.undo.invoke()
@ -1136,12 +1141,15 @@ class HomeFragment : Fragment() {
} }
companion object { companion object {
// Used to set homeViewModel.sessionToDelete when all tabs of a browsing mode are closed
const val ALL_NORMAL_TABS = "all_normal" const val ALL_NORMAL_TABS = "all_normal"
const val ALL_PRIVATE_TABS = "all_private" const val ALL_PRIVATE_TABS = "all_private"
// Navigation arguments passed to HomeFragment
private const val FOCUS_ON_ADDRESS_BAR = "focusOnAddressBar" private const val FOCUS_ON_ADDRESS_BAR = "focusOnAddressBar"
private const val SCROLL_TO_COLLECTION = "scrollToCollection" private const val SCROLL_TO_COLLECTION = "scrollToCollection"
// Delay for scrolling to the collection header
private const val ANIM_SCROLL_DELAY = 100L private const val ANIM_SCROLL_DELAY = 100L
// Sponsored top sites titles and search engine names used for filtering // Sponsored top sites titles and search engine names used for filtering

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.components.appstate
import mozilla.components.support.test.ext.joinBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
import org.mozilla.fenix.components.AppStore
class TabStripActionTest {
@Test
fun `WHEN the last remaining tab that was closed was private THEN state should reflect that`() {
val store = AppStore(initialState = AppState())
store.dispatch(AppAction.TabStripAction.UpdateLastTabClosed(true)).joinBlocking()
val expected = AppState(wasLastTabClosedPrivate = true)
assertEquals(expected, store.state)
}
@Test
fun `WHEN the last remaining tab that was closed was not private THEN state should reflect that`() {
val store = AppStore(initialState = AppState())
store.dispatch(AppAction.TabStripAction.UpdateLastTabClosed(false)).joinBlocking()
val expected = AppState(wasLastTabClosedPrivate = false)
assertEquals(expected, store.state)
}
}
Loading…
Cancel
Save