For #21236: Separate tabs with the same search term into a different section (#21177)

* For #21236: Separate tabs with the same search term into a different section in tabs tray

* Issue #21236: Scroll to selected tab + various tab fixes for groupings

* Issue #21236: Fix failing test

Co-authored-by: Jonathan Almeida <jalmeida@mozilla.com>
upstream-sync
Roger Yang 3 years ago committed by GitHub
parent 0b18d04a23
commit fc18fd2520
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -63,4 +63,9 @@ object FeatureFlags {
* Enables customizing the home screen
*/
val customizeHome = Config.channel.isNightlyOrDebug
/**
* Identifies and separates the tabs list with a group containing search term tabs.
*/
val tabGroupFeature = Config.channel.isNightlyOrDebug
}

@ -10,16 +10,13 @@ import android.view.ViewGroup
import androidx.annotation.VisibleForTesting
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.selector.selectedTab
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.sync.SyncedTabsAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter
import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter
import org.mozilla.fenix.tabstray.browser.maxActiveTime
import org.mozilla.fenix.tabstray.ext.isNormalTabActive
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter
import org.mozilla.fenix.tabstray.syncedtabs.TabClickDelegate
import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder
import org.mozilla.fenix.tabstray.viewholders.NormalBrowserPageViewHolder
@ -28,48 +25,62 @@ import org.mozilla.fenix.tabstray.viewholders.SyncedTabsPageViewHolder
class TrayPagerAdapter(
@VisibleForTesting internal val context: Context,
@VisibleForTesting internal val store: TabsTrayStore,
@VisibleForTesting internal val tabsTrayStore: TabsTrayStore,
@VisibleForTesting internal val browserInteractor: BrowserTrayInteractor,
@VisibleForTesting internal val navInteractor: NavigationInteractor,
@VisibleForTesting internal val interactor: TabsTrayInteractor,
@VisibleForTesting internal val browserStore: BrowserStore
) : RecyclerView.Adapter<AbstractPageViewHolder>() {
/**
* N.B: Scrolling to the selected tab depends on the order of these adapters. If you change
* the ordering or add/remove an adapter, please update [NormalBrowserPageViewHolder.scrollToTab] and
* the layout manager.
*/
private val normalAdapter by lazy {
ConcatAdapter(
BrowserTabsAdapter(context, browserInteractor, store, TABS_TRAY_FEATURE_NAME),
InactiveTabsAdapter(context, browserInteractor, INACTIVE_TABS_FEATURE_NAME)
InactiveTabsAdapter(context, browserInteractor, INACTIVE_TABS_FEATURE_NAME),
TabGroupAdapter(context, browserInteractor, tabsTrayStore, TAB_GROUP_FEATURE_NAME),
TitleHeaderAdapter(browserStore),
BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, TABS_TRAY_FEATURE_NAME)
)
}
private val privateAdapter by lazy { BrowserTabsAdapter(context, browserInteractor, store, TABS_TRAY_FEATURE_NAME) }
private val syncedTabsAdapter by lazy { SyncedTabsAdapter(TabClickDelegate(navInteractor)) }
private val privateAdapter by lazy {
BrowserTabsAdapter(
context,
browserInteractor,
tabsTrayStore,
TABS_TRAY_FEATURE_NAME
)
}
private val syncedTabsAdapter by lazy {
SyncedTabsAdapter(TabClickDelegate(navInteractor))
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractPageViewHolder {
val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
val selectedTab = browserStore.state.selectedTab
return when (viewType) {
NormalBrowserPageViewHolder.LAYOUT_ID -> {
NormalBrowserPageViewHolder(
itemView,
store,
interactor,
browserStore.state.normalTabs.filter { it.isNormalTabActive(maxActiveTime) }.indexOf(selectedTab)
tabsTrayStore,
browserStore,
interactor
)
}
PrivateBrowserPageViewHolder.LAYOUT_ID -> {
PrivateBrowserPageViewHolder(
itemView,
store,
interactor,
browserStore.state.privateTabs.indexOf(selectedTab)
tabsTrayStore,
browserStore,
interactor
)
}
SyncedTabsPageViewHolder.LAYOUT_ID -> {
SyncedTabsPageViewHolder(
itemView,
store
tabsTrayStore
)
}
else -> throw IllegalStateException("Unknown viewType.")
@ -102,6 +113,7 @@ class TrayPagerAdapter(
// Telemetry keys for identifying from which app features the a was opened / closed.
const val TABS_TRAY_FEATURE_NAME = "Tabs tray"
const val TAB_GROUP_FEATURE_NAME = "Tab group"
const val INACTIVE_TABS_FEATURE_NAME = "Inactive tabs"
val POSITION_NORMAL_TABS = Page.NormalTabs.ordinal

@ -1,69 +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.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageButton
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.ext.increaseTapArea
import kotlin.math.max
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
/**
* A RecyclerView ViewHolder implementation for "tab" items with grid layout.
*
* @param imageLoader [ImageLoader] used to load tab thumbnails.
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s.
* @param itemView [View] that displays a "tab".
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class BrowserTabGridViewHolder(
imageLoader: ImageLoader,
override val browserTrayInteractor: BrowserTrayInteractor,
store: TabsTrayStore,
selectionHolder: SelectionHolder<Tab>? = null,
itemView: View,
featureName: String
) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) {
private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close)
override val thumbnailSize: Int
get() = max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width)
)
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
val binding = TabTrayGridItemBinding.bind(itemView)
binding.tabTrayGridItem.background = if (showAsSelected) {
AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border)
} else {
null
}
return
}
override fun bind(
tab: Tab,
isSelected: Boolean,
styling: TabsTrayStyling,
observable: Observable<TabsTray.Observer>
) {
super.bind(tab, isSelected, styling, observable)
closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS)
}
}

@ -1,53 +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.View
import androidx.core.content.ContextCompat
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.tabstray.Tab
import org.mozilla.fenix.R
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import kotlin.math.max
/**
* A RecyclerView ViewHolder implementation for "tab" items with list layout.
*
* @param imageLoader [ImageLoader] used to load tab thumbnails.
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s.
* @param itemView [View] that displays a "tab".
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class BrowserTabListViewHolder(
imageLoader: ImageLoader,
override val browserTrayInteractor: BrowserTrayInteractor,
store: TabsTrayStore,
selectionHolder: SelectionHolder<Tab>? = null,
itemView: View,
featureName: String
) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) {
override val thumbnailSize: Int
get() = max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width)
)
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
val color = if (showAsSelected) {
R.color.tab_tray_item_selected_background_normal_theme
} else {
R.color.tab_tray_item_background_normal_theme
}
itemView.setBackgroundColor(
ContextCompat.getColor(
itemView.context,
color
)
)
}
}

@ -0,0 +1,120 @@
/* 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 androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.AppCompatImageButton
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.concept.base.images.ImageLoader
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.ext.increaseTapArea
import kotlin.math.max
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
sealed class BrowserTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
/**
* A RecyclerView ViewHolder implementation for "tab" items with grid layout.
*
* @param imageLoader [ImageLoader] used to load tab thumbnails.
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s.
* @param itemView [View] that displays a "tab".
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class GridViewHolder(
imageLoader: ImageLoader,
override val browserTrayInteractor: BrowserTrayInteractor,
store: TabsTrayStore,
selectionHolder: SelectionHolder<Tab>? = null,
itemView: View,
featureName: String
) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) {
private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close)
override val thumbnailSize: Int
get() = max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width)
)
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
val binding = TabTrayGridItemBinding.bind(itemView)
binding.tabTrayGridItem.background = if (showAsSelected) {
AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border)
} else {
null
}
return
}
override fun bind(
tab: Tab,
isSelected: Boolean,
styling: TabsTrayStyling,
observable: Observable<TabsTray.Observer>
) {
super.bind(tab, isSelected, styling, observable)
closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS)
}
companion object {
const val LAYOUT_ID = R.layout.tab_tray_grid_item
}
}
/**
* A RecyclerView ViewHolder implementation for "tab" items with list layout.
*
* @param imageLoader [ImageLoader] used to load tab thumbnails.
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param selectionHolder [SelectionHolder]<[Tab]> for helping with selecting any number of displayed [Tab]s.
* @param itemView [View] that displays a "tab".
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class ListViewHolder(
imageLoader: ImageLoader,
override val browserTrayInteractor: BrowserTrayInteractor,
store: TabsTrayStore,
selectionHolder: SelectionHolder<Tab>? = null,
itemView: View,
featureName: String
) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) {
override val thumbnailSize: Int
get() = max(
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height),
itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width)
)
override fun updateSelectedTabIndicator(showAsSelected: Boolean) {
val color = if (showAsSelected) {
R.color.tab_tray_item_selected_background_normal_theme
} else {
R.color.tab_tray_item_background_normal_theme
}
itemView.setBackgroundColor(
ContextCompat.getColor(
itemView.context,
color
)
)
}
companion object {
const val LAYOUT_ID = R.layout.tab_tray_item
}
}
}

@ -16,7 +16,6 @@ import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import mozilla.components.support.base.observer.ObserverRegistry
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.databinding.TabTrayItemBinding
@ -45,8 +44,8 @@ class BrowserTabsAdapter(
* The layout types for the tabs.
*/
enum class ViewType(val layoutRes: Int) {
LIST(R.layout.tab_tray_item),
GRID(R.layout.tab_tray_grid_item)
LIST(BrowserTabViewHolder.ListViewHolder.LAYOUT_ID),
GRID(BrowserTabViewHolder.GridViewHolder.LAYOUT_ID)
}
/**
@ -58,10 +57,13 @@ class BrowserTabsAdapter(
private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage)
override fun getItemViewType(position: Int): Int {
return if (context.components.settings.gridTabView) {
ViewType.GRID.layoutRes
} else {
ViewType.LIST.layoutRes
return when {
context.components.settings.gridTabView -> {
ViewType.GRID.layoutRes
}
else -> {
ViewType.LIST.layoutRes
}
}
}
@ -70,9 +72,9 @@ class BrowserTabsAdapter(
return when (viewType) {
ViewType.GRID.layoutRes ->
BrowserTabGridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
else ->
BrowserTabListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
}
}

@ -22,5 +22,5 @@ class DefaultInactiveTabsInteractor(
* TODO This should be replaced with the AppStore.
*/
object InactiveTabsState {
var isExpanded = true
var isExpanded = false
}

@ -15,7 +15,10 @@ import org.mozilla.fenix.ext.components
import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.isNormalTabActive
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithoutSearchTerm
import org.mozilla.fenix.tabstray.ext.isNormalTabInactive
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
import java.util.concurrent.TimeUnit
/**
@ -48,12 +51,37 @@ class NormalBrowserTrayList @JvmOverloads constructor(
if (!FeatureFlags.inactiveTabs) {
return@TabsFeature !state.content.private
}
state.isNormalTabActive(maxActiveTime)
if (!FeatureFlags.tabGroupFeature) {
state.isNormalTabActive(maxActiveTime)
} else {
state.isNormalTabActiveWithoutSearchTerm(maxActiveTime)
}
},
{}
)
}
private val searchTermFeature by lazy {
val store = context.components.core.store
val tabFilter: (TabSessionState) -> Boolean = filter@{
if (!FeatureFlags.tabGroupFeature) {
return@filter false
}
it.isNormalTabActiveWithSearchTerm(maxActiveTime)
}
val tabsAdapter = concatAdapter.tabGroupAdapter
TabsFeature(
tabsAdapter,
store,
selectTabUseCase,
removeTabUseCase,
tabFilter,
{}
)
}
/**
* NB: The setup for this feature is a bit complicated without a better dependency injection
* solution to scope it down to just this view.
@ -95,8 +123,9 @@ class NormalBrowserTrayList @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
tabsFeature.start()
inactiveFeature.start()
searchTermFeature.start()
tabsFeature.start()
touchHelper.attachToRecyclerView(this)
}
@ -105,6 +134,7 @@ class NormalBrowserTrayList @JvmOverloads constructor(
super.onDetachedFromWindow()
tabsFeature.stop()
searchTermFeature.stop()
inactiveFeature.stop()
touchHelper.attachToRecyclerView(null)

@ -4,10 +4,10 @@
package org.mozilla.fenix.tabstray.browser
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
@ -23,19 +23,21 @@ import org.mozilla.fenix.tabstray.TabsTrayStore
@OptIn(ExperimentalCoroutinesApi::class)
class SelectedItemAdapterBinding(
store: TabsTrayStore,
val adapter: BrowserTabsAdapter
val adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>
) : AbstractBinding<TabsTrayState>(store) {
override suspend fun onState(flow: Flow<TabsTrayState>) {
flow.map { it.mode }
// ignore initial mode update; the adapter is already in an updated state.
.drop(1)
.ifChanged()
.collect { mode ->
notifyAdapter(mode)
}
}
/**
* N.B: This method should be made more performant to find the position of the multi-selected tab that has
* changed in the adapter, and then [RecyclerView.Adapter.notifyItemChanged].
*/
private fun notifyAdapter(mode: Mode) = with(adapter) {
if (mode == Mode.Normal) {
notifyItemRangeChanged(0, itemCount, PAYLOAD_HIGHLIGHT_SELECTED_ITEM)

@ -0,0 +1,143 @@
/* 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 TabGroupViewHolder
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.HORIZONTAL
import androidx.recyclerview.widget.RecyclerView.VERTICAL
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.ObserverRegistry
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter.Group
import kotlin.math.max
import mozilla.components.concept.tabstray.Tab as TabsTrayTab
import mozilla.components.support.base.observer.Observable
typealias TrayObservable = Observable<TabsTray.Observer>
/**
* The [ListAdapter] for displaying the list of search term tabs.
*
* @param context [Context] used for various platform interactions or accessing [Components]
* @param browserTrayInteractor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
* @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry].
*/
@Suppress("TooManyFunctions")
class TabGroupAdapter(
private val context: Context,
private val browserTrayInteractor: BrowserTrayInteractor,
private val store: TabsTrayStore,
private val featureName: String,
delegate: TrayObservable = ObserverRegistry()
) : ListAdapter<Group, TabGroupViewHolder>(DiffCallback), TabsTray, TrayObservable by delegate {
data class Group(
/**
* A title for the tab group.
*/
val title: String,
/**
* The list of tabs belonging to this tab group.
*/
val tabs: List<TabsTrayTab>,
/**
* The last time tabs in this group was accessed.
*/
val lastAccess: Long
)
/**
* Tracks the selected tabs in multi-select mode.
*/
var selectionHolder: SelectionHolder<TabsTrayTab>? = null
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabGroupViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return when {
context.components.settings.gridTabView -> {
TabGroupViewHolder(view, HORIZONTAL, browserTrayInteractor, store, selectionHolder)
}
else -> {
TabGroupViewHolder(view, VERTICAL, browserTrayInteractor, store, selectionHolder)
}
}
}
override fun onBindViewHolder(holder: TabGroupViewHolder, position: Int) {
val group = getItem(position)
holder.bind(group, this)
}
override fun getItemViewType(position: Int) = TabGroupViewHolder.LAYOUT_ID
/**
* Notify the nested [RecyclerView] when this view has been attached.
*/
override fun onViewAttachedToWindow(holder: TabGroupViewHolder) {
holder.rebind()
}
/**
* Notify the nested [RecyclerView] when this view has been detached.
*/
override fun onViewDetachedFromWindow(holder: TabGroupViewHolder) {
holder.unbind()
}
/**
* Creates a grouping of data classes for how groupings will be structured.
*/
override fun updateTabs(tabs: Tabs) {
val data = tabs.list.groupBy { it.searchTerm.lowercase() }
val grouping = data.map { mapEntry ->
val searchTerm = mapEntry.key.replaceFirstChar(Char::uppercase)
val groupTabs = mapEntry.value
val groupMax = groupTabs.fold(0L) { acc, tab ->
max(tab.lastAccess, acc)
}
Group(
title = searchTerm,
tabs = groupTabs,
lastAccess = groupMax
)
}.sortedBy { it.lastAccess }
submitList(grouping)
}
/**
* Not implemented; handled by nested [RecyclerView].
*/
override fun isTabSelected(tabs: Tabs, position: Int): Boolean = false
override fun onTabsChanged(position: Int, count: Int) = Unit
override fun onTabsInserted(position: Int, count: Int) = Unit
override fun onTabsMoved(fromPosition: Int, toPosition: Int) = Unit
override fun onTabsRemoved(position: Int, count: Int) = Unit
private object DiffCallback : DiffUtil.ItemCallback<Group>() {
override fun areItemsTheSame(oldItem: Group, newItem: Group) = oldItem.title == newItem.title
override fun areContentsTheSame(oldItem: Group, newItem: Group) = oldItem == newItem
}
}
internal fun Group.containsTabId(tabId: String): Boolean {
return tabs.firstOrNull { it.id == tabId } != null
}

@ -0,0 +1,154 @@
/* 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.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM
import mozilla.components.browser.tabstray.TabsTrayStyling
import mozilla.components.browser.thumbnails.loader.ThumbnailLoader
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabTrayGridItemBinding
import org.mozilla.fenix.databinding.TabTrayItemBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.dpToPx
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.MIN_COLUMN_WIDTH_DP
/**
* The [ListAdapter] for displaying the list of tabs that have the same search term.
*
* @param context [Context] used for various platform interactions or accessing [Components]
* @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray.
* @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that.
* @param delegate [Observable]<[TabsTray.Observer]> for observing tabs tray changes. Defaults to [ObserverRegistry].
* @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting.
*/
class TabGroupListAdapter(
private val context: Context,
private val interactor: BrowserTrayInteractor,
private val store: TabsTrayStore,
private val delegate: Observable<TabsTray.Observer>,
private val selectionHolder: SelectionHolder<Tab>?,
private val featureName: String,
) : ListAdapter<Tab, AbstractBrowserTabViewHolder>(DiffCallback) {
private val selectedItemAdapterBinding = SelectedItemAdapterBinding(store, this)
private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): AbstractBrowserTabViewHolder {
return when {
context.components.settings.gridTabView -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_grid_item, parent, false)
view.layoutParams.width = view.dpToPx(MIN_COLUMN_WIDTH_DP.toFloat())
BrowserTabViewHolder.GridViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
}
else -> {
val view = LayoutInflater.from(parent.context).inflate(R.layout.tab_tray_item, parent, false)
BrowserTabViewHolder.ListViewHolder(imageLoader, interactor, store, selectionHolder, view, featureName)
}
}
}
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int) {
val tab = getItem(position)
val selectedTabId = context.components.core.store.state.selectedTabId
holder.bind(tab, tab.id == selectedTabId, TabsTrayStyling(), delegate)
holder.tab?.let { holderTab ->
when {
context.components.settings.gridTabView -> {
val gridBinding = TabTrayGridItemBinding.bind(holder.itemView)
gridBinding.mozacBrowserTabstrayClose.setOnClickListener {
interactor.close(holderTab, featureName)
}
}
else -> {
val listBinding = TabTrayItemBinding.bind(holder.itemView)
listBinding.mozacBrowserTabstrayClose.setOnClickListener {
interactor.close(holderTab, featureName)
}
}
}
}
}
/**
* Over-ridden [onBindViewHolder] that uses the payloads to notify the selected tab how to
* display itself.
*
* N.B: this is a modified version of [BrowserTabsAdapter.onBindViewHolder].
*/
override fun onBindViewHolder(holder: AbstractBrowserTabViewHolder, position: Int, payloads: List<Any>) {
val tabs = currentList
val selectedTabId = context.components.core.store.state.selectedTabId
val selectedIndex = tabs.indexOfFirst { it.id == selectedTabId }
if (tabs.isEmpty()) return
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
return
}
if (position == selectedIndex) {
if (payloads.contains(PAYLOAD_HIGHLIGHT_SELECTED_ITEM)) {
holder.updateSelectedTabIndicator(true)
} else if (payloads.contains(PAYLOAD_DONT_HIGHLIGHT_SELECTED_ITEM)) {
holder.updateSelectedTabIndicator(false)
}
}
selectionHolder?.let {
var selectedMaskView: View? = null
when (getItemViewType(position)) {
BrowserTabsAdapter.ViewType.GRID.layoutRes -> {
val gridBinding = TabTrayGridItemBinding.bind(holder.itemView)
selectedMaskView = gridBinding.checkboxInclude.selectedMask
}
BrowserTabsAdapter.ViewType.LIST.layoutRes -> {
val listBinding = TabTrayItemBinding.bind(holder.itemView)
selectedMaskView = listBinding.checkboxInclude.selectedMask
}
}
holder.showTabIsMultiSelectEnabled(selectedMaskView, it.selectedItems.contains(holder.tab))
}
}
override fun getItemViewType(position: Int): Int {
return when {
context.components.settings.gridTabView -> {
BrowserTabsAdapter.ViewType.GRID.layoutRes
}
else -> {
BrowserTabsAdapter.ViewType.LIST.layoutRes
}
}
}
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
selectedItemAdapterBinding.start()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
selectedItemAdapterBinding.stop()
}
private object DiffCallback : DiffUtil.ItemCallback<Tab>() {
override fun areItemsTheSame(oldItem: Tab, newItem: Tab) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Tab, newItem: Tab) = oldItem == newItem
}
}

