diff --git a/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt b/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt new file mode 100644 index 0000000000..cdc9fc7554 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/AbstractBinding.kt @@ -0,0 +1,43 @@ +/* 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 + +import androidx.annotation.CallSuper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store +import mozilla.components.lib.state.ext.flowScoped +import mozilla.components.support.base.feature.LifecycleAwareFeature + +/** + * Helper class for creating small binding classes that are responsible for reacting to state + * changes. + * + * Taken with ♥️ from Focus. + */ +abstract class AbstractBinding( + private val store: Store +) : LifecycleAwareFeature { + private var scope: CoroutineScope? = null + + @OptIn(ExperimentalCoroutinesApi::class) + @CallSuper + override fun start() { + scope = store.flowScoped { flow -> + onState(flow) + } + } + + @CallSuper + override fun stop() { + scope?.cancel() + } + + abstract suspend fun onState(flow: Flow) +} diff --git a/app/src/main/java/org/mozilla/fenix/components/Components.kt b/app/src/main/java/org/mozilla/fenix/components/Components.kt index 9990d32fb8..1a0a48f360 100644 --- a/app/src/main/java/org/mozilla/fenix/components/Components.kt +++ b/app/src/main/java/org/mozilla/fenix/components/Components.kt @@ -70,7 +70,8 @@ class Components(private val context: Context) { core.sessionManager, core.store, core.webAppShortcutManager, - core.topSitesStorage + core.topSitesStorage, + core.bookmarksStorage ) } diff --git a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt index 9249c8ab8f..bd9a4fb211 100644 --- a/app/src/main/java/org/mozilla/fenix/components/UseCases.kt +++ b/app/src/main/java/org/mozilla/fenix/components/UseCases.kt @@ -8,6 +8,7 @@ import android.content.Context import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.Engine +import mozilla.components.concept.storage.BookmarksStorage import mozilla.components.feature.app.links.AppLinksUseCases import mozilla.components.feature.contextmenu.ContextMenuUseCases import mozilla.components.feature.downloads.DownloadsUseCases @@ -23,6 +24,7 @@ import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.feature.top.sites.TopSitesStorage import mozilla.components.feature.top.sites.TopSitesUseCases import mozilla.components.support.locale.LocaleUseCases +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.perf.lazyMonitored import org.mozilla.fenix.utils.Mockable @@ -38,7 +40,8 @@ class UseCases( private val sessionManager: SessionManager, private val store: BrowserStore, private val shortcutManager: WebAppShortcutManager, - private val topSitesStorage: TopSitesStorage + private val topSitesStorage: TopSitesStorage, + private val bookmarksStorage: BookmarksStorage ) { /** * Use cases that provide engine interactions for a given browser session. @@ -94,4 +97,9 @@ class UseCases( * Use cases that handle locale management. */ val localeUseCases by lazyMonitored { LocaleUseCases(store) } + + /** + * Use cases that provide bookmark management. + */ + val bookmarksUseCases by lazyMonitored { BookmarksUseCase(bookmarksStorage) } } diff --git a/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt b/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt new file mode 100644 index 0000000000..e381312fa2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCase.kt @@ -0,0 +1,42 @@ +/* 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.bookmarks + +import androidx.annotation.WorkerThread +import mozilla.appservices.places.BookmarkRoot +import mozilla.components.concept.storage.BookmarksStorage + +/** + * Use cases that allow for modifying bookmarks. + */ +class BookmarksUseCase(storage: BookmarksStorage) { + + class AddBookmarksUseCase internal constructor(private val storage: BookmarksStorage) { + + /** + * Adds a new bookmark with the provided [url] and [title]. + * + * @return The result if the operation was executed or not. A bookmark may not be added if + * one with the identical [url] already exists. + */ + @WorkerThread + suspend operator fun invoke(url: String, title: String, position: Int? = null): Boolean { + val canAdd = storage.getBookmarksWithUrl(url).firstOrNull { it.url == it.url } == null + + if (canAdd) { + storage.addItem( + BookmarkRoot.Mobile.id, + url = url, + title = title, + position = position + ) + } + + return canAdd + } + } + + val addBookmark by lazy { AddBookmarksUseCase(storage) } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt b/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt index 42d55680b4..db8139f5da 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/MenuIntegration.kt @@ -5,15 +5,11 @@ package org.mozilla.fenix.tabstray import android.content.Context -import android.view.View import androidx.annotation.VisibleForTesting -import androidx.cardview.widget.CardView -import androidx.core.content.ContextCompat import com.google.android.material.tabs.TabLayout -import mozilla.components.browser.menu.BrowserMenu import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.state.store.BrowserStore -import org.mozilla.fenix.R +import org.mozilla.fenix.utils.Do /** * A wrapper class that building the tabs tray menu that handles item clicks. @@ -43,31 +39,19 @@ class MenuIntegration( fun build() = tabsTrayItemMenu.menuBuilder.build(context) @VisibleForTesting - internal fun handleMenuClicked(item: TabsTrayMenu.Item) = when (item) { - is TabsTrayMenu.Item.ShareAllTabs -> - navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode) - is TabsTrayMenu.Item.OpenTabSettings -> - navigationInteractor.onTabSettingsClicked() - is TabsTrayMenu.Item.CloseAllTabs -> - navigationInteractor.onCloseAllTabsClicked(isPrivateMode) - is TabsTrayMenu.Item.OpenRecentlyClosed -> - navigationInteractor.onOpenRecentlyClosedClicked() - is TabsTrayMenu.Item.SelectTabs -> { - /* TODO implement when mulitiselect call is available */ + internal fun handleMenuClicked(item: TabsTrayMenu.Item) { + Do exhaustive when (item) { + is TabsTrayMenu.Item.ShareAllTabs -> + navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode) + is TabsTrayMenu.Item.OpenTabSettings -> + navigationInteractor.onTabSettingsClicked() + is TabsTrayMenu.Item.CloseAllTabs -> + navigationInteractor.onCloseAllTabsClicked(isPrivateMode) + is TabsTrayMenu.Item.OpenRecentlyClosed -> + navigationInteractor.onOpenRecentlyClosedClicked() + is TabsTrayMenu.Item.SelectTabs -> { + tabsTrayStore.dispatch(TabsTrayAction.EnterSelectMode) + } } } } - -/** - * Invokes [BrowserMenu.show] and applies the default theme color background. - */ -fun BrowserMenu.showWithTheme(view: View) { - show(view).also { popupMenu -> - (popupMenu.contentView as? CardView)?.setCardBackgroundColor( - ContextCompat.getColor( - view.context, - R.color.foundation_normal_theme - ) - ) - } -} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt index 80efccbcb6..543c868e9d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt @@ -5,10 +5,15 @@ package org.mozilla.fenix.tabstray import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.concept.tabstray.Tab +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.home.HomeFragment @@ -24,35 +29,53 @@ interface NavigationInteractor { fun onTabTrayDismissed() /** - * Called when user clicks the share tabs button. + * Called when sharing a list of [Tab]s. + */ + fun onShareTabs(tabs: Collection) + + /** + * Called when clicking the share tabs button. */ fun onShareTabsOfTypeClicked(private: Boolean) /** - * Called when user clicks the tab settings button. + * Called when clicking the tab settings button. */ fun onTabSettingsClicked() /** - * Called when user clicks the close all tabs button. + * Called when clicking the close all tabs button. */ fun onCloseAllTabsClicked(private: Boolean) /** - * Called when user clicks the recently closed tabs menu button. + * Called when opening the recently closed tabs menu button. */ fun onOpenRecentlyClosedClicked() + + /** + * Used when opening the add-to-collections user flow. + */ + fun onSaveToCollections(tabs: Collection) + + /** + * Used when adding [Tab]s as bookmarks. + */ + fun onSaveToBookmarks(tabs: Collection) } /** * A default implementation of [NavigationInteractor]. */ +@Suppress("LongParameterList") class DefaultNavigationInteractor( + private val tabsTrayStore: TabsTrayStore, private val browserStore: BrowserStore, private val navController: NavController, private val metrics: MetricController, private val dismissTabTray: () -> Unit, - private val dismissTabTrayAndNavigateHome: (String) -> Unit + private val dismissTabTrayAndNavigateHome: (String) -> Unit, + private val bookmarksUseCase: BookmarksUseCase ) : NavigationInteractor { override fun onTabTrayDismissed() { @@ -68,6 +91,16 @@ class DefaultNavigationInteractor( metrics.track(Event.RecentlyClosedTabsOpened) } + override fun onShareTabs(tabs: Collection) { + val data = tabs.map { + ShareData(url = it.url, title = it.title) + } + val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( + data = data.toTypedArray() + ) + navController.navigate(directions) + } + override fun onShareTabsOfTypeClicked(private: Boolean) { val tabs = browserStore.state.getNormalOrPrivateTabs(private) val data = tabs.map { @@ -89,4 +122,24 @@ class DefaultNavigationInteractor( dismissTabTrayAndNavigateHome(sessionsToClose) } + + override fun onSaveToCollections(tabs: Collection) { + metrics.track(Event.TabsTraySaveToCollectionPressed) + + // TODO add this is a separate PR; it's quite a large change. + } + + override fun onSaveToBookmarks(tabs: Collection) { + tabs.forEach { tab -> + // We don't combine the context with lifecycleScope so that our jobs are not cancelled + // if we leave the fragment, i.e. we still want the bookmarks to be added. + CoroutineScope(Dispatchers.IO).launch { + bookmarksUseCase.addBookmark(tab.url, tab.title) + } + } + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + + // TODO show successful snackbar here (regardless of operation succes). + } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index 9e20a0b5a6..cced95358d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -15,13 +15,18 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.android.synthetic.main.component_tabstray.view.* import kotlinx.android.synthetic.main.component_tabstray2.* import kotlinx.android.synthetic.main.component_tabstray2.view.* +import kotlinx.android.synthetic.main.component_tabstray2.view.tab_tray_overflow +import kotlinx.android.synthetic.main.component_tabstray2.view.tab_wrapper import kotlinx.android.synthetic.main.component_tabstray_fab.* import kotlinx.android.synthetic.main.tabs_tray_tab_counter2.* +import kotlinx.android.synthetic.main.tabstray_multiselect_items.* +import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.plus +import mozilla.components.concept.tabstray.Tab import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.NavGraphDirections @@ -32,8 +37,13 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.home.HomeScreenViewModel import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.DefaultBrowserTrayInteractor +import org.mozilla.fenix.tabstray.browser.SelectionHandleBinding +import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding +import org.mozilla.fenix.tabstray.browser.SelectionBannerBinding.VisibilityModifier +import org.mozilla.fenix.tabstray.ext.showWithTheme import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsInteractor +@Suppress("TooManyFunctions") class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private var fabView: View? = null @@ -45,6 +55,8 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { private val tabLayoutMediator = ViewBoundFeatureWrapper() private val tabCounterBinding = ViewBoundFeatureWrapper() private val floatingActionButtonBinding = ViewBoundFeatureWrapper() + private val selectionBannerBinding = ViewBoundFeatureWrapper() + private val selectionHandleBinding = ViewBoundFeatureWrapper() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,7 +85,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { return containerView } - @ExperimentalCoroutinesApi + @Suppress("LongMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val activity = activity as HomeActivity @@ -100,11 +112,13 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val navigationInteractor = DefaultNavigationInteractor( + tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, navController = findNavController(), metrics = requireComponents.analytics.metrics, dismissTabTray = ::dismissAllowingStateLoss, - dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome + dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, + bookmarksUseCase = requireComponents.useCases.bookmarksUseCases ) val syncedTabsTrayInteractor = SyncedTabsInteractor( @@ -152,6 +166,41 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { owner = this, view = view ) + + selectionBannerBinding.set( + feature = SelectionBannerBinding( + context = requireContext(), + store = tabsTrayStore, + navInteractor = navigationInteractor, + tabsTrayInteractor = this, + containerView = view, + backgroundView = topBar, + showOnSelectViews = VisibilityModifier( + collect_multi_select, + share_multi_select, + menu_multi_select, + multiselect_title, + exit_multi_select + ), + showOnNormalViews = VisibilityModifier( + tab_layout, + tab_tray_overflow, + new_tab_button + ) + ), + owner = this, + view = view + ) + + selectionHandleBinding.set( + feature = SelectionHandleBinding( + store = tabsTrayStore, + handle = handle, + containerLayout = tab_wrapper + ), + owner = this, + view = view + ) } override fun setCurrentTrayPosition(position: Int, smoothScroll: Boolean) { @@ -172,7 +221,7 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { } } - override fun tabRemoved(tabId: String) { + override fun onDeleteTab(tabId: String) { // TODO re-implement these methods // showUndoSnackbarForTab(sessionId) // removeIfNotLastTab(sessionId) @@ -181,6 +230,12 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { requireComponents.useCases.tabsUseCases.removeTab(tabId) } + override fun onDeleteTabs(tabs: Collection) { + tabs.forEach { + onDeleteTab(it.id) + } + } + private fun setupPager( context: Context, store: TabsTrayStore, diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt index 9fd950a6b9..5c3a7371a5 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayInteractor.kt @@ -4,6 +4,8 @@ package org.mozilla.fenix.tabstray +import mozilla.components.concept.tabstray.Tab + interface TabsTrayInteractor { /** * Set the current tray item to the clamped [position]. @@ -21,5 +23,10 @@ interface TabsTrayInteractor { /** * Invoked when a tab is removed from the tabs tray with the given [tabId]. */ - fun tabRemoved(tabId: String) + fun onDeleteTab(tabId: String) + + /** + * Invoked when [Tab]s need to be deleted. + */ + fun onDeleteTabs(tabs: Collection) } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt index 03f70a11ef..1bfcee8615 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BaseBrowserTrayList.kt @@ -48,7 +48,7 @@ abstract class BaseBrowserTrayList @JvmOverloads constructor( val removeTabUseCase = RemoveTabUseCaseWrapper( context.components.analytics.metrics ) { sessionId -> - interactor.tabRemoved(sessionId) + interactor.onDeleteTab(sessionId) } TabsFeature( diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt index 5a85b77b57..49cb4784d1 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/BrowserTrayInteractor.kt @@ -64,7 +64,7 @@ class DefaultBrowserTrayInteractor( private val removeTabWrapper by lazy { RemoveTabUseCaseWrapper(metrics) { // Handle removal from the interactor where we can also handle "undo" visuals. - trayInteractor.tabRemoved(it) + trayInteractor.onDeleteTab(it) } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt new file mode 100644 index 0000000000..8850049a4b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionBannerBinding.kt @@ -0,0 +1,141 @@ +/* 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.tabstray.browser + +import android.content.Context +import android.view.View +import androidx.annotation.VisibleForTesting +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import kotlinx.android.synthetic.main.component_tabstray2.view.exit_multi_select +import kotlinx.android.synthetic.main.component_tabstray2.view.multiselect_title +import kotlinx.android.synthetic.main.tabstray_multiselect_items.view.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.components.AbstractBinding +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.tabstray.TabsTrayAction.ExitSelectMode +import org.mozilla.fenix.tabstray.TabsTrayState.Mode +import org.mozilla.fenix.tabstray.TabsTrayState.Mode.Select +import org.mozilla.fenix.tabstray.ext.showWithTheme + +/** + * A binding that shows/hides the multi-select banner of the selected count of tabs. + * + * @property context An Android context. + * @property store The TabsTrayStore instance. + * @property navInteractor An instance of [NavigationInteractor] for navigating on menu clicks. + * @property tabsTrayInteractor An instance of [TabsTrayInteractor] for handling deletion. + * @property containerView The view in the layout that contains all the implicit multi-select + * views. NB: This parameter is a bit opaque and requires a larger layout refactor to correct. + * @property backgroundView The background view that we want to alter when changing [Mode]. + * @property showOnSelectViews A variable list of views that will be made visible when in select mode. + * @property showOnNormalViews A variable list of views that will be made visible when in normal mode. + */ +@Suppress("LongParameterList") +class SelectionBannerBinding( + private val context: Context, + private val store: TabsTrayStore, + private val navInteractor: NavigationInteractor, + private val tabsTrayInteractor: TabsTrayInteractor, + private val containerView: View, + private val backgroundView: View, + private val showOnSelectViews: VisibilityModifier, + private val showOnNormalViews: VisibilityModifier +) : AbstractBinding(store) { + + /** + * A holder of views that will be used by having their [View.setVisibility] modified. + */ + class VisibilityModifier(vararg val views: View) + + private var isPreviousModeSelect = false + + override fun start() { + super.start() + + initListeners(containerView) + } + + override suspend fun onState(flow: Flow) { + flow.map { it.mode } + // ignore initial mode update; we never start in select mode. + .drop(1) + .ifChanged() + .collect { mode -> + val isSelectMode = mode is Select + + showOnSelectViews.views.forEach { + it.isVisible = isSelectMode + } + + showOnNormalViews.views.forEach { + it.isVisible = isSelectMode.not() + } + + updateBackgroundColor(isSelectMode) + + updateSelectTitle(isSelectMode, mode.selectedTabs.size) + + isPreviousModeSelect = isSelectMode + } + } + + private fun initListeners(containerView: View) { + containerView.share_multi_select.setOnClickListener { + navInteractor.onShareTabs(store.state.mode.selectedTabs) + } + + containerView.collect_multi_select.setOnClickListener { + navInteractor.onSaveToCollections(store.state.mode.selectedTabs) + } + + containerView.exit_multi_select.setOnClickListener { + store.dispatch(ExitSelectMode) + } + + containerView.menu_multi_select.setOnClickListener { anchor -> + val menu = SelectionMenuIntegration( + context, + store, + navInteractor, + tabsTrayInteractor + ).build() + + menu.showWithTheme(anchor) + } + } + + @VisibleForTesting + private fun updateBackgroundColor(isSelectMode: Boolean) { + // memoize to avoid setting the background unnecessarily. + if (isPreviousModeSelect != isSelectMode) { + val colorResource = if (isSelectMode) { + R.color.accent_normal_theme + } else { + R.color.foundation_normal_theme + } + + val color = ContextCompat.getColor(backgroundView.context, colorResource) + + backgroundView.setBackgroundColor(color) + } + } + + @VisibleForTesting + private fun updateSelectTitle(selectedMode: Boolean, tabCount: Int) { + if (selectedMode) { + containerView.multiselect_title.text = + context.getString(R.string.tab_tray_multi_select_title, tabCount) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt new file mode 100644 index 0000000000..6560b8d934 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionHandleBinding.kt @@ -0,0 +1,108 @@ +/* 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.tabstray.browser + +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.content.ContextCompat +import androidx.core.view.updateLayoutParams +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged +import org.mozilla.fenix.R +import org.mozilla.fenix.components.AbstractBinding +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.TabsTrayState.Mode +import org.mozilla.fenix.tabstray.TabsTrayStore + +private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F + +/** + * Various layout updates that need to be applied to the "handle" view when switching + * between [Mode]. + * + * @param store The TabsTrayStore instance. + * @property handle The "handle" of the Tabs Tray that is used to drag the tray open/close. + * @property containerLayout The [ConstraintLayout] that contains the "handle". + */ +class SelectionHandleBinding( + store: TabsTrayStore, + private val handle: View, + private val containerLayout: ConstraintLayout +) : AbstractBinding(store) { + + private var isPreviousModeSelect = false + + override suspend fun onState(flow: Flow) { + flow.map { it.mode } + // ignore initial mode update; we never start in select mode. + .drop(1) + .ifChanged() + .collect { mode -> + val isSelectMode = mode is Mode.Select + + // memoize to avoid unnecessary layout updates. + if (isPreviousModeSelect != isSelectMode) { + updateLayoutParams(handle, isSelectMode) + + updateBackgroundColor(handle, isSelectMode) + + updateWidthPercent(containerLayout, handle, isSelectMode) + } + + isPreviousModeSelect = isSelectMode + } + } + + private fun updateLayoutParams(handle: View, multiselect: Boolean) { + handle.updateLayoutParams { + height = handle.resources.getDimensionPixelSize( + if (multiselect) { + R.dimen.tab_tray_multiselect_handle_height + } else { + R.dimen.bottom_sheet_handle_height + } + ) + topMargin = handle.resources.getDimensionPixelSize( + if (multiselect) { + R.dimen.tab_tray_multiselect_handle_top_margin + } else { + R.dimen.bottom_sheet_handle_top_margin + } + ) + } + } + + private fun updateBackgroundColor(handle: View, multiselect: Boolean) { + val colorResource = if (multiselect) { + R.color.accent_normal_theme + } else { + R.color.secondary_text_normal_theme + } + + val color = ContextCompat.getColor(handle.context, colorResource) + + handle.setBackgroundColor(color) + } + + private fun updateWidthPercent( + container: ConstraintLayout, + handle: View, + multiselect: Boolean + ) { + val widthPercent = if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH + container.run { + ConstraintSet().apply { + clone(this@run) + constrainPercentWidth(handle.id, widthPercent) + applyTo(this@run) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt new file mode 100644 index 0000000000..1a789acb67 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenu.kt @@ -0,0 +1,40 @@ +/* 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.tabstray.browser + +import android.content.Context +import mozilla.components.browser.menu.BrowserMenuBuilder +import mozilla.components.browser.menu.item.SimpleBrowserMenuItem +import org.mozilla.fenix.R + +class SelectionMenu( + private val context: Context, + private val onItemTapped: (Item) -> Unit = {} +) { + sealed class Item { + object BookmarkTabs : Item() + object DeleteTabs : Item() + } + + val menuBuilder by lazy { BrowserMenuBuilder(menuItems) } + + private val menuItems by lazy { + listOf( + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_multiselect_menu_item_bookmark), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.BookmarkTabs) + }, + + SimpleBrowserMenuItem( + context.getString(R.string.tab_tray_multiselect_menu_item_close), + textColorResource = R.color.primary_text_normal_theme + ) { + onItemTapped.invoke(Item.DeleteTabs) + } + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt new file mode 100644 index 0000000000..a865ebae77 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegration.kt @@ -0,0 +1,41 @@ +/* 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.tabstray.browser + +import android.content.Context +import androidx.annotation.VisibleForTesting +import mozilla.components.browser.menu.BrowserMenuBuilder +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayStore +import org.mozilla.fenix.utils.Do + +class SelectionMenuIntegration( + private val context: Context, + private val store: TabsTrayStore, + private val navInteractor: NavigationInteractor, + private val trayInteractor: TabsTrayInteractor +) { + private val menu by lazy { + SelectionMenu(context, ::handleMenuClicked) + } + + /** + * Builds the internal menu items list. See [BrowserMenuBuilder.build]. + */ + fun build() = menu.menuBuilder.build(context) + + @VisibleForTesting + internal fun handleMenuClicked(item: SelectionMenu.Item) { + Do exhaustive when (item) { + is SelectionMenu.Item.BookmarkTabs -> navInteractor.onSaveToBookmarks( + store.state.mode.selectedTabs + ) + is SelectionMenu.Item.DeleteTabs -> trayInteractor.onDeleteTabs( + store.state.mode.selectedTabs + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt new file mode 100644 index 0000000000..ec98a59ec2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserMenu.kt @@ -0,0 +1,25 @@ +/* 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.tabstray.ext + +import android.view.View +import androidx.cardview.widget.CardView +import androidx.core.content.ContextCompat +import mozilla.components.browser.menu.BrowserMenu +import org.mozilla.fenix.R + +/** + * Invokes [BrowserMenu.show] and applies the default theme color background. + */ +fun BrowserMenu.showWithTheme(view: View) { + show(view).also { popupMenu -> + (popupMenu.contentView as? CardView)?.setCardBackgroundColor( + ContextCompat.getColor( + view.context, + R.color.foundation_normal_theme + ) + ) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt b/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt new file mode 100644 index 0000000000..55177b86ab --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/AbstractBindingTest.kt @@ -0,0 +1,71 @@ +/* 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 + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.support.test.libstate.ext.waitUntilIdle +import mozilla.components.support.test.rule.MainCoroutineRule +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class AbstractBindingTest { + + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + + @Test + fun `WHEN started THEN onState flow is invoked`() { + val store = BrowserStore() + var invoked = false + val binding = TestBinding(store) { + invoked = true + } + + binding.start() + + store.waitUntilIdle() + + assertTrue(invoked) + } + + @Test + fun `WHEN actions are dispatched THEN onState flow is invoked`() { + val store = BrowserStore() + var invoked = false + val binding = TestBinding(store) { + if (store.state.tabs.isNotEmpty()) { + invoked = true + } + } + + binding.start() + + store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org"))) + + store.waitUntilIdle() + + assertTrue(invoked) + } + + class TestBinding( + store: BrowserStore, + private val invoked: (BrowserState) -> Unit + ) : AbstractBinding(store) { + override suspend fun onState(flow: Flow) { + flow.collect { + invoked(it) + } + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt b/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt index dfc88b0e96..3aa8d41dcc 100644 --- a/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt +++ b/app/src/test/java/org/mozilla/fenix/components/TestComponents.kt @@ -24,7 +24,8 @@ class TestComponents(private val context: Context) : Components(context) { core.sessionManager, core.store, core.webAppShortcutManager, - core.topSitesStorage + core.topSitesStorage, + core.bookmarksStorage ) } override val intentProcessors by lazy { mockk(relaxed = true) } diff --git a/app/src/test/java/org/mozilla/fenix/components/TestCore.kt b/app/src/test/java/org/mozilla/fenix/components/TestCore.kt index 70dccaebd4..d5161017e6 100644 --- a/app/src/test/java/org/mozilla/fenix/components/TestCore.kt +++ b/app/src/test/java/org/mozilla/fenix/components/TestCore.kt @@ -9,6 +9,7 @@ import io.mockk.every import io.mockk.mockk import mozilla.components.browser.session.SessionManager import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.PlacesBookmarksStorage import mozilla.components.browser.thumbnails.storage.ThumbnailStorage import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.concept.engine.Engine @@ -32,4 +33,5 @@ class TestCore(context: Context, crashReporter: CrashReporting) : Core( override val webAppShortcutManager = mockk() override val thumbnailStorage = mockk() override val topSitesStorage = mockk() + override val bookmarksStorage = mockk() } diff --git a/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt b/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt new file mode 100644 index 0000000000..57619a395d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/components/bookmarks/BookmarksUseCaseTest.kt @@ -0,0 +1,50 @@ +/* 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.bookmarks + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runBlockingTest +import mozilla.appservices.places.BookmarkRoot +import mozilla.components.concept.storage.BookmarkNode +import mozilla.components.concept.storage.BookmarksStorage +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +@ExperimentalCoroutinesApi +class BookmarksUseCaseTest { + + @Test + fun `WHEN adding existing bookmark THEN no new item is stored`() = runBlockingTest { + val storage = mockk() + val bookmarkNode = mockk() + val useCase = BookmarksUseCase(storage) + + every { bookmarkNode.url }.answers { "https://mozilla.org" } + coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { listOf(bookmarkNode) } + + val result = useCase.addBookmark("https://mozilla.org", "Mozilla") + + assertFalse(result) + } + + @Test + fun `WHEN adding bookmark THEN new item is stored`() = runBlockingTest { + val storage = mockk(relaxed = true) + val useCase = BookmarksUseCase(storage) + + coEvery { storage.getBookmarksWithUrl(any()) }.coAnswers { emptyList() } + + val result = useCase.addBookmark("https://mozilla.org", "Mozilla") + + assertTrue(result) + + coVerify { storage.addItem(BookmarkRoot.Mobile.id, "https://mozilla.org", "Mozilla", null) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt index b789d6e0c7..bbf795d8f1 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/MenuIntegrationTest.kt @@ -6,13 +6,21 @@ package org.mozilla.fenix.tabstray import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import mozilla.components.support.test.libstate.ext.waitUntilIdle import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import mozilla.components.support.test.rule.MainCoroutineRule import org.junit.Assert.assertNotNull -import org.junit.Ignore +import org.junit.Rule import org.junit.Test class MenuIntegrationTest { + @OptIn(ExperimentalCoroutinesApi::class) + @get:Rule + val coroutinesTestRule = MainCoroutineRule(TestCoroutineDispatcher()) + private val captureMiddleware = CaptureActionsMiddleware() private val tabsTrayStore = TabsTrayStore(middlewares = listOf(captureMiddleware)) private val interactor = mockk(relaxed = true) @@ -53,12 +61,13 @@ class MenuIntegrationTest { verify { interactor.onOpenRecentlyClosedClicked() } } - @Ignore("Enable after we connect this menu item to the store") @Test fun `WHEN the select menu item is clicked THEN invoke the action`() { val menu = MenuIntegration(mockk(), mockk(), tabsTrayStore, mockk(), interactor) - menu.handleMenuClicked(TabsTrayMenu.Item.ShareAllTabs) + menu.handleMenuClicked(TabsTrayMenu.Item.SelectTabs) + + tabsTrayStore.waitUntilIdle() assertNotNull(captureMiddleware.findLastAction(TabsTrayAction.EnterSelectMode::class)) } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt index 4201afc80d..3ddf4f2d4f 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt @@ -6,34 +6,45 @@ package org.mozilla.fenix.tabstray import androidx.navigation.NavController import androidx.navigation.NavDirections +import io.mockk.coVerify import io.mockk.mockk import io.mockk.verify import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState -import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.state.createTab as createStateTab import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mozilla.fenix.components.bookmarks.BookmarksUseCase +import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController +import org.mozilla.fenix.tabstray.browser.createTab as createTrayTab class NavigationInteractorTest { private lateinit var store: BrowserStore + private lateinit var tabsTrayStore: TabsTrayStore private lateinit var navigationInteractor: NavigationInteractor - private val testTab: TabSessionState = createTab(url = "https://mozilla.org") + private val testTab: TabSessionState = createStateTab(url = "https://mozilla.org") private val navController: NavController = mockk(relaxed = true) private val metrics: MetricController = mockk(relaxed = true) private val dismissTabTray: () -> Unit = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: (String) -> Unit = mockk(relaxed = true) + private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true) @Before fun setup() { store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab))) + tabsTrayStore = TabsTrayStore() navigationInteractor = DefaultNavigationInteractor( + tabsTrayStore, store, navController, metrics, dismissTabTray, - dismissTabTrayAndNavigateHome + dismissTabTrayAndNavigateHome, + bookmarksUseCase ) } @@ -44,6 +55,9 @@ class NavigationInteractorTest { var openRecentlyClosedClicked = false var shareTabsOfTypeClicked = false var closeAllTabsClicked = false + var onShareTabs = false + var onSaveToCollections = false + var onBookmarkTabs = false class TestNavigationInteractor : NavigationInteractor { @@ -51,6 +65,10 @@ class NavigationInteractorTest { tabTrayDismissed = true } + override fun onShareTabs(tabs: Collection) { + onShareTabs = true + } + override fun onTabSettingsClicked() { tabSettingsClicked = true } @@ -59,6 +77,14 @@ class NavigationInteractorTest { openRecentlyClosedClicked = true } + override fun onSaveToCollections(tabs: Collection) { + onSaveToCollections = true + } + + override fun onSaveToBookmarks(tabs: Collection) { + onBookmarkTabs = true + } + override fun onShareTabsOfTypeClicked(private: Boolean) { shareTabsOfTypeClicked = true } @@ -70,15 +96,21 @@ class NavigationInteractorTest { val navigationInteractor: NavigationInteractor = TestNavigationInteractor() navigationInteractor.onTabTrayDismissed() - assert(tabTrayDismissed) + assertTrue(tabTrayDismissed) navigationInteractor.onTabSettingsClicked() - assert(tabSettingsClicked) + assertTrue(tabSettingsClicked) navigationInteractor.onOpenRecentlyClosedClicked() - assert(openRecentlyClosedClicked) + assertTrue(openRecentlyClosedClicked) navigationInteractor.onShareTabsOfTypeClicked(true) - assert(shareTabsOfTypeClicked) + assertTrue(shareTabsOfTypeClicked) navigationInteractor.onCloseAllTabsClicked(true) - assert(closeAllTabsClicked) + assertTrue(closeAllTabsClicked) + navigationInteractor.onShareTabs(emptyList()) + assertTrue(onShareTabs) + navigationInteractor.onSaveToCollections(emptyList()) + assertTrue(onSaveToCollections) + navigationInteractor.onSaveToBookmarks(emptyList()) + assertTrue(onBookmarkTabs) } @Test @@ -110,4 +142,22 @@ class NavigationInteractorTest { navigationInteractor.onShareTabsOfTypeClicked(false) verify(exactly = 1) { navController.navigate(any()) } } + + @Test + fun `onShareTabs calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onShareTabs(emptyList()) + verify(exactly = 1) { navController.navigate(any()) } + } + + @Test + fun `onSaveToCollections calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onSaveToCollections(emptyList()) + verify(exactly = 1) { metrics.track(Event.TabsTraySaveToCollectionPressed) } + } + + @Test + fun `onBookmarkTabs calls navigation on DefaultNavigationInteractor`() { + navigationInteractor.onSaveToBookmarks(listOf(createTrayTab())) + coVerify(exactly = 1) { bookmarksUseCase.addBookmark(any(), any(), any()) } + } } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt new file mode 100644 index 0000000000..24a2635dfe --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/SelectionMenuIntegrationTest.kt @@ -0,0 +1,37 @@ +/* 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.tabstray.browser + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test +import org.mozilla.fenix.tabstray.NavigationInteractor +import org.mozilla.fenix.tabstray.TabsTrayInteractor +import org.mozilla.fenix.tabstray.TabsTrayStore + +class SelectionMenuIntegrationTest { + + private val navInteractor = mockk(relaxed = true) + private val trayInteractor = mockk(relaxed = true) + private val store = TabsTrayStore() + + @Test + fun `WHEN bookmark item is clicked THEN invoke interactor`() { + val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor) + + menu.handleMenuClicked(SelectionMenu.Item.BookmarkTabs) + + verify { navInteractor.onSaveToBookmarks(store.state.mode.selectedTabs) } + } + + @Test + fun `WHEN delete tabs item is clicked THEN invoke interactor`() { + val menu = SelectionMenuIntegration(mockk(), store, navInteractor, trayInteractor) + + menu.handleMenuClicked(SelectionMenu.Item.DeleteTabs) + + verify { trayInteractor.onDeleteTabs(store.state.mode.selectedTabs) } + } +}