diff --git a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt new file mode 100644 index 0000000000..9461f0078f --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt @@ -0,0 +1,109 @@ +/* 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.ext + +import androidx.annotation.VisibleForTesting +import mozilla.components.service.pocket.PocketRecommendedStory +import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory + +/** + * Get the list of stories to be displayed. + * Either the stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] either + * filtered stories based on the user selected categories. + * + * @param neededStoriesCount how many stories are intended to be displayed. + * This only impacts filtered results guaranteeing an even spread of stories from each category. + * + * @return a list of [PocketRecommendedStory]es from the currently selected categories + * topped if necessary with stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount]. + */ +fun HomeFragmentState.getFilteredStories( + neededStoriesCount: Int +): List { + val currentlySelectedCategories = pocketStoriesCategories.filter { it.isSelected } + + if (currentlySelectedCategories.isEmpty()) { + return pocketStoriesCategories + .find { + it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME + }?.stories?.take(neededStoriesCount) ?: emptyList() + } + + val oldestSortedCategories = currentlySelectedCategories + .sortedBy { it.lastInteractedWithTimestamp } + + val filteredStoriesCount = getFilteredStoriesCount( + pocketStoriesCategories, oldestSortedCategories, neededStoriesCount + ) + + // Add general stories at the end of the stories list to show until neededStoriesCount + val generalStoriesTopup = filteredStoriesCount[POCKET_STORIES_DEFAULT_CATEGORY_NAME]?.let { neededTopups -> + pocketStoriesCategories.find { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }?.stories?.take(neededTopups) + } ?: emptyList() + + return oldestSortedCategories + .flatMap { it.stories.take(filteredStoriesCount[it.name]!!) } + .plus(generalStoriesTopup) + .take(neededStoriesCount) +} + +/** + * Get how many stories needs to be shown from each currently selected category. + * + * If the selected categories together don't have [neededStoriesCount] stories then + * the difference is added from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] category. + * + * @param allCategories the list of all Pocket stories categories. + * @param selectedCategories ordered list of categories from which to return results. + * @param neededStoriesCount how many stories are intended to be displayed. + * This impacts the results by guaranteeing an even spread of stories from each category in that stories count. + * + * @return a mapping of how many stories are to be shown from each category from [selectedCategories]. + * The result is topped with stories counts from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount]. + */ +@VisibleForTesting +@Suppress("ReturnCount", "NestedBlockDepth") +internal fun getFilteredStoriesCount( + allCategories: List, + selectedCategories: List, + neededStoriesCount: Int +): Map { + val totalStoriesInFilteredCategories = selectedCategories.fold(0) { availableStories, category -> + availableStories + category.stories.size + } + + when { + totalStoriesInFilteredCategories == neededStoriesCount -> { + return selectedCategories.map { it.name to it.stories.size }.toMap() + } + totalStoriesInFilteredCategories < neededStoriesCount -> { + return selectedCategories.map { it.name to it.stories.size }.toMap() + + allCategories.filter { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.map { + it.name to (neededStoriesCount - totalStoriesInFilteredCategories).coerceAtMost(it.stories.size) + }.toMap() + } + else -> { + val storiesCountFromEachCategory = mutableMapOf() + var currentFilteredStoriesCount = 0 + + for (i in 0 until selectedCategories.maxOf { it.stories.size }) { + selectedCategories.forEach { category -> + if (category.stories.getOrNull(i) != null) { + storiesCountFromEachCategory[category.name] = + storiesCountFromEachCategory[category.name]?.inc() ?: 1 + + if (++currentFilteredStoriesCount == neededStoriesCount) { + return storiesCountFromEachCategory + } + } + } + } + } + } + + return emptyMap() +} diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index e3bec2e0fc..05aa9b2983 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -47,7 +47,6 @@ import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map @@ -111,6 +110,8 @@ import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor import org.mozilla.fenix.home.sessioncontrol.SessionControlView import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.DefaultPocketStoriesController +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.DefaultTopSitesView import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.settings.SupportUtils @@ -241,9 +242,12 @@ class HomeFragment : Fragment() { } if (requireContext().settings().pocketRecommendations) { - lifecycleScope.async(IO) { - val stories = components.core.pocketStoriesService.getStories() - homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(stories)) + lifecycleScope.launch(IO) { + val categories = components.core.pocketStoriesService.getStories() + .groupBy { story -> story.category } + .map { (category, stories) -> PocketRecommendedStoryCategory(category, stories) } + + homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(categories)) } } @@ -327,6 +331,9 @@ class HomeFragment : Fragment() { ), historyMetadataController = DefaultHistoryMetadataController( navController = findNavController() + ), + pocketStoriesController = DefaultPocketStoriesController( + homeStore = homeFragmentStore ) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt index bd4cf1a370..9944db642f 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -15,7 +15,10 @@ import mozilla.components.lib.state.State import mozilla.components.lib.state.Store import mozilla.components.service.pocket.PocketRecommendedStory import org.mozilla.fenix.components.tips.Tip +import org.mozilla.fenix.ext.getFilteredStories import org.mozilla.fenix.historymetadata.HistoryMetadataGroup +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_TO_SHOW_COUNT +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory /** * The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s. @@ -63,7 +66,8 @@ data class HomeFragmentState( val recentTabs: List = emptyList(), val recentBookmarks: List = emptyList(), val historyMetadata: List = emptyList(), - val pocketStories: List = emptyList() + val pocketStories: List = emptyList(), + val pocketStoriesCategories: List = emptyList() ) : State sealed class HomeFragmentAction : Action { @@ -89,11 +93,16 @@ sealed class HomeFragmentAction : Action { data class RecentTabsChange(val recentTabs: List) : HomeFragmentAction() data class RecentBookmarksChange(val recentBookmarks: List) : HomeFragmentAction() data class HistoryMetadataChange(val historyMetadata: List) : HomeFragmentAction() + data class SelectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction() + data class DeselectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction() data class PocketStoriesChange(val pocketStories: List) : HomeFragmentAction() + data class PocketStoriesCategoriesChange(val storiesCategories: List) : + HomeFragmentAction() object RemoveCollectionsPlaceholder : HomeFragmentAction() object RemoveSetDefaultBrowserCard : HomeFragmentAction() } +@Suppress("ReturnCount") private fun homeFragmentStateReducer( state: HomeFragmentState, action: HomeFragmentAction @@ -132,6 +141,43 @@ private fun homeFragmentStateReducer( is HomeFragmentAction.RecentTabsChange -> state.copy(recentTabs = action.recentTabs) is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks) is HomeFragmentAction.HistoryMetadataChange -> state.copy(historyMetadata = action.historyMetadata) + is HomeFragmentAction.SelectPocketStoriesCategory -> { + // Selecting a category means the stories to be displayed needs to also be changed. + val updatedCategoriesState = state.copy( + pocketStoriesCategories = state.pocketStoriesCategories.map { + when (it.name == action.categoryName) { + true -> it.copy(isSelected = true) + false -> it + } + } + ) + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.DeselectPocketStoriesCategory -> { + val updatedCategoriesState = state.copy( + // Deselecting a category means the stories to be displayed needs to also be changed. + pocketStoriesCategories = state.pocketStoriesCategories.map { + when (it.name == action.categoryName) { + true -> it.copy(isSelected = false) + false -> it + } + } + ) + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ) + } + is HomeFragmentAction.PocketStoriesCategoriesChange -> { + // Whenever categories change stories to be displayed needs to also be changed. + val updatedCategoriesState = state.copy(pocketStoriesCategories = action.storiesCategories) + return updatedCategoriesState.copy( + pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) + ).also { + println("just updated stories in the state") + } + } is HomeFragmentAction.PocketStoriesChange -> state.copy(pocketStories = action.pocketStories) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index 1474e7dbcf..1a02ef459d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -233,7 +233,8 @@ class SessionControlAdapter( PocketStoriesViewHolder.LAYOUT_ID -> return PocketStoriesViewHolder( ComposeView(parent.context), store, - components.core.client + components.core.client, + interactor = interactor ) RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder( composeView = ComposeView(parent.context), diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index 669a4a615c..8f684360d8 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -17,6 +17,9 @@ import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksControll import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesController +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesInteractor /** * Interface for tab related actions in the [SessionControlInteractor]. @@ -216,14 +219,15 @@ interface ExperimentCardInteractor { /** * Interactor for the Home screen. Provides implementations for the CollectionInteractor, * OnboardingInteractor, TopSiteInteractor, TipInteractor, TabSessionInteractor, - * ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, and RecentBookmarksInteractor. + * ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, RecentBookmarksInteractor and others. */ @SuppressWarnings("TooManyFunctions") class SessionControlInteractor( private val controller: SessionControlController, private val recentTabController: RecentTabController, private val recentBookmarksController: RecentBookmarksController, - private val historyMetadataController: HistoryMetadataController + private val historyMetadataController: HistoryMetadataController, + private val pocketStoriesController: PocketStoriesController ) : CollectionInteractor, OnboardingInteractor, TopSiteInteractor, @@ -234,7 +238,8 @@ class SessionControlInteractor( RecentTabInteractor, RecentBookmarksInteractor, HistoryMetadataInteractor, - CustomizeHomeIteractor { + CustomizeHomeIteractor, + PocketStoriesInteractor { override fun onCollectionAddTabTapped(collection: TabCollection) { controller.handleCollectionAddTabTapped(collection) @@ -365,4 +370,8 @@ class SessionControlInteractor( override fun openCustomizeHomePage() { controller.handleCustomizeHomeTapped() } + + override fun onCategoryClick(categoryClicked: PocketRecommendedStoryCategory) { + pocketStoriesController.handleCategoryClick(categoryClicked) + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoryCategory.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoryCategory.kt new file mode 100644 index 0000000000..f141bf0394 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketRecommendedStoryCategory.kt @@ -0,0 +1,30 @@ +/* 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.home.sessioncontrol.viewholders.pocket + +import mozilla.components.service.pocket.PocketRecommendedStory + +/** + * Category name of the default category from which stories are to be shown + * if user hasn't explicitly selected others. + */ +const val POCKET_STORIES_DEFAULT_CATEGORY_NAME = "general" + +/** + * Pocket assigned topic of interest for each story. + * + * One to many relationship with [PocketRecommendedStory]es. + * + * @property name The exact name of each category. Case sensitive. + * @property stories All [PocketRecommendedStory]es with this category. + * @property isSelected Whether this category is currently selected by the user. + * @property lastInteractedWithTimestamp Last time the user selected or deselected this category. + */ +data class PocketRecommendedStoryCategory( + val name: String, + val stories: List = emptyList(), + val isSelected: Boolean = false, + val lastInteractedWithTimestamp: Long = 0L +) diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt index 243e685312..16a2734001 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt @@ -11,11 +11,14 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -26,6 +29,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.ContentAlpha import androidx.compose.material.ExperimentalMaterialApi @@ -33,6 +37,7 @@ import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.LocalContentAlpha import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -46,6 +51,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -54,6 +61,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import mozilla.components.concept.fetch.Client import mozilla.components.concept.fetch.MutableHeaders @@ -182,6 +191,157 @@ fun PocketStories( } } +/** + * Displays a list of [PocketRecommendedStoryCategory]. + * + * @param categories the categories needed to be displayed. + * @param onCategoryClick callback for when the user taps a category. + */ +@Composable +fun PocketStoriesCategories( + categories: List, + onCategoryClick: (PocketRecommendedStoryCategory) -> Unit +) { + StaggeredHorizontalGrid { + categories.forEach { category -> + PocketStoryCategory(category) { + onCategoryClick(it) + } + } + } +} + +/** + * Displays an individual [PocketRecommendedStoryCategory]. + * + * @param category the categories needed to be displayed. + * @param onClick callback for when the user taps this category. + */ +@Composable +fun PocketStoryCategory( + category: PocketRecommendedStoryCategory, + onClick: (PocketRecommendedStoryCategory) -> Unit +) { + val contentColor = when (category.isSelected) { + true -> Color.Blue + false -> Color.DarkGray + } + + OutlinedButton( + onClick = { onClick(category) }, + shape = RoundedCornerShape(32.dp), + border = BorderStroke(1.dp, contentColor), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor + ), + contentPadding = PaddingValues(8.dp, 7.dp) + ) { + Row { + Text( + text = category.name, + modifier = Modifier.alignByBaseline(), + ) + Icon( + painter = painterResource(id = R.drawable.mozac_ic_check), + contentDescription = "Expand or collapse Pocket recommended stories", + modifier = Modifier.alignByBaseline() + ) + } + } +} + +/** + * Displays a list of items as a staggered horizontal grid placing them on ltr rows and continuing + * on as many below rows as needed to place all items. + * + * In an effort to best utilize the available row space this can mix the items such that narrower ones + * are placed on the same row as wider ones if the otherwise next item doesn't fit. + * + * @param modifier to be applied to the layout. + * @param horizontalItemsSpacing minimum horizontal space between items. Does not add spacing to layout bounds. + * @param verticalItemsSpacing vertical space between items + * @param arrangement how the items will be horizontally aligned and spaced. + * @param content the children composables to be laid out. + */ +@Composable +fun StaggeredHorizontalGrid( + modifier: Modifier = Modifier, + horizontalItemsSpacing: Dp = 0.dp, + verticalItemsSpacing: Dp = 8.dp, + arrangement: Arrangement.Horizontal = Arrangement.SpaceEvenly, + content: @Composable () -> Unit +) { + Layout(content, modifier) { items, constraints -> + val horizontalItemsSpacingPixels = horizontalItemsSpacing.roundToPx() + val verticalItemsSpacingPixels = verticalItemsSpacing.roundToPx() + var totalHeight = 0 + val itemsRows = mutableListOf>() + val notYetPlacedItems = items.map { + it.measure(constraints) + }.toMutableList() + + fun getIndexOfNextPlaceableThatFitsRow(available: List, currentWidth: Int): Int { + return available.indexOfFirst { + currentWidth + it.width <= constraints.maxWidth + } + } + + // Populate each row with as many items as possible combining wider with narrower items. + // This will change the order of shown categories. + var (currentRow, currentWidth) = mutableListOf() to 0 + while (notYetPlacedItems.isNotEmpty()) { + if (currentRow.isEmpty()) { + currentRow.add( + notYetPlacedItems[0].also { + currentWidth += it.width + totalHeight += it.height + verticalItemsSpacingPixels + } + ) + notYetPlacedItems.removeAt(0) + } else { + val nextPlaceableThatFitsIndex = getIndexOfNextPlaceableThatFitsRow(notYetPlacedItems, currentWidth) + if (nextPlaceableThatFitsIndex >= 0) { + currentRow.add( + notYetPlacedItems[nextPlaceableThatFitsIndex].also { + currentWidth += it.width + horizontalItemsSpacingPixels + } + ) + notYetPlacedItems.removeAt(nextPlaceableThatFitsIndex) + } else { + itemsRows.add(currentRow) + currentRow = mutableListOf() + currentWidth = 0 + } + } + } + if (currentRow.isNotEmpty()) { + itemsRows.add(currentRow) + } + totalHeight -= verticalItemsSpacingPixels + + // Place each item from each row on screen. + layout(constraints.maxWidth, totalHeight) { + itemsRows.forEachIndexed { rowIndex, itemRow -> + val itemsSizes = IntArray(itemRow.size) { + itemRow[it].width + + if (it < itemRow.lastIndex) horizontalItemsSpacingPixels else 0 + } + val itemsPositions = IntArray(itemsSizes.size) { 0 } + with(arrangement) { + arrange(constraints.maxWidth, itemsSizes, LayoutDirection.Ltr, itemsPositions) + } + + itemRow.forEachIndexed { itemIndex, item -> + item.place( + x = itemsPositions[itemIndex], + y = (rowIndex * item.height) + (rowIndex * verticalItemsSpacingPixels) + ) + } + } + } + } +} + /** * Displays [content] in a layout which will have at the bottom more information about Pocket * and also an external link for more up-to-date content. @@ -291,6 +451,14 @@ private fun FinalDesign() { stories = getFakePocketStories(7), client = FakeClient() ) + + Spacer(Modifier.height(8.dp)) + + PocketStoriesCategories( + listOf("general", "health", "technology", "food", "career").map { + PocketRecommendedStoryCategory(it) + } + ) { } } } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt new file mode 100644 index 0000000000..bbf849bfe6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt @@ -0,0 +1,65 @@ +/* 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.home.sessioncontrol.viewholders.pocket + +import org.mozilla.fenix.home.HomeFragmentAction +import org.mozilla.fenix.home.HomeFragmentStore +import mozilla.components.lib.state.Store + +/** + * Contract for how all user interactions with the Pocket recommended stories feature are to be handled. + */ +interface PocketStoriesController { + /** + * Callback allowing to handle a specific [PocketRecommendedStoryCategory] being clicked by the user. + * + * @param categoryClicked the just clicked [PocketRecommendedStoryCategory]. + */ + fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory): Unit +} + +/** + * Default behavior for handling all user interactions with the Pocket recommended stories feature. + * + * @param homeStore [Store] from which to read the current Pocket recommendations and dispatch new actions on. + */ +internal class DefaultPocketStoriesController( + val homeStore: HomeFragmentStore +) : PocketStoriesController { + override fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory) { + val allCategories = homeStore.state.pocketStoriesCategories + + // First check whether the category is clicked to be deselected. + if (categoryClicked.isSelected) { + homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categoryClicked.name)) + return + } + + // If a new category is clicked to be selected: + // Ensure the number of categories selected at a time is capped. + val currentlySelectedCategoriesCount = allCategories.fold(0) { count, category -> + if (category.isSelected) count + 1 else count + } + val oldestCategoryToDeselect = + if (currentlySelectedCategoriesCount == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT) { + allCategories + .filter { it.isSelected } + .reduce { oldestSelected, category -> + when (oldestSelected.lastInteractedWithTimestamp <= category.lastInteractedWithTimestamp) { + true -> oldestSelected + false -> category + } + } + } else { + null + } + oldestCategoryToDeselect?.let { + homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(it.name)) + } + + // Finally update the selection. + homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categoryClicked.name)) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt new file mode 100644 index 0000000000..58b0718594 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt @@ -0,0 +1,17 @@ +/* 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.home.sessioncontrol.viewholders.pocket + +/** + * Contract for all possible user interactions with the Pocket recommended stories feature. + */ +interface PocketStoriesInteractor { + /** + * Callback for when the user clicked a specific category. + * + * @param categoryClicked the just clicked [PocketRecommendedStoryCategory]. + */ + fun onCategoryClick(categoryClicked: PocketRecommendedStoryCategory) +} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt index db0f96f8aa..2dba583e87 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt @@ -5,7 +5,10 @@ package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket import android.view.View +import androidx.compose.foundation.layout.Column +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.runtime.Composable import androidx.compose.ui.Modifier @@ -18,7 +21,8 @@ import mozilla.components.lib.state.ext.observeAsComposableState import mozilla.components.service.pocket.PocketRecommendedStory import org.mozilla.fenix.home.HomeFragmentStore -private const val STORIES_TO_SHOW_COUNT = 7 +internal const val POCKET_STORIES_TO_SHOW_COUNT = 7 +internal const val POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT = 7 /** * [RecyclerView.ViewHolder] that will display a list of [PocketRecommendedStory]es @@ -26,11 +30,14 @@ private const val STORIES_TO_SHOW_COUNT = 7 * * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. * @param store [HomeFragmentStore] containing the list of Pocket stories to be displayed. + * @param client [Client] instance used for the stories header images. + * @param interactor [PocketStoriesInteractor] callback for user interaction. */ class PocketStoriesViewHolder( val composeView: ComposeView, val store: HomeFragmentStore, - val client: Client + val client: Client, + val interactor: PocketStoriesInteractor ) : RecyclerView.ViewHolder(composeView) { init { @@ -38,7 +45,7 @@ class PocketStoriesViewHolder( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) composeView.setContent { - PocketStories(store, client) + PocketStories(store, client) { interactor.onCategoryClick(it) } } } @@ -50,11 +57,14 @@ class PocketStoriesViewHolder( @Composable fun PocketStories( store: HomeFragmentStore, - client: Client + client: Client, + onCategoryClick: (PocketRecommendedStoryCategory) -> Unit ) { val stories = store .observeAsComposableState { state -> state.pocketStories }.value - ?.take(STORIES_TO_SHOW_COUNT) + + val categories = store + .observeAsComposableState { state -> state.pocketStoriesCategories }.value ExpandableCard( Modifier @@ -62,10 +72,15 @@ fun PocketStories( .padding(top = 40.dp) ) { PocketRecommendations { - PocketStories( - stories ?: emptyList(), - client - ) + Column { + PocketStories(stories ?: emptyList(), client) + + Spacer(Modifier.height(8.dp)) + + PocketStoriesCategories(categories ?: emptyList()) { + onCategoryClick(it) + } + } } } } diff --git a/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt b/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt new file mode 100644 index 0000000000..f704746fe0 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt @@ -0,0 +1,300 @@ +/* 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.ext + +import mozilla.components.service.pocket.PocketRecommendedStory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory +import kotlin.random.Random + +class HomeFragmentStateTest { + private val otherStoriesCategory = + PocketRecommendedStoryCategory("other", getFakePocketStories(3, "other")) + private val anotherStoriesCategory = + PocketRecommendedStoryCategory("another", getFakePocketStories(3, "another")) + private val defaultStoriesCategory = PocketRecommendedStoryCategory( + POCKET_STORIES_DEFAULT_CATEGORY_NAME, + getFakePocketStories(3) + ) + + @Test + fun `GIVEN no category is selected WHEN getFilteredStories is called THEN only Pocket stories from the default category are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory + ) + ) + + var result = homeState.getFilteredStories(2) + assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME }) + + result = homeState.getFilteredStories(5) + assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME }) + } + + @Test + fun `GIVEN no category is selected WHEN getFilteredStories is called THEN no more than the indicated number of stories are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory + ) + ) + + // Asking for fewer than available + var result = homeState.getFilteredStories(2) + assertEquals(2, result.size) + + // Asking for more than available + result = homeState.getFilteredStories(5) + assertEquals(3, result.size) + } + + @Test + fun `GIVEN a category is selected WHEN getFilteredStories is called for fewer than in the category THEN only stories from that category are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory + ) + ) + + var result = homeState.getFilteredStories(2) + assertEquals(2, result.size) + assertNull(result.firstOrNull { it.category != otherStoriesCategory.name }) + + result = homeState.getFilteredStories(3) + assertEquals(3, result.size) + assertNull(result.firstOrNull { it.category != otherStoriesCategory.name }) + } + + @Test + fun `GIVEN a category is selected WHEN getFilteredStories is called for more than in the category THEN results topped with ones from the default category are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory + ) + ) + + val result = homeState.getFilteredStories(5) + + assertEquals(5, result.size) + assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size) + assertEquals( + 2, + result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size + ) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStories is called for fewer than in both THEN only stories from those categories are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory.copy(isSelected = true), + anotherStoriesCategory.copy(isSelected = true), + defaultStoriesCategory + ) + ) + + var result = homeState.getFilteredStories(2) + assertEquals(2, result.size) + assertNull( + result.firstOrNull { + it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name + } + ) + + result = homeState.getFilteredStories(6) + assertEquals(6, result.size) + assertNull( + result.firstOrNull { + it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name + } + ) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStories is called for more than in the categories THEN results topped with ones from the default category are returned`() { + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory.copy(isSelected = true), + anotherStoriesCategory.copy(isSelected = true), + defaultStoriesCategory + ) + ) + + val result = homeState.getFilteredStories(8) + + assertEquals(8, result.size) + assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size) + assertEquals(3, result.filter { it.category == anotherStoriesCategory.name }.size) + assertEquals( + 2, + result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size + ) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStories is called for an odd number of stories THEN there are more by one stories from the oldest category`() { + val firstSelectedCategory = otherStoriesCategory.copy(lastInteractedWithTimestamp = 0, isSelected = true) + val lastSelectedCategory = anotherStoriesCategory.copy(lastInteractedWithTimestamp = 1, isSelected = true) + val homeState = HomeFragmentState( + pocketStoriesCategories = listOf( + firstSelectedCategory, lastSelectedCategory, defaultStoriesCategory + ) + ) + + val result = homeState.getFilteredStories(5) + + assertEquals(5, result.size) + assertEquals(3, result.filter { it.category == firstSelectedCategory.name }.size) + assertEquals(2, result.filter { it.category == lastSelectedCategory.name }.size) + } + + @Test + fun `GIVEN no category is selected WHEN getFilteredStoriesCount is called THEN Pocket stories count only from the default category are returned`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + var result = getFilteredStoriesCount(availableCategories, emptyList(), 2) + assertEquals(1, result.keys.size) + assertEquals(defaultStoriesCategory.name, result.entries.first().key) + assertEquals(2, result[defaultStoriesCategory.name]) + + result = getFilteredStoriesCount(availableCategories, emptyList(), 5) + assertEquals(1, result.keys.size) + assertEquals(defaultStoriesCategory.name, result.entries.first().key) + assertEquals(3, result[defaultStoriesCategory.name]) + } + + @Test + fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for at most the stories from this category THEN only stories count only from that category are returned`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 2) + assertEquals(1, result.keys.size) + assertEquals(otherStoriesCategory.name, result.entries.first().key) + assertEquals(2, result[otherStoriesCategory.name]) + + result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 3) + assertEquals(1, result.keys.size) + assertEquals(otherStoriesCategory.name, result.entries.first().key) + assertEquals(3, result[otherStoriesCategory.name]) + } + + @Test + fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for more stories than this category has THEN results topped with ones from the default category are returned`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 5) + + assertEquals(2, result.keys.size) + assertTrue( + result.keys.containsAll( + listOf( + defaultStoriesCategory.name, + otherStoriesCategory.name + ) + ) + ) + assertEquals(3, result[otherStoriesCategory.name]) + assertEquals(2, result[defaultStoriesCategory.name]) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for at most the stories count in both THEN only stories counts from those categories are returned`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 2) + assertEquals(2, result.keys.size) + assertTrue( + result.keys.containsAll( + listOf( + otherStoriesCategory.name, + anotherStoriesCategory.name + ) + ) + ) + assertEquals(1, result[otherStoriesCategory.name]) + assertEquals(1, result[anotherStoriesCategory.name]) + + result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 6) + assertEquals(2, result.keys.size) + assertTrue( + result.keys.containsAll( + listOf( + otherStoriesCategory.name, + anotherStoriesCategory.name + ) + ) + ) + assertEquals(3, result[otherStoriesCategory.name]) + assertEquals(3, result[anotherStoriesCategory.name]) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for more results than in those categories THEN results topped with ones from the default category are returned`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 8) + + assertEquals(3, result.size) + assertTrue( + result.keys.containsAll( + listOf( + defaultStoriesCategory.name, + otherStoriesCategory.name, + anotherStoriesCategory.name + ) + ) + ) + assertEquals(3, result[otherStoriesCategory.name]) + assertEquals(3, result[anotherStoriesCategory.name]) + assertEquals(2, result[defaultStoriesCategory.name]) + } + + @Test + fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for an odd number of results THEN there are more by one results from first selected category`() { + val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory) + + // The lastInteractedWithTimestamp is not checked in this method but the selected categories order + val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 5) + + assertTrue( + result.keys.containsAll( + listOf( + otherStoriesCategory.name, + anotherStoriesCategory.name + ) + ) + ) + assertEquals(3, result[otherStoriesCategory.name]) + assertEquals(2, result[anotherStoriesCategory.name]) + } +} + +private fun getFakePocketStories( + limit: Int = 1, + category: String = POCKET_STORIES_DEFAULT_CATEGORY_NAME +): List { + return mutableListOf().apply { + for (index in 0 until limit) { + val randomNumber = Random.nextInt(0, 10) + + add( + PocketRecommendedStory( + title = "This is a ${"very ".repeat(randomNumber)} long title", + publisher = "Publisher", + url = "https://story$randomNumber.com", + imageUrl = "", + timeToRead = randomNumber, + category = category + ) + ) + } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt index 27c842248b..1d8469c581 100644 --- a/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/HomeFragmentStoreTest.kt @@ -7,21 +7,28 @@ package org.mozilla.fenix.home import android.content.Context import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify import kotlinx.coroutines.runBlocking import mozilla.components.browser.state.state.TabSessionState import mozilla.components.concept.storage.BookmarkNode import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.top.sites.TopSite import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.service.pocket.PocketRecommendedStory import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.getFilteredStories import org.mozilla.fenix.historymetadata.HistoryMetadataGroup +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_TO_SHOW_COUNT +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory import org.mozilla.fenix.onboarding.FenixOnboarding class HomeFragmentStoreTest { @@ -179,4 +186,112 @@ class HomeFragmentStoreTest { assertEquals(2, homeFragmentStore.state.historyMetadata.size) assertEquals(Mode.Private, homeFragmentStore.state.mode) } + + @Test + fun `Test selecting a Pocket recommendations category`() = runBlocking { + val otherStoriesCategory = PocketRecommendedStoryCategory("other") + val anotherStoriesCategory = PocketRecommendedStoryCategory("another") + val filteredStories = listOf(mockk()) + homeFragmentStore = HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory, anotherStoriesCategory + ) + ) + ) + + mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") { + every { any().getFilteredStories(any()) } returns filteredStories + + homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("other")).join() + + verify { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } + } + + assertTrue( + listOf(otherStoriesCategory.copy(isSelected = true)) + .containsAll(homeFragmentStore.state.pocketStoriesCategories.filter { it.isSelected }) + ) + assertSame(filteredStories, homeFragmentStore.state.pocketStories) + } + + @Test + fun `Test deselecting a Pocket recommendations category`() = runBlocking { + val otherStoriesCategory = PocketRecommendedStoryCategory("other", isSelected = true) + val anotherStoriesCategory = PocketRecommendedStoryCategory("another", isSelected = true) + val filteredStories = listOf(mockk()) + homeFragmentStore = HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf( + otherStoriesCategory, anotherStoriesCategory + ) + ) + ) + + mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") { + every { any().getFilteredStories(any()) } returns filteredStories + + homeFragmentStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory("other")).join() + + verify { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } + } + + assertTrue( + listOf(anotherStoriesCategory) + .containsAll(homeFragmentStore.state.pocketStoriesCategories.filter { it.isSelected }) + ) + assertSame(filteredStories, homeFragmentStore.state.pocketStories) + } + + @Test + fun `Test updating the list of Pocket recommended stories`() = runBlocking { + val story1 = PocketRecommendedStory("title1", "publisher", "url", "imageUrl", 1, "category") + val story2 = story1.copy("title2") + homeFragmentStore = HomeFragmentStore(HomeFragmentState()) + + homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(listOf(story1, story2))) + .join() + assertTrue(homeFragmentStore.state.pocketStories.containsAll(listOf(story1, story2))) + + val updatedStories = listOf(story2.copy("title3")) + homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(updatedStories)).join() + assertTrue(updatedStories.containsAll(homeFragmentStore.state.pocketStories)) + } + + @Test + fun `Test updating the list of Pocket recommendations categories`() = runBlocking { + val otherStoriesCategory = PocketRecommendedStoryCategory("other") + val anotherStoriesCategory = PocketRecommendedStoryCategory("another", isSelected = true) + homeFragmentStore = HomeFragmentStore(HomeFragmentState()) + + mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") { + val firstFilteredStories = listOf(mockk()) + every { any().getFilteredStories(any()) } returns firstFilteredStories + + homeFragmentStore.dispatch( + HomeFragmentAction.PocketStoriesCategoriesChange( + listOf(otherStoriesCategory, anotherStoriesCategory) + ) + ).join() + verify { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } + assertTrue( + homeFragmentStore.state.pocketStoriesCategories.containsAll( + listOf(otherStoriesCategory, anotherStoriesCategory) + ) + ) + assertSame(firstFilteredStories, homeFragmentStore.state.pocketStories) + + val updatedCategories = listOf(PocketRecommendedStoryCategory("yetAnother")) + val secondFilteredStories = listOf(mockk()) + every { any().getFilteredStories(any()) } returns secondFilteredStories + homeFragmentStore.dispatch( + HomeFragmentAction.PocketStoriesCategoriesChange( + updatedCategories + ) + ).join() + verify(exactly = 2) { any().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) } + assertTrue(updatedCategories.containsAll(homeFragmentStore.state.pocketStoriesCategories)) + assertSame(secondFilteredStories, homeFragmentStore.state.pocketStories) + } + } } diff --git a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt index 8d9b0b4f7a..d6c4a3b10d 100644 --- a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt @@ -22,6 +22,8 @@ import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksControll import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory +import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesController class SessionControlInteractorTest { @@ -29,6 +31,7 @@ class SessionControlInteractorTest { private val recentTabController: RecentTabController = mockk(relaxed = true) private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true) private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true) + private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true) private lateinit var interactor: SessionControlInteractor @@ -38,7 +41,8 @@ class SessionControlInteractorTest { controller, recentTabController, recentBookmarksController, - historyMetadataController + historyMetadataController, + pocketStoriesController ) } @@ -222,4 +226,13 @@ class SessionControlInteractorTest { interactor.onPrivateModeButtonClicked(newMode, hasBeenOnboarded) verify { controller.handlePrivateModeButtonClicked(newMode, hasBeenOnboarded) } } + + @Test + fun `GIVEN a PocketStoriesInteractor WHEN a category is clicked THEN handle it in a PocketStoriesController`() { + val clickedCategory: PocketRecommendedStoryCategory = mockk() + + interactor.onCategoryClick(clickedCategory) + + verify { pocketStoriesController.handleCategoryClick(clickedCategory) } + } } diff --git a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt new file mode 100644 index 0000000000..05f58f1651 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt @@ -0,0 +1,93 @@ +/* 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.home.sessioncontrol.viewholders.pocket + +import io.mockk.spyk +import io.mockk.verify +import org.junit.Test +import org.mozilla.fenix.home.HomeFragmentAction +import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.HomeFragmentStore + +class DefaultPocketStoriesControllerTest { + @Test + fun `GIVEN a category is selected WHEN that same category is clicked THEN deselect it`() { + val category1 = PocketRecommendedStoryCategory("cat1", emptyList(), isSelected = false) + val category2 = PocketRecommendedStoryCategory("cat2", emptyList(), isSelected = true) + val store = spyk( + HomeFragmentStore( + HomeFragmentState(pocketStoriesCategories = listOf(category1, category2)) + ) + ) + val controller = DefaultPocketStoriesController(store) + + controller.handleCategoryClick(category1) + verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category1.name)) } + + controller.handleCategoryClick(category2) + verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category2.name)) } + } + + @Test + fun `GIVEN 7 categories are selected WHEN when a new one is clicked THEN the oldest seleected is deselected before selecting the new one`() { + val category1 = PocketRecommendedStoryCategory( + "cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111 + ) + val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222) + val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333) + val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0) + val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444) + val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555) + val category6 = category1.copy("cat6", lastInteractedWithTimestamp = 678) + val newSelectedCategory = category1.copy( + "newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321 + ) + val store = spyk( + HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf( + category1, category2, category3, category4, category5, category6, oldestSelectedCategory + ) + ) + ) + ) + val controller = DefaultPocketStoriesController(store) + + controller.handleCategoryClick(newSelectedCategory) + + verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) } + verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) } + } + + @Test + fun `GIVEN fewer than 7 categories are selected WHEN when a new one is clicked THEN don't deselect anything but select the newly clicked category`() { + val category1 = PocketRecommendedStoryCategory( + "cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111 + ) + val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222) + val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333) + val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0) + val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444) + val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555) + val newSelectedCategory = category1.copy( + "newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321 + ) + val store = spyk( + HomeFragmentStore( + HomeFragmentState( + pocketStoriesCategories = listOf( + category1, category2, category3, category4, category5, oldestSelectedCategory + ) + ) + ) + ) + val controller = DefaultPocketStoriesController(store) + + controller.handleCategoryClick(newSelectedCategory) + + verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) } + verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) } + } +}