@ -0,0 +1,84 @@
/* 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/. */
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.concept.tabstray.Tab
import mozilla.components.concept.tabstray.TabsTray
import mozilla.components.support.base.observer.Observable
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabGroupItemBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.TrayPagerAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter
import org.mozilla.fenix.tabstray.browser.TabGroupListAdapter
/**
* A RecyclerView ViewHolder implementation for tab group items.
*
* @param itemView [View] that displays a "tab".
* @param orientation [Int] orientation of the items. Horizontal for grid layout, vertical for list layout
* @param interactor the [BrowserTrayInteractor] for tab interactions.
* @param store the [TabsTrayStore] instance.
* @param selectionHolder the store that holds the currently selected tabs.
*/
class TabGroupViewHolder(
itemView: View,
val orientation: Int,
val interactor: BrowserTrayInteractor,
val store: TabsTrayStore,
val selectionHolder: SelectionHolder<Tab>? = null
) : RecyclerView.ViewHolder(itemView) {
private val binding = TabGroupItemBinding.bind(itemView)
lateinit var groupListAdapter: TabGroupListAdapter
fun bind(
group: TabGroupAdapter.Group,
observable: Observable<TabsTray.Observer>
) {
val selectedTabId = itemView.context.components.core.store.state.selectedTabId
val selectedIndex = group.tabs.indexOfFirst { it.id == selectedTabId }
binding.tabGroupTitle.text = group.title
binding.tabGroupList.apply {
layoutManager = LinearLayoutManager(itemView.context, orientation, false)
groupListAdapter = TabGroupListAdapter(
context = itemView.context,
interactor = interactor,
store = store,
delegate = observable,
selectionHolder = selectionHolder,
featureName = TrayPagerAdapter.TAB_GROUP_FEATURE_NAME
)
adapter = groupListAdapter
groupListAdapter.submitList(group.tabs)
scrollToPosition(selectedIndex)
}
}
/**
* Notify the nested [RecyclerView] that it has been detached.
*/
fun unbind() {
groupListAdapter.onDetachedFromRecyclerView(binding.tabGroupList)
}
/**
* Notify the nested [RecyclerView] that it has been attached. This is so our observers know when to start again.
*/
fun rebind() {
groupListAdapter.onAttachedToRecyclerView(binding.tabGroupList)
}
companion object {
const val LAYOUT_ID = R.layout.tab_group_item
}
}

