For #21897 - Convert inactive tabs to compose

pull/543/head
Noah Bond 2 years ago committed by mergify[bot]
parent 0cbf4d9b7f
commit 08a84f8353

@ -17,6 +17,7 @@ import androidx.appcompat.app.AppCompatDialogFragment
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.setFragmentResultListener
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs import androidx.navigation.fragment.navArgs
@ -228,11 +229,12 @@ class TabsTrayFragment : AppCompatDialogFragment() {
setupMenu(navigationInteractor) setupMenu(navigationInteractor)
setupPager( setupPager(
view.context, context = view.context,
tabsTrayStore, lifecycleOwner = viewLifecycleOwner,
tabsTrayInteractor, store = tabsTrayStore,
browserTrayInteractor, trayInteractor = tabsTrayInteractor,
navigationInteractor browserInteractor = browserTrayInteractor,
navigationInteractor = navigationInteractor,
) )
setupBackgroundDismissalListener { setupBackgroundDismissalListener {
@ -467,8 +469,10 @@ class TabsTrayFragment : AppCompatDialogFragment() {
} }
@VisibleForTesting @VisibleForTesting
@Suppress("LongParameterList")
internal fun setupPager( internal fun setupPager(
context: Context, context: Context,
lifecycleOwner: LifecycleOwner,
store: TabsTrayStore, store: TabsTrayStore,
trayInteractor: TabsTrayInteractor, trayInteractor: TabsTrayInteractor,
browserInteractor: BrowserTrayInteractor, browserInteractor: BrowserTrayInteractor,
@ -476,13 +480,14 @@ class TabsTrayFragment : AppCompatDialogFragment() {
) { ) {
tabsTrayBinding.tabsTray.apply { tabsTrayBinding.tabsTray.apply {
adapter = TrayPagerAdapter( adapter = TrayPagerAdapter(
context, context = context,
store, lifecycleOwner = lifecycleOwner,
browserInteractor, tabsTrayStore = store,
navigationInteractor, browserInteractor = browserInteractor,
trayInteractor, navInteractor = navigationInteractor,
requireComponents.core.store, tabsTrayInteractor = trayInteractor,
requireComponents.appStore, browserStore = requireComponents.core.store,
appStore = requireComponents.appStore,
) )
isUserInputEnabled = false isUserInputEnabled = false
} }

@ -9,14 +9,18 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.DefaultInactiveTabsInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter
import org.mozilla.fenix.tabstray.browser.InactiveTabsController
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter import org.mozilla.fenix.tabstray.browser.TabGroupAdapter
import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter
import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder
@ -27,10 +31,11 @@ import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder
@Suppress("LongParameterList") @Suppress("LongParameterList")
class TrayPagerAdapter( class TrayPagerAdapter(
@VisibleForTesting internal val context: Context, @VisibleForTesting internal val context: Context,
@VisibleForTesting internal val lifecycleOwner: LifecycleOwner,
@VisibleForTesting internal val tabsTrayStore: TabsTrayStore, @VisibleForTesting internal val tabsTrayStore: TabsTrayStore,
@VisibleForTesting internal val browserInteractor: BrowserTrayInteractor, @VisibleForTesting internal val browserInteractor: BrowserTrayInteractor,
@VisibleForTesting internal val navInteractor: NavigationInteractor, @VisibleForTesting internal val navInteractor: NavigationInteractor,
@VisibleForTesting internal val interactor: TabsTrayInteractor, @VisibleForTesting internal val tabsTrayInteractor: TabsTrayInteractor,
@VisibleForTesting internal val browserStore: BrowserStore, @VisibleForTesting internal val browserStore: BrowserStore,
@VisibleForTesting internal val appStore: AppStore @VisibleForTesting internal val appStore: AppStore
) : RecyclerView.Adapter<AbstractPageViewHolder>() { ) : RecyclerView.Adapter<AbstractPageViewHolder>() {
@ -42,12 +47,29 @@ class TrayPagerAdapter(
*/ */
private val normalAdapter by lazy { private val normalAdapter by lazy {
ConcatAdapter( ConcatAdapter(
InactiveTabsAdapter(context, browserInteractor, interactor, INACTIVE_TABS_FEATURE_NAME, context.settings()), InactiveTabsAdapter(
lifecycleOwner = lifecycleOwner,
tabsTrayStore = tabsTrayStore,
tabsTrayInteractor = tabsTrayInteractor,
inactiveTabsInteractor = inactiveTabsInteractor,
featureName = INACTIVE_TABS_FEATURE_NAME,
),
TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME), TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME),
TitleHeaderAdapter(), TitleHeaderAdapter(),
BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME) BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME)
) )
} }
private val inactiveTabsInteractor by lazy {
DefaultInactiveTabsInteractor(
InactiveTabsController(
appStore = context.components.appStore,
settings = context.settings(),
browserInteractor = browserInteractor,
)
)
}
private val privateAdapter by lazy { private val privateAdapter by lazy {
BrowserTabsAdapter( BrowserTabsAdapter(
context, context,
@ -62,10 +84,11 @@ class TrayPagerAdapter(
NormalBrowserPageViewHolder.LAYOUT_ID -> { NormalBrowserPageViewHolder.LAYOUT_ID -> {
NormalBrowserPageViewHolder( NormalBrowserPageViewHolder(
LayoutInflater.from(parent.context).inflate(viewType, parent, false), LayoutInflater.from(parent.context).inflate(viewType, parent, false),
lifecycleOwner,
tabsTrayStore, tabsTrayStore,
browserStore, browserStore,
appStore, appStore,
interactor tabsTrayInteractor
) )
} }
PrivateBrowserPageViewHolder.LAYOUT_ID -> { PrivateBrowserPageViewHolder.LAYOUT_ID -> {
@ -73,7 +96,7 @@ class TrayPagerAdapter(
LayoutInflater.from(parent.context).inflate(viewType, parent, false), LayoutInflater.from(parent.context).inflate(viewType, parent, false),
tabsTrayStore, tabsTrayStore,
browserStore, browserStore,
interactor tabsTrayInteractor
) )
} }
SyncedTabsPageViewHolder.LAYOUT_ID -> { SyncedTabsPageViewHolder.LAYOUT_ID -> {

@ -5,165 +5,95 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import android.view.View import android.view.View
import androidx.core.view.updatePadding import androidx.compose.runtime.Composable
import androidx.recyclerview.widget.RecyclerView import androidx.compose.runtime.getValue
import mozilla.components.browser.state.state.TabSessionState import androidx.compose.runtime.mutableStateOf
import mozilla.components.browser.tabstray.TabsTray import androidx.compose.runtime.remember
import mozilla.components.browser.toolbar.MAX_URI_LENGTH import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.LifecycleOwner
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.telemetry.glean.private.NoExtras import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.databinding.InactiveFooterItemBinding import org.mozilla.fenix.components.components
import org.mozilla.fenix.databinding.InactiveHeaderItemBinding import org.mozilla.fenix.compose.ComposeViewHolder
import org.mozilla.fenix.databinding.InactiveTabListItemBinding
import org.mozilla.fenix.databinding.InactiveTabsAutoCloseBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.home.topsites.dpToPx
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import org.mozilla.fenix.tabstray.TabsTrayFragment import org.mozilla.fenix.tabstray.TabsTrayFragment
import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TrayPagerAdapter
import org.mozilla.fenix.tabstray.inactivetabs.InactiveTabsList
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { /**
* The [ComposeViewHolder] for displaying the section of inactive tabs in [TrayPagerAdapter].
class HeaderHolder( *
itemView: View, * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content.
inactiveTabsInteractor: InactiveTabsInteractor, * @param lifecycleOwner [LifecycleOwner] to which this Composable will be tied to.
tabsTrayInteractor: TabsTrayInteractor, * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs].
) : InactiveTabViewHolder(itemView) { * @param tabsTrayInteractor [TabsTrayInteractor] used to handle deleting all inactive tabs.
* @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header
private val binding = InactiveHeaderItemBinding.bind(itemView) * and the auto close dialog.
*/
init { @Suppress("LongParameterList")
itemView.apply { class InactiveTabViewHolder(
isActivated = itemView.context.components.appStore.state.inactiveTabsExpanded composeView: ComposeView,
lifecycleOwner: LifecycleOwner,
correctHeaderBorder(isActivated) private val tabsTrayStore: TabsTrayStore,
private val tabsTrayInteractor: TabsTrayInteractor,
setOnClickListener { private val inactiveTabsInteractor: InactiveTabsInteractor,
val newState = !it.isActivated ) : ComposeViewHolder(composeView, lifecycleOwner) {
inactiveTabsInteractor.onHeaderClicked(newState) @Composable
override fun Content() {
it.isActivated = newState val expanded = components.appStore
.observeAsComposableState { state -> state.inactiveTabsExpanded }.value ?: false
correctHeaderBorder(isActivated) val inactiveTabs = tabsTrayStore
} .observeAsComposableState { state -> state.inactiveTabs }.value ?: emptyList()
val showInactiveTabsAutoCloseDialog =
binding.delete.setOnClickListener { components.settings.shouldShowInactiveTabsAutoCloseDialog(inactiveTabs.size)
tabsTrayInteractor.onDeleteInactiveTabs() var showAutoClosePrompt by remember { mutableStateOf(showInactiveTabsAutoCloseDialog) }
}
} if (showInactiveTabsAutoCloseDialog) {
}
/**
* When the header is collapsed we use its bottom border instead of the footer's
*/
private fun correctHeaderBorder(isActivated: Boolean) {
binding.inactiveHeaderBorder.updatePadding(
bottom = binding.root.context.dpToPx(if (isActivated) 0f else 1f)
)
}
companion object {
const val LAYOUT_ID = R.layout.inactive_header_item
}
}
class AutoCloseDialogHolder(
itemView: View,
interactor: InactiveTabsAutoCloseDialogInteractor
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveTabsAutoCloseBinding.bind(itemView)
init {
TabsTrayMetrics.autoCloseSeen.record(NoExtras()) TabsTrayMetrics.autoCloseSeen.record(NoExtras())
binding.message.text = with(binding.root.context) {
getString(
R.string.tab_tray_inactive_auto_close_body_2,
getString(R.string.app_name)
)
}
binding.closeButton.setOnClickListener {
interactor.onCloseClicked()
}
binding.action.setOnClickListener {
interactor.onEnabledAutoCloseClicked()
showConfirmationSnackbar()
}
} }
private fun showConfirmationSnackbar() { if (inactiveTabs.isNotEmpty()) {
val context = binding.root.context InactiveTabsList(
val view = binding.root inactiveTabs = inactiveTabs,
val text = context.getString(R.string.inactive_tabs_auto_close_message_snackbar) expanded = expanded,
val snackbar = FenixSnackbar.make( showAutoCloseDialog = showAutoClosePrompt,
view = view, onHeaderClick = { inactiveTabsInteractor.onHeaderClicked(!expanded) },
duration = FenixSnackbar.LENGTH_SHORT, onDeleteAllButtonClick = tabsTrayInteractor::onDeleteInactiveTabs,
isDisplayedWithBrowserToolbar = true onAutoCloseDismissClick = {
).setText(text) inactiveTabsInteractor.onCloseClicked()
snackbar.view.elevation = TabsTrayFragment.ELEVATION showAutoClosePrompt = !showAutoClosePrompt
snackbar.show() },
} onEnableAutoCloseClick = {
inactiveTabsInteractor.onEnabledAutoCloseClicked()
companion object { showAutoClosePrompt = !showAutoClosePrompt
const val LAYOUT_ID = R.layout.inactive_tabs_auto_close showConfirmationSnackbar()
},
onTabClick = inactiveTabsInteractor::onTabClicked,
onTabCloseClick = inactiveTabsInteractor::onTabClosed,
)
} }
} }
/** private fun showConfirmationSnackbar() {
* A RecyclerView ViewHolder implementation for an inactive tab view. val context = composeView.context
* val text = context.getString(R.string.inactive_tabs_auto_close_message_snackbar)
* @param itemView the inactive tab [View]. val snackbar = FenixSnackbar.make(
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. view = composeView,
*/ duration = FenixSnackbar.LENGTH_SHORT,
class TabViewHolder( isDisplayedWithBrowserToolbar = true
itemView: View, ).setText(text)
private val delegate: TabsTray.Delegate, snackbar.view.elevation = TabsTrayFragment.ELEVATION
private val featureName: String snackbar.show()
) : InactiveTabViewHolder(itemView) {
private val binding = InactiveTabListItemBinding.bind(itemView)
fun bind(tab: TabSessionState) {
val components = itemView.context.components
val title = tab.content.title.ifEmpty { tab.content.url.take(MAX_URI_LENGTH) }
val url = tab.content.url.toShortUrl(components.publicSuffixList).take(MAX_URI_LENGTH)
itemView.setOnClickListener {
TabsTrayMetrics.openInactiveTab.add()
delegate.onTabSelected(tab, featureName)
}
binding.siteListItem.apply {
components.core.icons.loadIntoView(iconView, tab.content.url)
setText(title, url)
setSecondaryButton(
R.drawable.mozac_ic_close,
R.string.content_description_close_button
) {
TabsTrayMetrics.closeInactiveTab.add()
delegate.onTabClosed(tab, featureName)
}
}
}
companion object {
const val LAYOUT_ID = R.layout.inactive_tab_list_item
}
} }
class FooterHolder(itemView: View) : InactiveTabViewHolder(itemView) { companion object {
val LAYOUT_ID = View.generateViewId()
init {
InactiveFooterItemBinding.bind(itemView)
}
companion object {
const val LAYOUT_ID = R.layout.inactive_footer_item
}
} }
} }

@ -4,149 +4,57 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil import androidx.compose.ui.platform.ComposeView
import androidx.recyclerview.widget.ListAdapter import androidx.lifecycle.LifecycleOwner
import mozilla.components.browser.state.state.TabPartition import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.AutoCloseDialogHolder import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.FooterHolder import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.TabViewHolder
import org.mozilla.fenix.utils.Settings
/** /**
* A convenience alias for readability. * The adapter for displaying the section of inactive tabs.
*/
private typealias Adapter = ListAdapter<InactiveTabsAdapter.Item, InactiveTabViewHolder>
/**
* The [ListAdapter] for displaying the list of inactive tabs.
* *
* @param context [Context] used for various platform interactions or accessing [Components] * @param lifecycleOwner [LifecycleOwner] to which the Composable will be tied to.
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray. * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs].
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. * @param tabsTrayInteractor [TabsTrayInteractor] used to handle deleting all inactive tabs.
* @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header
* and the auto close dialog.
* @param featureName [String] representing the name of the inactive tabs feature for telemetry reporting.
*/ */
@Suppress("LongParameterList")
class InactiveTabsAdapter( class InactiveTabsAdapter(
private val context: Context, private val lifecycleOwner: LifecycleOwner,
private val browserTrayInteractor: BrowserTrayInteractor, private val tabsTrayStore: TabsTrayStore,
private val tabsTrayInteractor: TabsTrayInteractor, private val tabsTrayInteractor: TabsTrayInteractor,
private val inactiveTabsInteractor: InactiveTabsInteractor,
override val featureName: String, override val featureName: String,
private val settings: Settings, ) : RecyclerView.Adapter<InactiveTabViewHolder>(), FeatureNameHolder {
) : Adapter(DiffCallback), TabsTray, FeatureNameHolder {
internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor override fun getItemCount(): Int = 1
private var inActiveTabsCount: Int = 0
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder {
val view = LayoutInflater.from(parent.context) return InactiveTabViewHolder(
.inflate(viewType, parent, false) composeView = ComposeView(parent.context),
lifecycleOwner = lifecycleOwner,
return when (viewType) { tabsTrayStore = tabsTrayStore,
AutoCloseDialogHolder.LAYOUT_ID -> AutoCloseDialogHolder(view, inactiveTabsInteractor) inactiveTabsInteractor = inactiveTabsInteractor,
HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor, tabsTrayInteractor) tabsTrayInteractor = tabsTrayInteractor,
TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor, featureName) )
FooterHolder.LAYOUT_ID -> FooterHolder(view)
else -> throw IllegalStateException("Unknown viewType: $viewType")
}
} }
override fun onBindViewHolder(holder: InactiveTabViewHolder, position: Int) { override fun onBindViewHolder(holder: InactiveTabViewHolder, position: Int) {
when (holder) { // no-op. This ViewHolder receives the TabsTrayStore as argument and will observe that
is TabViewHolder -> { // without the need for us to manually update here for the data to be displayed.
val item = getItem(position) as Item.Tab
holder.bind(item.tab)
}
is FooterHolder, is HeaderHolder, is AutoCloseDialogHolder -> {
// do nothing.
}
}
}
override fun getItemViewType(position: Int): Int {
return when (position) {
0 -> HeaderHolder.LAYOUT_ID
1 -> if (settings.shouldShowInactiveTabsAutoCloseDialog(inActiveTabsCount)) {
AutoCloseDialogHolder.LAYOUT_ID
} else {
TabViewHolder.LAYOUT_ID
}
itemCount - 1 -> FooterHolder.LAYOUT_ID
else -> TabViewHolder.LAYOUT_ID
}
}
override fun updateTabs(tabs: List<TabSessionState>, tabPartition: TabPartition?, selectedTabId: String?) {
inActiveTabsCount = tabs.size
// Early return with an empty list to remove the header/footer items.
if (tabs.isEmpty()) {
submitList(emptyList())
return
}
// If we have items, but we should be in a collapsed state.
if (!context.components.appStore.state.inactiveTabsExpanded) {
submitList(listOf(Item.Header))
return
}
val items = tabs.map { Item.Tab(it) }
val footer = Item.Footer
val headerItems = if (settings.shouldShowInactiveTabsAutoCloseDialog(items.size)) {
listOf(Item.Header, Item.AutoCloseMessage)
} else {
listOf(Item.Header)
}
submitList(headerItems + items + listOf(footer))
} }
private object DiffCallback : DiffUtil.ItemCallback<Item>() { override fun getItemViewType(position: Int): Int = InactiveTabViewHolder.LAYOUT_ID
override fun areItemsTheSame(oldItem: Item, newItem: Item): Boolean {
return if (oldItem is Item.Tab && newItem is Item.Tab) {
oldItem.tab.id == newItem.tab.id
} else {
oldItem == newItem
}
}
override fun areContentsTheSame(oldItem: Item, newItem: Item): Boolean {
return oldItem == newItem
}
}
/**
* The types of different data we can put into the [InactiveTabsAdapter].
*/
sealed class Item {
/**
* A title header for the inactive tab section. This may be seen only
* when at least one inactive tab is present.
*/
object Header : Item()
/**
* A tab that is now considered inactive.
*/
data class Tab(val tab: TabSessionState) : Item()
/**
* A dialog for when the inactive tabs section reach 20 tabs.
*/
object AutoCloseMessage : Item()
/** override fun onViewRecycled(holder: InactiveTabViewHolder) {
* A footer for the inactive tab section. This may be seen only // no op
* when at least one inactive tab is present. // This previously called "composeView.disposeComposition" which would have the
*/ // entire Composable destroyed and recreated when this View is scrolled off or on screen again.
object Footer : Item() // This View already listens and maps store updates. Avoid creating and binding new Views.
// The composition will live until the ViewTreeLifecycleOwner to which it's attached to is destroyed.
} }
} }

@ -1,31 +0,0 @@
/* 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.tabstray.TabsTrayState
import org.mozilla.fenix.tabstray.TabsTrayStore
/**
* An inactive tabs observer that updates the provided [TabsTray].
*/
class InactiveTabsBinding(
store: TabsTrayStore,
private val tray: TabsTray
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.inactiveTabs }
.ifChanged()
.collect {
// We pass null for the selected tab id here, because inactive tabs doesn't care.
tray.updateTabs(it, null, null)
}
}
}

@ -4,31 +4,50 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import androidx.annotation.VisibleForTesting import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.tabstray.TabsTray
import mozilla.telemetry.glean.private.NoExtras import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.appstate.AppAction.UpdateInactiveExpanded import org.mozilla.fenix.components.appstate.AppAction.UpdateInactiveExpanded
import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.TrayPagerAdapter.Companion.INACTIVE_TABS_FEATURE_NAME
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
/**
* Default behavior for handling all user interactions with the Inactive Tabs feature.
*
* @param appStore [AppStore] used to dispatch any [AppAction].
* @param settings [Settings] used to update any user preferences.
* @param browserInteractor [BrowserTrayInteractor] used to respond to interactions with specific inactive tabs.
*/
class InactiveTabsController( class InactiveTabsController(
private val tabsTrayStore: TabsTrayStore,
private val appStore: AppStore, private val appStore: AppStore,
private val tray: TabsTray, private val settings: Settings,
private val settings: Settings private val browserInteractor: BrowserTrayInteractor,
) { ) {
/**
* Opens the given inactive tab.
*/
fun openInactiveTab(tab: TabSessionState) {
TabsTrayMetrics.openInactiveTab.add()
browserInteractor.onTabSelected(tab, INACTIVE_TABS_FEATURE_NAME)
}
/**
* Closes the given inactive tab.
*/
fun closeInactiveTab(tab: TabSessionState) {
TabsTrayMetrics.closeInactiveTab.add()
browserInteractor.onTabClosed(tab, INACTIVE_TABS_FEATURE_NAME)
}
/** /**
* Updates the inactive card to be expanded to display all the tabs, or collapsed with only * Updates the inactive card to be expanded to display all the tabs, or collapsed with only
* the title showing. * the title showing.
*/ */
fun updateCardExpansion(isExpanded: Boolean) { fun updateCardExpansion(isExpanded: Boolean) {
appStore.dispatch(UpdateInactiveExpanded(isExpanded)).invokeOnCompletion { appStore.dispatch(UpdateInactiveExpanded(isExpanded))
// To avoid racing, we read the list of inactive tabs only after we have updated
// the expanded state.
refreshInactiveTabsSection()
}
when (isExpanded) { when (isExpanded) {
true -> TabsTrayMetrics.inactiveTabsExpanded.record(NoExtras()) true -> TabsTrayMetrics.inactiveTabsExpanded.record(NoExtras())
@ -41,7 +60,6 @@ class InactiveTabsController(
*/ */
fun close() { fun close() {
markDialogAsShown() markDialogAsShown()
refreshInactiveTabsSection()
TabsTrayMetrics.autoCloseDimissed.record(NoExtras()) TabsTrayMetrics.autoCloseDimissed.record(NoExtras())
} }
@ -54,7 +72,6 @@ class InactiveTabsController(
settings.closeTabsAfterOneWeek = false settings.closeTabsAfterOneWeek = false
settings.closeTabsAfterOneDay = false settings.closeTabsAfterOneDay = false
settings.manuallyCloseTabs = false settings.manuallyCloseTabs = false
refreshInactiveTabsSection()
TabsTrayMetrics.autoCloseTurnOnClicked.record(NoExtras()) TabsTrayMetrics.autoCloseTurnOnClicked.record(NoExtras())
} }
@ -64,10 +81,4 @@ class InactiveTabsController(
private fun markDialogAsShown() { private fun markDialogAsShown() {
settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true
} }
@VisibleForTesting
internal fun refreshInactiveTabsSection() {
val tabs = tabsTrayStore.state.inactiveTabs
tray.updateTabs(tabs, null, null)
}
} }

@ -4,16 +4,32 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import mozilla.components.browser.state.state.TabSessionState
/** /**
* Interactor for all things related to inactive tabs in the tabs tray. * Interactor for all things related to inactive tabs in the tabs tray.
*/ */
interface InactiveTabsInteractor : InactiveTabsAutoCloseDialogInteractor { interface InactiveTabsInteractor : InactiveTabsAutoCloseDialogInteractor {
/** /**
* Invoked when the header is tapped on. * Invoked when the header is clicked.
* *
* @param activated true when the tap should expand the inactive section. * @param activated true when the tap should expand the inactive section.
*/ */
fun onHeaderClicked(activated: Boolean) fun onHeaderClicked(activated: Boolean)
/**
* Invoked when an inactive tab is clicked.
*
* @param tab [TabSessionState] that was clicked.
*/
fun onTabClicked(tab: TabSessionState)
/**
* Invoked when an inactive tab is closed.
*
* @param tab [TabSessionState] that was closed.
*/
fun onTabClosed(tab: TabSessionState)
} }
/** /**
@ -56,4 +72,18 @@ class DefaultInactiveTabsInteractor(
override fun onEnabledAutoCloseClicked() { override fun onEnabledAutoCloseClicked() {
controller.enableAutoClosed() controller.enableAutoClosed()
} }
/**
* See [InactiveTabsInteractor.onTabClicked].
*/
override fun onTabClicked(tab: TabSessionState) {
controller.openInactiveTab(tab)
}
/**
* See [InactiveTabsInteractor.onTabClosed].
*/
override fun onTabClosed(tab: TabSessionState) {
controller.closeInactiveTab(tab)
}
} }

@ -9,9 +9,7 @@ import android.util.AttributeSet
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.browser.tabstray.TabViewHolder
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
@ -23,10 +21,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
private val concatAdapter by lazy { adapter as ConcatAdapter } private val concatAdapter by lazy { adapter as ConcatAdapter }
private val inactiveTabsBinding by lazy {
InactiveTabsBinding(tabsTrayStore, concatAdapter.inactiveTabsAdapter)
}
private val normalTabsBinding by lazy { private val normalTabsBinding by lazy {
NormalTabsBinding(tabsTrayStore, context.components.core.store, concatAdapter.browserAdapter) NormalTabsBinding(tabsTrayStore, context.components.core.store, concatAdapter.browserAdapter)
} }
@ -39,17 +33,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
TabGroupBinding(tabsTrayStore) { concatAdapter.tabGroupAdapter.submitList(it) } TabGroupBinding(tabsTrayStore) { concatAdapter.tabGroupAdapter.submitList(it) }
} }
private val inactiveTabsInteractor by lazy {
DefaultInactiveTabsInteractor(
InactiveTabsController(
tabsTrayStore,
context.components.appStore,
concatAdapter.inactiveTabsAdapter,
context.settings()
)
)
}
private val touchHelper by lazy { private val touchHelper by lazy {
TabsTouchHelper( TabsTouchHelper(
interactionDelegate = concatAdapter.browserAdapter.interactor, interactionDelegate = concatAdapter.browserAdapter.interactor,
@ -64,9 +47,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
override fun onAttachedToWindow() { override fun onAttachedToWindow() {
super.onAttachedToWindow() super.onAttachedToWindow()
concatAdapter.inactiveTabsAdapter.inactiveTabsInteractor = inactiveTabsInteractor
inactiveTabsBinding.start()
normalTabsBinding.start() normalTabsBinding.start()
titleHeaderBinding.start() titleHeaderBinding.start()
tabGroupBinding.start() tabGroupBinding.start()
@ -77,7 +57,6 @@ class NormalBrowserTrayList @JvmOverloads constructor(
override fun onDetachedFromWindow() { override fun onDetachedFromWindow() {
super.onDetachedFromWindow() super.onDetachedFromWindow()
inactiveTabsBinding.stop()
normalTabsBinding.stop() normalTabsBinding.stop()
titleHeaderBinding.stop() titleHeaderBinding.stop()
tabGroupBinding.stop() tabGroupBinding.stop()

@ -43,11 +43,11 @@ fun BrowserState.getNormalTrayTabs(
): List<TabSessionState> { ): List<TabSessionState> {
val tabGroupsTabIds = getTabGroups()?.flatMap { it.tabIds } ?: emptyList() val tabGroupsTabIds = getTabGroups()?.flatMap { it.tabIds } ?: emptyList()
return normalTabs.run { return normalTabs.run {
if (searchTermTabGroupsAreEnabled && inactiveTabsEnabled) { if (searchTermTabGroupsAreEnabled && tabGroupsTabIds.isNotEmpty() && inactiveTabsEnabled) {
filter { it.isNormalTabActive(maxActiveTime) }.filter { tabGroupsTabIds.contains(it.id) } filter { it.isNormalTabActive(maxActiveTime) }.filter { tabGroupsTabIds.contains(it.id) }
} else if (inactiveTabsEnabled) { } else if (inactiveTabsEnabled) {
filter { it.isNormalTabActive(maxActiveTime) } filter { it.isNormalTabActive(maxActiveTime) }
} else if (searchTermTabGroupsAreEnabled) { } else if (searchTermTabGroupsAreEnabled && tabGroupsTabIds.isNotEmpty()) {
filter { it.isNormalTab() }.filter { tabGroupsTabIds.contains(it.id) } filter { it.isNormalTab() }.filter { tabGroupsTabIds.contains(it.id) }
} else { } else {
this this

@ -5,6 +5,7 @@
package org.mozilla.fenix.tabstray.ext package org.mozilla.fenix.tabstray.ext
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.toolbar.MAX_URI_LENGTH
fun TabSessionState.isActive(maxActiveTime: Long): Boolean { fun TabSessionState.isActive(maxActiveTime: Long): Boolean {
val lastActiveTime = maxOf(lastAccess, createdAt) val lastActiveTime = maxOf(lastAccess, createdAt)
@ -53,3 +54,8 @@ internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean {
internal fun TabSessionState.isNormalTab(): Boolean { internal fun TabSessionState.isNormalTab(): Boolean {
return !content.private return !content.private
} }
/**
* Returns a [String] for displaying a [TabSessionState]'s title or its url when a title is not available.
*/
fun TabSessionState.toDisplayTitle(): String = content.title.ifEmpty { content.url.take(MAX_URI_LENGTH) }

@ -0,0 +1,278 @@
/* 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/. */
@file:Suppress("TooManyFunctions")
package org.mozilla.fenix.tabstray.inactivetabs
import android.content.res.Configuration
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.PrimaryText
import org.mozilla.fenix.compose.SecondaryText
import org.mozilla.fenix.compose.button.TextButton
import org.mozilla.fenix.compose.list.ExpandableListHeader
import org.mozilla.fenix.compose.list.FaviconListItem
import org.mozilla.fenix.ext.toShortUrl
import org.mozilla.fenix.tabstray.ext.toDisplayTitle
import org.mozilla.fenix.theme.FirefoxTheme
import org.mozilla.fenix.theme.Theme
private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp)
/**
* Top-level list for displaying an expandable section of Inactive Tabs.
*
* @param inactiveTabs List of [TabSessionState] to display.
* @param expanded Whether to show the inactive tabs section expanded or collapsed.
* @param showAutoCloseDialog Whether to show the auto close inactive tabs dialog.
* @param onHeaderClick Called when the user clicks on the inactive tabs section header.
* @param onDeleteAllButtonClick Called when the user clicks on the delete all inactive tabs button.
* @param onAutoCloseDismissClick Called when the user clicks on the auto close dialog's dismiss button.
* @param onEnableAutoCloseClick Called when the user clicks on the auto close dialog's enable button.
* @param onTabClick Called when the user clicks on a specific inactive tab.
* @param onTabCloseClick Called when the user clicks on a specific inactive tab's close button.
*/
@Composable
@Suppress("LongParameterList")
fun InactiveTabsList(
inactiveTabs: List<TabSessionState>,
expanded: Boolean,
showAutoCloseDialog: Boolean,
onHeaderClick: () -> Unit,
onDeleteAllButtonClick: () -> Unit,
onAutoCloseDismissClick: () -> Unit,
onEnableAutoCloseClick: () -> Unit,
onTabClick: (TabSessionState) -> Unit,
onTabCloseClick: (TabSessionState) -> Unit,
) {
Card(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
shape = ROUNDED_CORNER_SHAPE,
backgroundColor = FirefoxTheme.colors.layer2,
border = BorderStroke(
width = 1.dp,
color = FirefoxTheme.colors.borderPrimary,
),
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
InactiveTabsHeader(
expanded = expanded,
onClick = onHeaderClick,
onDeleteAllClick = onDeleteAllButtonClick,
)
if (expanded) {
if (showAutoCloseDialog) {
InactiveTabsAutoClosePrompt(
onDismissClick = onAutoCloseDismissClick,
onEnableAutoCloseClick = onEnableAutoCloseClick,
)
}
Column {
inactiveTabs.forEach { tab ->
val tabUrl = tab.content.url.toShortUrl()
FaviconListItem(
label = tab.toDisplayTitle(),
description = tabUrl,
onClick = { onTabClick(tab) },
url = tabUrl,
iconPainter = painterResource(R.drawable.mozac_ic_close),
iconDescription = stringResource(R.string.content_description_close_button),
onIconClick = { onTabCloseClick(tab) },
)
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
/**
* Collapsible header for the Inactive Tabs section.
*
* @param expanded Whether the section is expanded.
* @param onClick Called when the user clicks on the header.
* @param onDeleteAllClick Called when the user clicks on the delete all button.
*/
@Composable
private fun InactiveTabsHeader(
expanded: Boolean,
onClick: () -> Unit,
onDeleteAllClick: () -> Unit,
) {
ExpandableListHeader(
headerText = stringResource(R.string.inactive_tabs_title),
expanded = expanded,
expandActionContentDescription = stringResource(R.string.inactive_tabs_expand_content_description),
collapseActionContentDescription = stringResource(R.string.inactive_tabs_collapse_content_description),
onClick = onClick,
) {
IconButton(
onClick = onDeleteAllClick,
modifier = Modifier.padding(horizontal = 4.dp),
) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = stringResource(R.string.inactive_tabs_delete_all),
tint = FirefoxTheme.colors.iconPrimary,
)
}
}
}
/**
* Inactive Tabs auto close dialog.
*
* @param onDismissClick Called when the user clicks on the auto close dialog's dismiss button.
* @param onEnableAutoCloseClick Called when the user clicks on the auto close dialog's enable button.
*/
@Composable
private fun InactiveTabsAutoClosePrompt(
onDismissClick: () -> Unit,
onEnableAutoCloseClick: () -> Unit,
) {
Card(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
shape = ROUNDED_CORNER_SHAPE,
backgroundColor = FirefoxTheme.colors.layer2,
border = BorderStroke(
width = 1.dp,
color = FirefoxTheme.colors.borderPrimary,
),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.End,
) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
PrimaryText(
text = stringResource(R.string.tab_tray_inactive_auto_close_title),
modifier = Modifier.weight(1f),
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.metropolis_semibold)),
)
IconButton(
onClick = onDismissClick,
modifier = Modifier.size(20.dp)
) {
Icon(
painter = painterResource(R.drawable.mozac_ic_close_20),
contentDescription =
stringResource(R.string.tab_tray_inactive_auto_close_button_content_description),
tint = FirefoxTheme.colors.iconPrimary,
)
}
}
SecondaryText(
text = stringResource(
R.string.tab_tray_inactive_auto_close_body_2,
stringResource(R.string.app_name)
),
modifier = Modifier.fillMaxWidth(),
fontSize = 14.sp,
)
TextButton(
text = stringResource(R.string.tab_tray_inactive_turn_on_auto_close_button_2),
onClick = onEnableAutoCloseClick,
)
}
}
}
@Composable
@Preview(name = "Auto close dialog dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Auto close dialog light", uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun InactiveTabsAutoClosePromptPreview() {
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
InactiveTabsAutoClosePrompt(
onDismissClick = {},
onEnableAutoCloseClick = {},
)
}
}
}
@Composable
@Preview(name = "Full preview dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Preview(name = "Full preview light", uiMode = Configuration.UI_MODE_NIGHT_NO)
private fun InactiveTabsListPreview() {
var expanded by remember { mutableStateOf(true) }
var showAutoClosePrompt by remember { mutableStateOf(true) }
FirefoxTheme(theme = Theme.getTheme(isPrivate = false)) {
Box(Modifier.background(FirefoxTheme.colors.layer1)) {
InactiveTabsList(
inactiveTabs = generateFakeInactiveTabsList(),
expanded = expanded,
showAutoCloseDialog = showAutoClosePrompt,
onHeaderClick = { expanded = !expanded },
onDeleteAllButtonClick = {},
onAutoCloseDismissClick = { showAutoClosePrompt = !showAutoClosePrompt },
onEnableAutoCloseClick = { showAutoClosePrompt = !showAutoClosePrompt },
onTabClick = {},
onTabCloseClick = {},
)
}
}
}
private fun generateFakeInactiveTabsList(): List<TabSessionState> =
listOf(
TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
),
TabSessionState(
id = "tabId",
content = ContentState(
url = "www.google.com",
)
),
)

@ -5,10 +5,9 @@
package org.mozilla.fenix.tabstray.viewholders package org.mozilla.fenix.tabstray.viewholders
import android.view.View import android.view.View
import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.TextView import android.widget.TextView
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor
@ -72,11 +71,11 @@ abstract class AbstractBrowserPageViewHolder(
adapterRef?.let { adapter -> adapterRef?.let { adapter ->
adapterObserver = object : RecyclerView.AdapterDataObserver() { adapterObserver = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
updateTrayVisibility(adapter.itemCount) updateTrayVisibility(showTrayList(adapter))
} }
override fun onItemRangeRemoved(positionstart: Int, itemcount: Int) { override fun onItemRangeRemoved(positionstart: Int, itemcount: Int) {
updateTrayVisibility(adapter.itemCount) updateTrayVisibility(showTrayList(adapter))
} }
} }
adapterObserver?.let { adapterObserver?.let {
@ -97,14 +96,18 @@ abstract class AbstractBrowserPageViewHolder(
adapterObserver = null adapterObserver = null
} }
} }
/**
* A way for an implementor of [AbstractBrowserPageViewHolder] to define their own behavior of
* when to show/hide the tray list and empty list UI.
*/
open fun showTrayList(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>): Boolean =
adapter.itemCount > 0
private fun updateTrayVisibility(size: Int) { /**
if (size == 0) { * Helper function used to toggle the visibility of the tabs tray lists and the empty list message.
trayList.visibility = GONE */
emptyList.visibility = VISIBLE fun updateTrayVisibility(showTrayList: Boolean) {
} else { trayList.isVisible = showTrayList
trayList.visibility = VISIBLE emptyList.isVisible = !showTrayList
emptyList.visibility = GONE
}
} }
} }

@ -6,44 +6,51 @@ package org.mozilla.fenix.tabstray.viewholders
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.selector.selectedNormalTab import mozilla.components.browser.state.selector.selectedNormalTab
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.ext.flowScoped
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.components.appstate.AppAction
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.ext.potentialInactiveTabs
import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.selection.SelectionHolder import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayAction import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayInteractor import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.containsTabId import org.mozilla.fenix.tabstray.browser.containsTabId
import org.mozilla.fenix.ext.maxActiveTime
import org.mozilla.fenix.tabstray.ext.browserAdapter import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns
import org.mozilla.fenix.tabstray.ext.getNormalTrayTabs import org.mozilla.fenix.tabstray.ext.getNormalTrayTabs
import org.mozilla.fenix.ext.potentialInactiveTabs
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.tabstray.ext.isNormalTabInactive import org.mozilla.fenix.tabstray.ext.isNormalTabInactive
import org.mozilla.fenix.tabstray.ext.observeFirstInsert import org.mozilla.fenix.tabstray.ext.observeFirstInsert
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
/** /**
* View holder for the normal tabs tray list. * View holder for the normal tabs tray list.
*/ */
class NormalBrowserPageViewHolder( class NormalBrowserPageViewHolder(
containerView: View, containerView: View,
private val lifecycleOwner: LifecycleOwner,
private val tabsTrayStore: TabsTrayStore, private val tabsTrayStore: TabsTrayStore,
private val browserStore: BrowserStore, private val browserStore: BrowserStore,
private val appStore: AppStore, private val appStore: AppStore,
interactor: TabsTrayInteractor, interactor: TabsTrayInteractor,
) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder<TabSessionState> { ) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder<TabSessionState> {
private var inactiveTabsSize = 0
/** /**
* Holds the list of selected tabs. * Holds the list of selected tabs.
* *
@ -66,6 +73,8 @@ class NormalBrowserPageViewHolder(
browserAdapter.selectionHolder = this browserAdapter.selectionHolder = this
tabGroupAdapter.selectionHolder = this tabGroupAdapter.selectionHolder = this
observeTabsTrayInactiveTabsState(adapter)
super.bind(adapter, manager) super.bind(adapter, manager)
} }
@ -98,11 +107,9 @@ class NormalBrowserPageViewHolder(
appStore.dispatch(AppAction.UpdateInactiveExpanded(true)) appStore.dispatch(AppAction.UpdateInactiveExpanded(true))
inactiveTabAdapter.observeFirstInsert { inactiveTabAdapter.observeFirstInsert {
inactiveTabsList.forEachIndexed { tabIndex, item -> inactiveTabsList.forEach { item ->
if (item.id == selectedTab.id) { if (item.id == selectedTab.id) {
// Inactive Tabs are first + inactive header item. containerView.post { layoutManager.scrollToPosition(0) }
val indexToScrollTo = tabIndex + 1
layoutManager.scrollToPosition(indexToScrollTo)
return@observeFirstInsert return@observeFirstInsert
} }
@ -132,7 +139,7 @@ class NormalBrowserPageViewHolder(
// Index is based on tabs above (inactive) with our calculated index. // Index is based on tabs above (inactive) with our calculated index.
val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex
layoutManager.scrollToPosition(indexToScrollTo) containerView.post { layoutManager.scrollToPosition(indexToScrollTo) }
if (focusGroupTabId != null) { if (focusGroupTabId != null) {
tabsTrayStore.dispatch(TabsTrayAction.ConsumeFocusGroupTabId) tabsTrayStore.dispatch(TabsTrayAction.ConsumeFocusGroupTabId)
@ -158,7 +165,7 @@ class NormalBrowserPageViewHolder(
tabGroupAdapter.itemCount + tabGroupAdapter.itemCount +
headerAdapter.itemCount + tabIndex headerAdapter.itemCount + tabIndex
layoutManager.scrollToPosition(indexToScrollTo) containerView.post { layoutManager.scrollToPosition(indexToScrollTo) }
return@observeFirstInsert return@observeFirstInsert
} }
@ -167,6 +174,24 @@ class NormalBrowserPageViewHolder(
} }
} }
// Temporary hack until https://github.com/mozilla-mobile/fenix/issues/21901 where the
// logic that shows/hides the "Your open tabs will be shown here." message will no longer be derived
// from adapters, view holders, and item counts.
override fun showTrayList(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>): Boolean {
return inactiveTabsSize > 0 || adapter.itemCount > 1 // InactiveTabsAdapter will always return 1
}
private fun observeTabsTrayInactiveTabsState(adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>) {
tabsTrayStore.flowScoped(lifecycleOwner) { flow ->
flow.map { state -> state.inactiveTabs }
.ifChanged()
.collect { inactiveTabs ->
inactiveTabsSize = inactiveTabs.size
updateTrayVisibility(showTrayList(adapter))
}
}
}
private fun setupLayoutManager( private fun setupLayoutManager(
context: Context, context: Context,
concatAdapter: ConcatAdapter concatAdapter: ConcatAdapter

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android" android:constantSize="true">
<item android:drawable="@drawable/rounded_top_corners" android:state_activated="true" />
<item android:drawable="@drawable/rounded_all_corners" />
</selector>

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<stroke
android:width="1dp"
android:color="?borderPrimary" />
<corners android:radius="8dp" />
<solid android:color="?attr/layer2" />
</shape>

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/layer2" />
<corners android:bottomLeftRadius="@dimen/tab_corner_radius" android:bottomRightRadius="@dimen/tab_corner_radius" />
</shape>

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?attr/layer2" />
<corners android:topLeftRadius="@dimen/tab_corner_radius" android:topRightRadius="@dimen/tab_corner_radius" />
</shape>

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="8dp"
android:background="@drawable/rounded_bottom_corners"
android:backgroundTint="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingEnd="1dp"
android:paddingBottom="1dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/rounded_bottom_corners"
android:paddingBottom="8dp">
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

@ -1,70 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/inactive_header_border"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:background="@drawable/card_list_row_background"
android:backgroundTint="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingTop="1dp"
android:paddingEnd="1dp">
<androidx.constraintlayout.widget.ConstraintLayout xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="48dp"
android:background="@drawable/card_list_row_background"
android:clickable="false"
android:clipToPadding="false"
android:focusable="true"
android:foreground="?android:attr/selectableItemBackground"
android:paddingStart="16dp">
<TextView
android:id="@+id/inactive_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:gravity="start"
android:maxLines="1"
android:minLines="1"
android:text="@string/inactive_tabs_title"
android:textAppearance="@style/Header16TextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Inactive tabs" />
<ImageView
android:id="@+id/chevron"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:contentDescription="@string/tab_menu"
app:layout_constraintBottom_toBottomOf="@id/inactive_title"
app:layout_constraintStart_toEndOf="@id/inactive_title"
app:layout_constraintTop_toTopOf="@id/inactive_title"
app:srcCompat="@drawable/ic_chevron" />
<ImageView
android:id="@+id/delete"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:contentDescription="@string/inactive_tabs_delete_all"
android:foreground="?android:attr/selectableItemBackground"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_delete" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:background="?borderPrimary"
android:importantForAccessibility="no"
android:paddingStart="1dp"
android:paddingEnd="1dp">
<mozilla.components.ui.widgets.WidgetSiteItemView
android:id="@+id/site_list_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/layer2"
android:foreground="?android:attr/selectableItemBackground"
android:minHeight="@dimen/mozac_widget_site_item_height" />
</FrameLayout>

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:paddingHorizontal="1dp"
android:background="?borderPrimary">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/layer2"
android:clickable="false"
android:clipToPadding="false"
android:focusable="true"
android:padding="12dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/banner_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="false"
android:clipToPadding="false"
android:background="@drawable/inactive_tab_auto_close_border_background"
android:focusable="true"
android:padding="12dp">
<TextView
android:id="@+id/banner_info_message"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:padding="8dp"
android:text="@string/tab_tray_inactive_auto_close_title"
android:textAppearance="@style/Header14TextStyle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/close_button"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/close_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/tab_tray_inactive_auto_close_button_content_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_close" />
<TextView
android:id="@+id/message"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:padding="8dp"
android:text="@string/tab_tray_inactive_auto_close_body_2"
android:textAppearance="@style/Body14TextStyle"
app:layout_constraintTop_toBottomOf="@id/banner_info_message"
app:layout_constraintStart_toStartOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/action"
style="@style/DialogButtonStyleDark"
android:background="?android:attr/selectableItemBackground"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="3dp"
android:textAllCaps="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:text="@string/tab_tray_inactive_turn_on_auto_close_button_2"
app:layout_constraintTop_toBottomOf="@+id/message" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
</FrameLayout>

@ -700,7 +700,7 @@
<!-- Open tabs menu item to save tabs to collection --> <!-- Open tabs menu item to save tabs to collection -->
<string name="tabs_menu_save_to_collection1">Save tabs to collection</string> <string name="tabs_menu_save_to_collection1">Save tabs to collection</string>
<!-- Content description (not visible, for screen readers etc.): Opens the tab menu when pressed --> <!-- Content description (not visible, for screen readers etc.): Opens the tab menu when pressed -->
<string name="tab_menu">Tab menu</string> <string name="tab_menu" moz:RemovedIn="103" tools:ignore="UnusedResources">Tab menu</string>
<!-- Text for the menu button to delete a collection --> <!-- Text for the menu button to delete a collection -->
<string name="collection_delete">Delete collection</string> <string name="collection_delete">Delete collection</string>
<!-- Text for the menu button to rename a collection --> <!-- Text for the menu button to rename a collection -->
@ -1739,6 +1739,10 @@
<string name="inactive_tabs_title">Inactive tabs</string> <string name="inactive_tabs_title">Inactive tabs</string>
<!-- Content description for closing all inactive tabs --> <!-- Content description for closing all inactive tabs -->
<string name="inactive_tabs_delete_all">Close all inactive tabs</string> <string name="inactive_tabs_delete_all">Close all inactive tabs</string>
<!-- Content description for expanding the inactive tabs section. -->
<string name="inactive_tabs_expand_content_description">Expand inactive tabs</string>
<!-- Content description for collapsing the inactive tabs section. -->
<string name="inactive_tabs_collapse_content_description">Collapse inactive tabs</string>
<!-- Inactive tabs auto-close message in the tabs tray --> <!-- Inactive tabs auto-close message in the tabs tray -->
<!-- The header text of the auto-close message when the user is asked if they want to turn on the auto-closing of inactive tabs. --> <!-- The header text of the auto-close message when the user is asked if they want to turn on the auto-closing of inactive tabs. -->

@ -213,6 +213,7 @@ class TabsTrayFragmentTest {
@Test @Test
fun `WHEN setupPager is called THEN it sets the tray adapter and disables user initiated scrolling`() { fun `WHEN setupPager is called THEN it sets the tray adapter and disables user initiated scrolling`() {
val store: TabsTrayStore = mockk() val store: TabsTrayStore = mockk()
val lifecycleOwner = mockk<LifecycleOwner>(relaxed = true)
val trayInteractor: TabsTrayInteractor = mockk() val trayInteractor: TabsTrayInteractor = mockk()
val browserInteractor: BrowserTrayInteractor = mockk() val browserInteractor: BrowserTrayInteractor = mockk()
val navigationInteractor: NavigationInteractor = mockk() val navigationInteractor: NavigationInteractor = mockk()
@ -220,13 +221,19 @@ class TabsTrayFragmentTest {
every { context.components.core.store } returns browserStore every { context.components.core.store } returns browserStore
fragment.setupPager( fragment.setupPager(
context, store, trayInteractor, browserInteractor, navigationInteractor context = context,
lifecycleOwner = lifecycleOwner,
store = store,
trayInteractor = trayInteractor,
browserInteractor = browserInteractor,
navigationInteractor = navigationInteractor,
) )
val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter) val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter)
assertSame(context, adapter.context) assertSame(context, adapter.context)
assertSame(lifecycleOwner, adapter.lifecycleOwner)
assertSame(store, adapter.tabsTrayStore) assertSame(store, adapter.tabsTrayStore)
assertSame(trayInteractor, adapter.interactor) assertSame(trayInteractor, adapter.tabsTrayInteractor)
assertSame(browserInteractor, adapter.browserInteractor) assertSame(browserInteractor, adapter.browserInteractor)
assertSame(navigationInteractor, adapter.navInteractor) assertSame(navigationInteractor, adapter.navInteractor)
assertSame(browserStore, adapter.browserStore) assertSame(browserStore, adapter.browserStore)

@ -6,6 +6,8 @@ package org.mozilla.fenix.tabstray.browser
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState
import org.junit.Test import org.junit.Test
class DefaultInactiveTabsInteractorTest { class DefaultInactiveTabsInteractorTest {
@ -39,4 +41,36 @@ class DefaultInactiveTabsInteractorTest {
verify { controller.enableAutoClosed() } verify { controller.enableAutoClosed() }
} }
@Test
fun `WHEN an inactive tab is clicked THEN open the tab`() {
val controller: InactiveTabsController = mockk(relaxed = true)
val interactor = DefaultInactiveTabsInteractor(controller)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
interactor.onTabClicked(tab)
verify { controller.openInactiveTab(tab) }
}
@Test
fun `WHEN an inactive tab is clicked to be closed THEN close the tab`() {
val controller: InactiveTabsController = mockk(relaxed = true)
val interactor = DefaultInactiveTabsInteractor(controller)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
interactor.onTabClosed(tab)
verify { controller.closeInactiveTab(tab) }
}
} }

@ -1,39 +0,0 @@
/* 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.LayoutInflater
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder
@RunWith(FenixRobolectricTestRunner::class)
class InactiveTabViewHolderTest {
@Test
fun `HeaderHolder - WHEN clicked THEN notify the interactor`() {
every { testContext.components.appStore } returns AppStore()
val view = LayoutInflater.from(testContext).inflate(HeaderHolder.LAYOUT_ID, null)
val interactor: InactiveTabsInteractor = mockk(relaxed = true)
val tabsTrayInteractor: TabsTrayInteractor = mockk(relaxed = true)
val viewHolder = HeaderHolder(view, interactor, tabsTrayInteractor)
val initialActivatedState = view.isActivated
viewHolder.itemView.performClick()
verify { interactor.onHeaderClicked(any()) }
assertEquals(!initialActivatedState, view.isActivated)
}
}

@ -1,58 +0,0 @@
/* 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 mozilla.components.browser.state.state.createTab
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.support.test.ext.joinBlocking
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.mozilla.fenix.tabstray.TabsTrayAction
import org.mozilla.fenix.tabstray.TabsTrayStore
class InactiveTabsBindingTest {
val store = TabsTrayStore()
val tray: TabsTray = mockk(relaxed = true)
val binding = InactiveTabsBinding(store, tray)
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@After
fun teardown() {
binding.stop()
}
@Test
fun `WHEN the store is updated THEN notify the tabs tray`() {
assertTrue(store.state.inactiveTabs.isEmpty())
store.dispatch(TabsTrayAction.UpdateInactiveTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
binding.start()
assertTrue(store.state.inactiveTabs.isNotEmpty())
verify { tray.updateTabs(any(), any(), any()) }
}
@Test
fun `WHEN non-inactive tabs are updated THEN do not notify the tabs tray`() {
assertTrue(store.state.inactiveTabs.isEmpty())
store.dispatch(TabsTrayAction.UpdatePrivateTabs(listOf(createTab("https://mozilla.org")))).joinBlocking()
binding.start()
assertTrue(store.state.inactiveTabs.isEmpty())
verify { tray.updateTabs(emptyList(), null, null) }
}
}

@ -4,73 +4,37 @@
package org.mozilla.fenix.tabstray.browser package org.mozilla.fenix.tabstray.browser
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.slot
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import mozilla.components.browser.state.state.ContentState
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.browser.tabstray.TabsTray
import mozilla.components.service.glean.testing.GleanTestRule import mozilla.components.service.glean.testing.GleanTestRule
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.robolectric.testContext import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import mozilla.components.browser.state.state.createTab as createTabState
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.components.AppStore
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.tabstray.TabsTrayState import org.mozilla.fenix.tabstray.TrayPagerAdapter
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.utils.Settings import org.mozilla.fenix.utils.Settings
import org.mozilla.fenix.GleanMetrics.TabsTray as TabsTrayMetrics
@RunWith(FenixRobolectricTestRunner::class) // for gleanTestRule @RunWith(FenixRobolectricTestRunner::class)
class InactiveTabsControllerTest { class InactiveTabsControllerTest {
private val settings: Settings = mockk(relaxed = true) private val settings: Settings = mockk(relaxed = true)
private val browserInteractor: BrowserTrayInteractor = mockk(relaxed = true)
private val appStore = AppStore() private val appStore = AppStore()
@get:Rule @get:Rule
val gleanTestRule = GleanTestRule(testContext) val gleanTestRule = GleanTestRule(testContext)
@Test @Test
fun `WHEN expanded THEN notify filtered card`() { fun `WHEN the inactive tabs section is expanded THEN the expanded telemetry event should be report`() {
val store = TabsTrayStore( val controller = InactiveTabsController(appStore, settings, browserInteractor)
TabsTrayState(
inactiveTabs = listOf(
createTabState("https://mozilla.org", id = "1"),
createTabState("https://firefox.com", id = "2")
)
)
)
val tray: TabsTray = mockk(relaxed = true)
val tabsSlot = slot<List<TabSessionState>>()
val controller =
InactiveTabsController(store, appStore, tray, settings)
controller.updateCardExpansion(true)
appStore.waitUntilIdle()
verify { tray.updateTabs(capture(tabsSlot), null, any()) }
assertEquals(2, tabsSlot.captured.size)
assertEquals("1", tabsSlot.captured.first().id)
}
@Test
fun `WHEN expanded THEN track telemetry event`() {
val store = TabsTrayStore()
val controller = InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue()) assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue())
assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue()) assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue())
@ -82,11 +46,8 @@ class InactiveTabsControllerTest {
} }
@Test @Test
fun `WHEN collapsed THEN track telemetry event`() { fun `WHEN the inactive tabs section is collapsed THEN the collapsed telemetry event should be report`() {
val store = TabsTrayStore() val controller = InactiveTabsController(appStore, settings, browserInteractor)
val controller = InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue()) assertFalse(TabsTrayMetrics.inactiveTabsExpanded.testHasValue())
assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue()) assertFalse(TabsTrayMetrics.inactiveTabsCollapsed.testHasValue())
@ -98,15 +59,8 @@ class InactiveTabsControllerTest {
} }
@Test @Test
fun `WHEN close THEN update settings and refresh`() { fun `WHEN the inactive tabs auto-close feature prompt is dismissed THEN update settings and report the telemetry event`() {
val store = TabsTrayStore() val controller = spyk(InactiveTabsController(appStore, settings, browserInteractor))
val controller = spyk(
InactiveTabsController(
store, appStore, mockk(relaxed = true), settings
)
)
every { controller.refreshInactiveTabsSection() } just Runs
assertFalse(TabsTrayMetrics.autoCloseDimissed.testHasValue()) assertFalse(TabsTrayMetrics.autoCloseDimissed.testHasValue())
@ -114,29 +68,59 @@ class InactiveTabsControllerTest {
assertTrue(TabsTrayMetrics.autoCloseDimissed.testHasValue()) assertTrue(TabsTrayMetrics.autoCloseDimissed.testHasValue())
verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true } verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
verify { controller.refreshInactiveTabsSection() }
} }
@Test @Test
fun `WHEN enableAutoClosed THEN update closeTabsAfterOneMonth settings and refresh`() { fun `WHEN the inactive tabs auto-close feature prompt is accepted THEN update settings and report the telemetry event`() {
val filter: (TabSessionState) -> Boolean = { !it.content.private } val controller = spyk(InactiveTabsController(appStore, settings, browserInteractor))
val store = BrowserStore()
val tray: TabsTray = mockk(relaxed = true)
val controller =
spyk(InactiveTabsAutoCloseDialogController(store, settings, filter, tray))
every { controller.refreshInactiveTabsSection() } just Runs
assertFalse(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue()) assertFalse(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue())
controller.enableAutoClosed() controller.enableAutoClosed()
assertTrue(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue()) assertTrue(TabsTrayMetrics.autoCloseTurnOnClicked.testHasValue())
verify { settings.closeTabsAfterOneMonth = true } verify { settings.closeTabsAfterOneMonth = true }
verify { settings.closeTabsAfterOneWeek = false } verify { settings.closeTabsAfterOneWeek = false }
verify { settings.closeTabsAfterOneDay = false } verify { settings.closeTabsAfterOneDay = false }
verify { settings.manuallyCloseTabs = false } verify { settings.manuallyCloseTabs = false }
verify { controller.refreshInactiveTabsSection() } verify { settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true }
}
@Test
fun `WHEN an inactive tab is selected THEN the open the tab and report the telemetry event`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
assertFalse(TabsTrayMetrics.openInactiveTab.testHasValue())
controller.openInactiveTab(tab)
verify { browserInteractor.onTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
assertTrue(TabsTrayMetrics.openInactiveTab.testHasValue())
}
@Test
fun `WHEN an inactive tab is closed THEN the close the tab and report the telemetry event`() {
val controller = InactiveTabsController(appStore, settings, browserInteractor)
val tab = TabSessionState(
id = "tabId",
content = ContentState(
url = "www.mozilla.com",
)
)
assertFalse(TabsTrayMetrics.openInactiveTab.testHasValue())
controller.openInactiveTab(tab)
verify { browserInteractor.onTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) }
assertTrue(TabsTrayMetrics.openInactiveTab.testHasValue())
} }
} }

Loading…
Cancel
Save