@ -0,0 +1,73 @@
/* 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 android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.TabTrayTitleHeaderItemBinding
/**
* A [RecyclerView.Adapter] for tab header.
*/
class TitleHeaderAdapter(
browserStore: BrowserStore
) : ListAdapter<TitleHeaderAdapter.Header, TitleHeaderAdapter.HeaderViewHolder>(DiffCallback) {
class Header
private val normalTabsHeaderBinding = TitleHeaderBinding(browserStore, ::handleListChanges)
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HeaderViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return HeaderViewHolder(view)
}
override fun getItemViewType(position: Int) = HeaderViewHolder.LAYOUT_ID
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
normalTabsHeaderBinding.start()
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
normalTabsHeaderBinding.stop()
}
/* Do nothing */
override fun onBindViewHolder(holder: HeaderViewHolder, position: Int) = Unit
private fun handleListChanges(showHeader: Boolean) {
val header = if (showHeader) {
listOf(Header())
} else {
emptyList()
}
submitList(header)
}
class HeaderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val binding = TabTrayTitleHeaderItemBinding.bind(itemView)
fun bind() {
binding.tabTrayHeaderTitle.text =
itemView.context.getString(R.string.tab_tray_header_title)
}
companion object {
const val LAYOUT_ID = R.layout.tab_tray_title_header_item
}
}
private object DiffCallback : DiffUtil.ItemCallback<Header>() {
override fun areItemsTheSame(oldItem: Header, newItem: Header) = true
override fun areContentsTheSame(oldItem: Header, newItem: Header) = true
}
}

@ -0,0 +1,36 @@
/* 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.lib.state.helpers.AbstractBinding
import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifChanged
import org.mozilla.fenix.tabstray.ext.normalTrayTabs
/**
* A binding class to notify an observer to show a title if there is at least one tab available.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TitleHeaderBinding(
store: BrowserStore,
private val showHeader: (Boolean) -> Unit
) : AbstractBinding<BrowserState>(store) {
override suspend fun onState(flow: Flow<BrowserState>) {
flow.map { it.normalTrayTabs }
.ifChanged { it.size }
.collect {
if (it.isEmpty()) {
showHeader(false)
} else {
showHeader(true)
}
}
}
}

@ -6,7 +6,9 @@ package org.mozilla.fenix.tabstray.ext
import androidx.recyclerview.widget.ConcatAdapter
import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter
import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter
import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter
import org.mozilla.fenix.tabstray.browser.TabGroupAdapter
/**
* A convenience binding for retrieving the [BrowserTabsAdapter] from the [ConcatAdapter].
@ -19,3 +21,15 @@ internal val ConcatAdapter.browserAdapter
*/
internal val ConcatAdapter.inactiveTabsAdapter
get() = adapters.find { it is InactiveTabsAdapter } as InactiveTabsAdapter
/**
* A convenience binding for retrieving the [TabGroupAdapter] from the [ConcatAdapter].
*/
internal val ConcatAdapter.tabGroupAdapter
get() = adapters.find { it is TabGroupAdapter } as TabGroupAdapter
/**
* A convenience binding for retrieving the [TitleHeaderAdapter] from the [ConcatAdapter].
*/
internal val ConcatAdapter.titleHeaderAdapter
get() = adapters.find { it is TitleHeaderAdapter } as TitleHeaderAdapter

@ -8,7 +8,7 @@ import android.content.Context
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.tabstray.browser.AutoCloseInterval
private const val MIN_COLUMN_WIDTH_DP = 180
const val MIN_COLUMN_WIDTH_DP = 180
/**
* Returns the number of grid columns we can fit on the screen in the tabs tray.

@ -0,0 +1,20 @@
/* 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 androidx.recyclerview.widget.RecyclerView
/**
* Observes the adapter and invokes the callback [block] only when data is first inserted to the adapter.
*/
fun <VH : RecyclerView.ViewHolder> RecyclerView.Adapter<out VH>.observeFirstInsert(block: () -> Unit) {
val observer = object : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
block.invoke()
unregisterAdapterDataObserver(this)
}
}
registerAdapterDataObserver(observer)
}

@ -0,0 +1,57 @@
/* 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 mozilla.components.browser.state.selector.normalTabs
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.state.BrowserState
import mozilla.components.browser.state.state.TabSessionState
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.tabstray.browser.maxActiveTime
/**
* The currently selected tab if there's one that is private.
*
* NB: Upstream to Selectors.kt.
*/
val BrowserState.selectedPrivateTab: TabSessionState?
get() = selectedTabId?.let { id -> findPrivateTab(id) }
/**
* Finds and returns the private tab with the given id. Returns null if no
* matching tab could be found.
*
* @param tabId The ID of the tab to search for.
* @return The [TabSessionState] with the provided [tabId] or null if it could not be found.
*
* NB: Upstream to Selectors.kt.
*/
fun BrowserState.findPrivateTab(tabId: String): TabSessionState? {
return privateTabs.firstOrNull { it.id == tabId }
}
/**
* The list of inactive tabs in the tabs tray filtered based on [maxActiveTime].
*/
val BrowserState.inactiveTabs: List<TabSessionState>
get() = normalTabs.filter { it.isNormalTabInactive(maxActiveTime) }
/**
* The list of normal tabs in the tabs tray filtered appropriately based on feature flags.
*/
val BrowserState.normalTrayTabs: List<TabSessionState>
get() {
return normalTabs.run {
if (FeatureFlags.tabGroupFeature && FeatureFlags.inactiveTabs) {
filter { it.isNormalTabActiveWithoutSearchTerm(maxActiveTime) }
} else if (FeatureFlags.inactiveTabs) {
filter { it.isNormalTabActive(maxActiveTime) }
} else if (FeatureFlags.tabGroupFeature) {
filter { it.isNormalTabWithSearchTerm() }
} else {
this
}
}
}

@ -13,14 +13,43 @@ private fun TabSessionState.isActive(maxActiveTime: Long): Boolean {
}
/**
* Returns true if a [TabSessionState] is considered active based on the [maxActiveTime].
* Returns true if the [TabSessionState] has a search term.
*/
private fun TabSessionState.hasSearchTerm(): Boolean {
return content.searchTerms.isNotEmpty() || !historyMetadata?.searchTerm.isNullOrBlank()
}
/**
* Returns true if the [TabSessionState] is considered active based on the [maxActiveTime].
*/
internal fun TabSessionState.isNormalTabActive(maxActiveTime: Long): Boolean {
return isActive(maxActiveTime) && !content.private
}
/**
* Returns true if a [TabSessionState] is considered active based on the [maxActiveTime].
* Returns true if the [TabSessionState] is considered active based on the [maxActiveTime] and
* does not have a search term
*/
internal fun TabSessionState.isNormalTabActiveWithoutSearchTerm(maxActiveTime: Long): Boolean {
return isNormalTabActive(maxActiveTime) && !hasSearchTerm()
}
/**
* Returns true if the [TabSessionState] have a search term.
*/
internal fun TabSessionState.isNormalTabActiveWithSearchTerm(maxActiveTime: Long): Boolean {
return isNormalTabActive(maxActiveTime) && hasSearchTerm()
}
/**
* Returns true if the [TabSessionState] has a search term but may or may not be active.
*/
internal fun TabSessionState.isNormalTabWithSearchTerm(): Boolean {
return hasSearchTerm() && !content.private
}
/**
* Returns true if the [TabSessionState] is considered active based on the [maxActiveTime].
*/
internal fun TabSessionState.isNormalTabInactive(maxActiveTime: Long): Boolean {
return !isActive(maxActiveTime) && !content.private

@ -14,6 +14,7 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.AbstractBrowserTrayList
import org.mozilla.fenix.tabstray.ext.observeFirstInsert
/**
* A shared view holder for browser tabs tray list.
@ -22,7 +23,6 @@ abstract class AbstractBrowserPageViewHolder(
containerView: View,
tabsTrayStore: TabsTrayStore,
interactor: TabsTrayInteractor,
private val currentTabIndex: Int
) : AbstractPageViewHolder(containerView) {
private val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item)
@ -35,17 +35,23 @@ abstract class AbstractBrowserPageViewHolder(
emptyList.text = emptyStringText
}
/**
* A way for an implementor of [AbstractBrowserPageViewHolder] to define their own scroll-to-tab behaviour.
*/
abstract fun scrollToTab(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>,
layoutManager: RecyclerView.LayoutManager
)
@CallSuper
protected fun bind(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>,
layoutManager: RecyclerView.LayoutManager
) {
adapter.registerAdapterDataObserver(
OneTimeAdapterObserver(adapter) {
trayList.scrollToPosition(currentTabIndex)
updateTrayVisibility(adapter.itemCount)
}
)
adapter.observeFirstInsert {
updateTrayVisibility(adapter.itemCount)
}
scrollToTab(adapter, layoutManager)
trayList.layoutManager = layoutManager
trayList.adapter = adapter
}
@ -60,16 +66,3 @@ abstract class AbstractBrowserPageViewHolder(
}
}
}
/**
* Observes the adapter and invokes the callback when data is first inserted.
*/
class OneTimeAdapterObserver(
private val adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>,
private val onAdapterReady: () -> Unit
) : RecyclerView.AdapterDataObserver() {
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
onAdapterReady.invoke()
adapter.unregisterAdapterDataObserver(this)
}
}

@ -4,33 +4,42 @@
package org.mozilla.fenix.tabstray.viewholders
import android.content.Context
import android.view.View
import androidx.recyclerview.widget.ConcatAdapter
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.selector.selectedNormalTab
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.tabstray.Tab
import org.mozilla.fenix.FeatureFlags
import org.mozilla.fenix.R
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.browser.InactiveTabsState
import org.mozilla.fenix.tabstray.browser.containsTabId
import org.mozilla.fenix.tabstray.browser.maxActiveTime
import org.mozilla.fenix.tabstray.ext.browserAdapter
import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns
import org.mozilla.fenix.tabstray.ext.inactiveTabs
import org.mozilla.fenix.tabstray.ext.titleHeaderAdapter
import org.mozilla.fenix.tabstray.ext.inactiveTabsAdapter
import org.mozilla.fenix.tabstray.ext.isNormalTabInactive
import org.mozilla.fenix.tabstray.ext.isNormalTabActiveWithSearchTerm
import org.mozilla.fenix.tabstray.ext.normalTrayTabs
import org.mozilla.fenix.tabstray.ext.observeFirstInsert
import org.mozilla.fenix.tabstray.ext.tabGroupAdapter
/**
* View holder for the normal tabs tray list.
*/
class NormalBrowserPageViewHolder(
containerView: View,
private val store: TabsTrayStore,
private val tabsTrayStore: TabsTrayStore,
private val browserStore: BrowserStore,
interactor: TabsTrayInteractor,
currentTabIndex: Int
) : AbstractBrowserPageViewHolder(
containerView,
store,
interactor,
currentTabIndex
),
SelectionHolder<Tab> {
) : AbstractBrowserPageViewHolder(containerView, tabsTrayStore, interactor), SelectionHolder<Tab> {
/**
* Holds the list of selected tabs.
@ -39,7 +48,7 @@ class NormalBrowserPageViewHolder(
* to select tabs.
*/
override val selectedItems: Set<Tab>
get() = store.state.mode.selectedTabs
get() = tabsTrayStore.state.mode.selectedTabs
override val emptyStringText: String
get() = itemView.resources.getString(R.string.no_open_tabs_description)
@ -47,23 +56,115 @@ class NormalBrowserPageViewHolder(
override fun bind(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>
) {
val browserAdapter = (adapter as ConcatAdapter).browserAdapter
val concatAdapter = adapter as ConcatAdapter
val browserAdapter = concatAdapter.browserAdapter
val tabGroupAdapter = concatAdapter.tabGroupAdapter
val manager = setupLayoutManager(containerView.context, concatAdapter)
browserAdapter.selectionHolder = this
tabGroupAdapter.selectionHolder = this
super.bind(adapter, manager)
}
/**
* Add giant explanation why this is complicated.
*/
override fun scrollToTab(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>,
layoutManager: RecyclerView.LayoutManager
) {
val concatAdapter = adapter as ConcatAdapter
val headerAdapter = concatAdapter.titleHeaderAdapter
val browserAdapter = concatAdapter.browserAdapter
val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter
val tabGroupAdapter = concatAdapter.tabGroupAdapter
val selectedTab = browserStore.state.selectedNormalTab ?: return
val number = containerView.context.defaultBrowserLayoutColumns
val manager = GridLayoutManager(containerView.context, number).apply {
// Update tabs into the inactive adapter.
if (FeatureFlags.inactiveTabs && selectedTab.isNormalTabInactive(maxActiveTime)) {
val inactiveTabsList = browserStore.state.inactiveTabs
// We want to expand the inactive section first before we want to fire our scroll observer.
InactiveTabsState.isExpanded = true
inactiveTabAdapter.observeFirstInsert {
inactiveTabsList.forEachIndexed { tabIndex, item ->
if (item.id == selectedTab.id) {
// Inactive Tabs are first + inactive header item.
val indexToScrollTo = tabIndex + 1
layoutManager.scrollToPosition(indexToScrollTo)
return@observeFirstInsert
}
}
}
}
// Updates tabs into the search term group adapter.
if (FeatureFlags.tabGroupFeature && selectedTab.isNormalTabActiveWithSearchTerm(maxActiveTime)) {
tabGroupAdapter.observeFirstInsert {
// With a grouping, we need to use the list of the adapter that is already grouped
// together for the UI, so we know the final index of the grouping to scroll to.
//
// N.B: Why are we using currentList here and no where else? `currentList` is an API on top of
// `ListAdapter` which is updated when the [ListAdapter.submitList] is invoked. For our BrowserAdapter
// as an example, the updates are coming from [TabsFeature] which internally uses the internal
// [DiffUtil.calculateDiff] directly to submit a changed list which evades the `ListAdapter` from being
// notified of updates, so it therefore returns an empty list.
tabGroupAdapter.currentList.forEachIndexed { groupIndex, group ->
if (group.containsTabId(selectedTab.id)) {
// Index is based on tabs above (inactive) with our calculated index.
val indexToScrollTo = inactiveTabAdapter.itemCount + groupIndex
layoutManager.scrollToPosition(indexToScrollTo)
return@observeFirstInsert
}
}
}
}
// Updates tabs into the normal browser tabs adapter.
browserAdapter.observeFirstInsert {
val activeTabsList = browserStore.state.normalTrayTabs
activeTabsList.forEachIndexed { tabIndex, trayTab ->
if (trayTab.id == selectedTab.id) {
// Index is based on tabs above (inactive + groups + header) with our calculated index.
val indexToScrollTo = inactiveTabAdapter.itemCount +
tabGroupAdapter.itemCount +
headerAdapter.itemCount + tabIndex
layoutManager.scrollToPosition(indexToScrollTo)
return@observeFirstInsert
}
}
}
}
private fun setupLayoutManager(
context: Context,
concatAdapter: ConcatAdapter
): GridLayoutManager {
val headerAdapter = concatAdapter.titleHeaderAdapter
val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter
val tabGroupAdapter = concatAdapter.tabGroupAdapter
val numberOfColumns = containerView.context.defaultBrowserLayoutColumns
return GridLayoutManager(context, numberOfColumns).apply {
spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (position >= browserAdapter.itemCount) {
number
} else {
return if (position >= inactiveTabAdapter.itemCount + tabGroupAdapter.itemCount +
headerAdapter.itemCount
) {
1
} else {
numberOfColumns
}
}
}
}
super.bind(adapter, manager)
}
companion object {

@ -7,29 +7,44 @@ package org.mozilla.fenix.tabstray.viewholders
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.state.selector.privateTabs
import mozilla.components.browser.state.store.BrowserStore
import org.mozilla.fenix.R
import org.mozilla.fenix.tabstray.TabsTrayInteractor
import org.mozilla.fenix.tabstray.TabsTrayStore
import org.mozilla.fenix.tabstray.ext.defaultBrowserLayoutColumns
import org.mozilla.fenix.tabstray.ext.observeFirstInsert
import org.mozilla.fenix.tabstray.ext.selectedPrivateTab
/**
* View holder for the private tabs tray list.
*/
class PrivateBrowserPageViewHolder(
containerView: View,
store: TabsTrayStore,
interactor: TabsTrayInteractor,
currentTabIndex: Int
tabsTrayStore: TabsTrayStore,
private val browserStore: BrowserStore,
interactor: TabsTrayInteractor
) : AbstractBrowserPageViewHolder(
containerView,
store,
tabsTrayStore,
interactor,
currentTabIndex
) {
override val emptyStringText: String
get() = itemView.resources.getString(R.string.no_private_tabs_description)
override fun scrollToTab(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>,
layoutManager: RecyclerView.LayoutManager
) {
adapter.observeFirstInsert {
val selectedTab = browserStore.state.selectedPrivateTab ?: return@observeFirstInsert
val scrollIndex = browserStore.state.privateTabs.indexOf(selectedTab)
layoutManager.scrollToPosition(scrollIndex)
}
}
override fun bind(
adapter: RecyclerView.Adapter<out RecyclerView.ViewHolder>
) {

@ -0,0 +1,47 @@
<!-- 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/. -->
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:layout_marginBottom="15dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/group_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_search" />
<TextView
android:id="@+id/tab_group_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="11dp"
android:ellipsize="end"
android:gravity="start"
android:maxLines="1"
android:textAppearance="@style/Header16TextStyle"
app:layout_constraintBottom_toBottomOf="@id/group_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/group_icon"
app:layout_constraintTop_toTopOf="@id/group_icon"
tools:text="Cats" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/tab_group_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tab_group_title" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,24 @@
<?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/. -->
<TextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/tab_tray_header_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:clickable="false"
android:clipToPadding="false"
android:ellipsize="end"
android:focusable="false"
android:gravity="start"
android:maxLines="1"
android:text="@string/tab_tray_header_title"
android:textAppearance="@style/Header16TextStyle"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

@ -740,6 +740,8 @@
<string name="pwa_site_controls_title_private">%1$s (Private Mode)</string>
<!-- Button in the current tab tray header in multiselect mode. Saved the selected tabs to a collection when pressed. -->
<string name="tab_tray_save_to_collection">Save</string>
<!-- Text for normal tabs header title. -->
<string name="tab_tray_header_title">Other</string>
<!-- History -->
<!-- Text for the button to clear all history -->

@ -217,7 +217,7 @@ class TabsTrayFragmentTest {
val adapter = (tabsTrayBinding.tabsTray.adapter as TrayPagerAdapter)
assertSame(context, adapter.context)
assertSame(store, adapter.store)
assertSame(store, adapter.tabsTrayStore)
assertSame(trayInteractor, adapter.interactor)
assertSame(browserInteractor, adapter.browserInteractor)
assertSame(navigationInteractor, adapter.navInteractor)

@ -56,7 +56,7 @@ class BrowserTabsAdapterTest {
val adapter = BrowserTabsAdapter(context, interactor, store, "Test")
val binding = TabTrayItemBinding.inflate(LayoutInflater.from(testContext))
val holder = spyk(
BrowserTabListViewHolder(
BrowserTabViewHolder.ListViewHolder(
imageLoader = mockk(),
browserTrayInteractor = interactor,
store = store,

@ -32,18 +32,6 @@ class SelectedItemAdapterBindingTest {
every { adapter.itemCount }.answers { 1 }
}
@Test
fun `WHEN observing on start THEN ignore the initial state update`() {
val store = TabsTrayStore()
val binding = SelectedItemAdapterBinding(store, adapter)
binding.start()
verify(exactly = 0) {
adapter.notifyItemRangeChanged(any(), any(), any())
}
}
@Test
fun `WHEN mode changes THEN notify the adapter`() {
val store = TabsTrayStore()

@ -0,0 +1,84 @@
/* 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.ExperimentalCoroutinesApi
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.concept.storage.HistoryMetadataKey
import mozilla.components.support.test.libstate.ext.waitUntilIdle
import mozilla.components.support.test.rule.MainCoroutineRule
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class TitleHeaderBindingTest {
@OptIn(ExperimentalCoroutinesApi::class)
@get:Rule
val coroutinesTestRule = MainCoroutineRule()
@Test
fun `WHEN normal tabs are added to the list THEN return true`() {
var result = false
val store = BrowserStore()
val binding = TitleHeaderBinding(store) { result = it }
store.dispatch(TabListAction.AddTabAction(createTab("https://mozilla.org")))
binding.start()
store.waitUntilIdle()
assertTrue(result)
}
@Test
fun `WHEN grouped tabs are added to the list THEN return false`() {
var result = false
val store = BrowserStore()
val binding = TitleHeaderBinding(store) { result = it }
store.dispatch(
TabListAction.AddTabAction(
createTab(
url = "https://mozilla.org",
historyMetadata = HistoryMetadataKey(
url = "https://getpocket.com",
searchTerm = "Mozilla"
)
)
)
)
binding.start()
store.waitUntilIdle()
assertFalse(result)
}
@Test
fun `WHEN normal tabs are all removed THEN return false`() {
var result = false
val store = BrowserStore(
initialState = BrowserState(
tabs = listOf(createTab("https://getpocket.com", id = "123"))
)
)
val binding = TitleHeaderBinding(store) { result = it }
store.dispatch(TabListAction.RemoveTabAction("123"))
binding.start()
store.waitUntilIdle()
assertFalse(result)
}
}

@ -5,6 +5,7 @@
package org.mozilla.fenix.tabstray.ext
import mozilla.components.browser.state.state.createTab
import mozilla.components.concept.storage.HistoryMetadataKey
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
@ -103,4 +104,26 @@ class TabSessionStateKtTest {
)
assertFalse(tab.isNormalTabActive(maxTime))
}
@Test
fun `WHEN tab has a search term or metadata THEN return true `() {
val tab = createTab(
url = "https://mozilla.org",
createdAt = System.currentTimeMillis(),
historyMetadata = HistoryMetadataKey("https://getpockjet.com", "cats")
)
val tab2 = createTab(
url = "https://mozilla.org",
createdAt = System.currentTimeMillis(),
searchTerms = "dogs"
)
val tab3 = createTab(
url = "https://mozilla.org",
createdAt = inactiveTimestamp,
searchTerms = "dogs"
)
assertTrue(tab.isNormalTabActiveWithSearchTerm(maxTime))
assertTrue(tab2.isNormalTabActiveWithSearchTerm(maxTime))
assertFalse(tab3.isNormalTabActiveWithSearchTerm(maxTime))
}
}

@ -9,6 +9,7 @@ import android.view.View.GONE
import android.view.View.VISIBLE
import android.widget.TextView
import io.mockk.mockk
import mozilla.components.browser.state.store.BrowserStore
import mozilla.components.concept.tabstray.Tabs
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertTrue
@ -25,16 +26,17 @@ import org.mozilla.fenix.tabstray.browser.createTab
@RunWith(FenixRobolectricTestRunner::class)
class AbstractBrowserPageViewHolderTest {
val store: TabsTrayStore = TabsTrayStore()
val tabsTrayStore: TabsTrayStore = TabsTrayStore()
val browserStore = BrowserStore()
val interactor = mockk<TabsTrayInteractor>(relaxed = true)
val browserTrayInteractor = mockk<BrowserTrayInteractor>(relaxed = true)
val adapter = BrowserTabsAdapter(testContext, browserTrayInteractor, store, "Test")
val adapter = BrowserTabsAdapter(testContext, browserTrayInteractor, tabsTrayStore, "Test")
@Test
fun `WHEN tabs inserted THEN show tray`() {
val itemView =
LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null)
val viewHolder = PrivateBrowserPageViewHolder(itemView, store, interactor, 5)
val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor)
val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item)
val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view)
@ -58,7 +60,7 @@ class AbstractBrowserPageViewHolderTest {
fun `WHEN no tabs THEN show empty view`() {
val itemView =
LayoutInflater.from(testContext).inflate(R.layout.normal_browser_tray_list, null)
val viewHolder = PrivateBrowserPageViewHolder(itemView, store, interactor, 5)
val viewHolder = PrivateBrowserPageViewHolder(itemView, tabsTrayStore, browserStore, interactor)
val trayList: AbstractBrowserTrayList = itemView.findViewById(R.id.tray_list_item)
val emptyList: TextView = itemView.findViewById(R.id.tab_tray_empty_view)

Loading…
Cancel
Save