From 2cc9ca3773b2d17dd71e9ce16cbc28b069dd26da Mon Sep 17 00:00:00 2001 From: MatthewTighe Date: Thu, 17 Feb 2022 11:27:42 -0800 Subject: [PATCH] for #23069: add blocklist middleware for home --- .../mozilla/fenix/ext/HomeFragmentState.kt | 15 + .../java/org/mozilla/fenix/ext/RecentTabs.kt | 18 + .../org/mozilla/fenix/home/HomeFragment.kt | 17 +- .../mozilla/fenix/home/HomeFragmentStore.kt | 11 + .../fenix/home/blocklist/BlocklistHandler.kt | 89 +++++ .../home/blocklist/BlocklistMiddleware.kt | 99 ++++++ .../controller/RecentBookmarksController.kt | 14 +- .../interactor/RecentBookmarksInteractor.kt | 8 + .../recentbookmarks/view/RecentBookmarks.kt | 80 ++++- .../view/RecentBookmarksMenuItem.kt | 18 + .../view/RecentBookmarksViewHolder.kt | 10 +- .../controller/RecentTabController.kt | 15 +- .../interactor/RecentTabInteractor.kt | 10 + .../home/recenttabs/view/RecentTabMenuItem.kt | 18 + .../recenttabs/view/RecentTabViewHolder.kt | 9 +- .../fenix/home/recenttabs/view/RecentTabs.kt | 76 ++++- .../SessionControlInteractor.kt | 9 + .../java/org/mozilla/fenix/utils/Settings.kt | 9 + app/src/main/res/values/preference_keys.xml | 1 + app/src/main/res/values/strings.xml | 5 + .../org/mozilla/fenix/ext/RecentTabsTest.kt | 34 ++ .../home/blocklist/BlocklistHandlerTest.kt | 120 +++++++ .../home/blocklist/BlocklistMiddlewareTest.kt | 308 ++++++++++++++++++ .../DefaultRecentBookmarksControllerTest.kt | 7 +- .../controller/RecentTabControllerTest.kt | 3 + 25 files changed, 986 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/ext/RecentTabs.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistHandler.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistMiddleware.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksMenuItem.kt create mode 100644 app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabMenuItem.kt create mode 100644 app/src/test/java/org/mozilla/fenix/ext/RecentTabsTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistHandlerTest.kt create mode 100644 app/src/test/java/org/mozilla/fenix/home/blocklist/BlocklistMiddlewareTest.kt diff --git a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt index 6f47ad34a7..947bf7f5df 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt @@ -7,6 +7,7 @@ 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.blocklist.BlocklistHandler import org.mozilla.fenix.home.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory import org.mozilla.fenix.home.recenttabs.RecentTab.SearchGroup @@ -100,3 +101,17 @@ internal fun getFilteredStoriesCount( */ internal val HomeFragmentState.recentSearchGroup: SearchGroup? get() = recentTabs.find { it is SearchGroup } as SearchGroup? + +/** + * Filter a [HomeFragmentState] by the blocklist. + * + * @param blocklistHandler The handler that will filter the state. + */ +fun HomeFragmentState.filterState(blocklistHandler: BlocklistHandler): HomeFragmentState = + with(blocklistHandler) { + copy( + recentBookmarks = recentBookmarks.filteredByBlocklist(), + recentTabs = recentTabs.filteredByBlocklist(), + recentHistory = recentHistory.filteredByBlocklist() + ) + } diff --git a/app/src/main/java/org/mozilla/fenix/ext/RecentTabs.kt b/app/src/main/java/org/mozilla/fenix/ext/RecentTabs.kt new file mode 100644 index 0000000000..2a6524ad60 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/ext/RecentTabs.kt @@ -0,0 +1,18 @@ +/* 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 org.mozilla.fenix.home.recenttabs.RecentTab + +/** + * Removes a [RecentTab.Tab] from a list of [RecentTab]. [RecentTab.SearchGroup]s will not be filtered. + * + * @param tab [RecentTab] to remove from the list + */ +@VisibleForTesting +internal fun List.filterOutTab(tab: RecentTab): List = filterNot { + it is RecentTab.Tab && tab is RecentTab.Tab && it.state.id == tab.state.id +} 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 eec52dfa40..aaa2cccab4 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -97,6 +97,7 @@ import org.mozilla.fenix.databinding.FragmentHomeBinding import org.mozilla.fenix.datastore.pocketStoriesSelectedCategoriesDataStore import org.mozilla.fenix.ext.asRecentTabs import org.mozilla.fenix.ext.components +import org.mozilla.fenix.ext.filterState import org.mozilla.fenix.ext.hideToolbar import org.mozilla.fenix.ext.metrics import org.mozilla.fenix.ext.nav @@ -104,6 +105,8 @@ import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.settings import org.mozilla.fenix.ext.sort +import org.mozilla.fenix.home.blocklist.BlocklistHandler +import org.mozilla.fenix.home.blocklist.BlocklistMiddleware import org.mozilla.fenix.home.mozonline.showPrivacyPopWindow import org.mozilla.fenix.home.pocket.DefaultPocketStoriesController import org.mozilla.fenix.home.pocket.PocketRecommendedStoriesCategory @@ -234,9 +237,10 @@ class HomeFragment : Fragment() { ::dispatchModeChanges ) + val blocklistHandler = BlocklistHandler(components.settings) homeFragmentStore = StoreProvider.get(this) { HomeFragmentStore( - HomeFragmentState( + initialState = HomeFragmentState( collections = components.core.tabCollectionStorage.cachedTabCollections, expandedCollections = emptySet(), mode = currentMode.getCurrentMode(), @@ -260,8 +264,9 @@ class HomeFragment : Fragment() { // to some state. recentTabs = getRecentTabs(components), recentHistory = emptyList() - ), - listOf( + ).run { filterState(blocklistHandler) }, + middlewares = listOf( + BlocklistMiddleware(blocklistHandler), PocketUpdatesMiddleware( lifecycleScope, requireComponents.core.pocketStoriesService, @@ -358,11 +363,13 @@ class HomeFragment : Fragment() { selectTabUseCase = components.useCases.tabsUseCases.selectTab, navController = findNavController(), metrics = requireComponents.analytics.metrics, - store = components.core.store + store = components.core.store, + homeStore = homeFragmentStore, ), recentBookmarksController = DefaultRecentBookmarksController( activity = activity, - navController = findNavController() + navController = findNavController(), + homeStore = homeFragmentStore, ), recentVisitsController = DefaultRecentVisitsController( navController = findNavController(), 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 4098bea6b7..bf03328535 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -15,6 +15,7 @@ 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.filterOutTab import org.mozilla.fenix.ext.getFilteredStories import org.mozilla.fenix.ext.recentSearchGroup import org.mozilla.fenix.home.pocket.POCKET_STORIES_TO_SHOW_COUNT @@ -101,7 +102,9 @@ sealed class HomeFragmentAction : Action { data class TopSitesChange(val topSites: List) : HomeFragmentAction() data class RemoveTip(val tip: Tip) : HomeFragmentAction() data class RecentTabsChange(val recentTabs: List) : HomeFragmentAction() + data class RemoveRecentTab(val recentTab: RecentTab) : HomeFragmentAction() data class RecentBookmarksChange(val recentBookmarks: List) : HomeFragmentAction() + data class RemoveRecentBookmark(val recentBookmark: RecentBookmark) : HomeFragmentAction() data class RecentHistoryChange(val recentHistory: List) : HomeFragmentAction() data class RemoveRecentHistoryHighlight(val highlightUrl: String) : HomeFragmentAction() data class DisbandSearchGroupAction(val searchTerm: String) : HomeFragmentAction() @@ -167,7 +170,15 @@ private fun homeFragmentStateReducer( recentHistory = state.recentHistory.filterOut(recentSearchGroup?.searchTerm) ) } + is HomeFragmentAction.RemoveRecentTab -> { + state.copy( + recentTabs = state.recentTabs.filterOutTab(action.recentTab) + ) + } is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks) + is HomeFragmentAction.RemoveRecentBookmark -> { + state.copy(recentBookmarks = state.recentBookmarks.filterNot { it.url == action.recentBookmark.url }) + } is HomeFragmentAction.RecentHistoryChange -> state.copy( recentHistory = action.recentHistory.filterOut(state.recentSearchGroup?.searchTerm) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistHandler.kt b/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistHandler.kt new file mode 100644 index 0000000000..c95005a6a6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistHandler.kt @@ -0,0 +1,89 @@ +/* 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.blocklist + +import androidx.annotation.VisibleForTesting +import mozilla.components.support.ktx.kotlin.sha1 +import org.mozilla.fenix.home.recentbookmarks.RecentBookmark +import org.mozilla.fenix.home.recenttabs.RecentTab +import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem +import org.mozilla.fenix.utils.Settings + +/** + * Class for interacting with the a blocklist stored in [settings]. + * The blocklist is a set of SHA1 hashed URLs, which are stripped + * of protocols and common subdomains like "www" or "mobile". + */ +class BlocklistHandler(private val settings: Settings) { + + /** + * Add an URL to the blocklist. The URL will be stripped and hashed, + * so no pre-formatted is required. + */ + fun addUrlToBlocklist(url: String) { + val updatedBlocklist = settings.homescreenBlocklist + url.stripAndHash() + settings.homescreenBlocklist = updatedBlocklist + } + + /** + * Filter a list of recent bookmarks by the blocklist. Requires this class to be contextually + * in a scope. + */ + @JvmName("filterRecentBookmark") + fun List.filteredByBlocklist(): List = + settings.homescreenBlocklist.let { blocklist -> + filterNot { + it.url?.let { url -> blocklistContainsUrl(blocklist, url) } ?: false + } + } + + /** + * Filter a list of recent tabs by the blocklist. Requires this class to be contextually + * in a scope. + */ + @JvmName("filterRecentTab") + fun List.filteredByBlocklist(): List = + settings.homescreenBlocklist.let { blocklist -> + filterNot { + it is RecentTab.Tab && blocklistContainsUrl(blocklist, it.state.content.url) + } + } + + /** + * Filter a list of recent history items by the blocklist. Requires this class to be contextually + * in a scope. + */ + @JvmName("filterRecentHistory") + fun List.filteredByBlocklist(): List = + settings.homescreenBlocklist.let { blocklist -> + filterNot { + it is RecentlyVisitedItem.RecentHistoryHighlight && + blocklistContainsUrl(blocklist, it.url) + } + } + + private fun blocklistContainsUrl(blocklist: Set, url: String): Boolean = + blocklist.any { it == url.stripAndHash() } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal fun String.stripAndHash(): String = + this.stripProtocolAndCommonSubdomains().sha1() + +// Eventually, this should be standardize in A-C and this can then be removed +// https://github.com/mozilla-mobile/android-components/issues/11743 +private fun String.stripProtocolAndCommonSubdomains(): String { + val stripped = this.substringAfter("://").dropLastWhile { it == '/' } + // This kind of stripping allows us to match "twitter" to "mobile.twitter.com". + // Borrowed from DomainMatcher in A-C + val domainsToStrip = listOf("www", "mobile", "m") + + domainsToStrip.forEach { domain -> + if (stripped.startsWith("$domain.")) { + return stripped.substring(domain.length + 1) + } + } + return stripped +} diff --git a/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistMiddleware.kt b/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistMiddleware.kt new file mode 100644 index 0000000000..879c730006 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/blocklist/BlocklistMiddleware.kt @@ -0,0 +1,99 @@ +/* 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.blocklist + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import org.mozilla.fenix.home.HomeFragmentAction +import org.mozilla.fenix.home.HomeFragmentState +import org.mozilla.fenix.home.recenttabs.RecentTab + +/** + * This [Middleware] reacts to item removals from the home screen, adding them to a blocklist. + * Additionally, it reacts to state changes in order to filter them by the blocklist. + * + * @param settings Blocklist is stored here as a string set + */ +class BlocklistMiddleware( + private val blocklistHandler: BlocklistHandler +) : Middleware { + + /** + * Will filter "Change" actions using the blocklist and use "Remove" actions to update + * the blocklist. + */ + override fun invoke( + context: MiddlewareContext, + next: (HomeFragmentAction) -> Unit, + action: HomeFragmentAction + ) { + next(getUpdatedAction(context.state, action)) + } + + private fun getUpdatedAction( + state: HomeFragmentState, + action: HomeFragmentAction + ) = with(blocklistHandler) { + when (action) { + is HomeFragmentAction.Change -> { + action.copy( + recentBookmarks = action.recentBookmarks.filteredByBlocklist(), + recentTabs = action.recentTabs.filteredByBlocklist(), + recentHistory = action.recentHistory.filteredByBlocklist() + ) + } + is HomeFragmentAction.RecentTabsChange -> { + action.copy( + recentTabs = action.recentTabs.filteredByBlocklist() + ) + } + is HomeFragmentAction.RecentBookmarksChange -> { + action.copy( + recentBookmarks = action.recentBookmarks.filteredByBlocklist() + ) + } + is HomeFragmentAction.RecentHistoryChange -> { + action.copy(recentHistory = action.recentHistory.filteredByBlocklist()) + } + is HomeFragmentAction.RemoveRecentTab -> { + if (action.recentTab is RecentTab.Tab) { + addUrlToBlocklist(action.recentTab.state.content.url) + state.toActionFilteringAllState(this) + } else { + action + } + } + is HomeFragmentAction.RemoveRecentBookmark -> { + action.recentBookmark.url?.let { url -> + addUrlToBlocklist(url) + state.toActionFilteringAllState(this) + } ?: action + } + is HomeFragmentAction.RemoveRecentHistoryHighlight -> { + addUrlToBlocklist(action.highlightUrl) + state.toActionFilteringAllState(this) + } + else -> action + } + } + + // When an item is removed from any part of the state, it should also be removed from any other + // relevant parts that contain it. + // This is a candidate for refactoring once context receivers lands in Kotlin 1.6.20 + // https://blog.jetbrains.com/kotlin/2022/02/kotlin-1-6-20-m1-released/#prototype-of-context-receivers-for-kotlin-jvm + private fun HomeFragmentState.toActionFilteringAllState(blocklistHandler: BlocklistHandler) = + with(blocklistHandler) { + HomeFragmentAction.Change( + recentTabs = recentTabs.filteredByBlocklist(), + recentBookmarks = recentBookmarks.filteredByBlocklist(), + recentHistory = recentHistory.filteredByBlocklist(), + topSites = topSites, + mode = mode, + collections = collections, + tip = tip, + showCollectionPlaceholder = showCollectionPlaceholder + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt index f18bacf281..5b0aa71c18 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/controller/RecentBookmarksController.kt @@ -15,7 +15,9 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.ext.components +import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentDirections +import org.mozilla.fenix.home.HomeFragmentStore import org.mozilla.fenix.home.recentbookmarks.RecentBookmark import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor @@ -34,6 +36,11 @@ interface RecentBookmarksController { * @see [RecentBookmarksInteractor.onShowAllBookmarksClicked] */ fun handleShowAllBookmarksClicked() + + /** + * @see [RecentBookmarksInteractor.onRecentBookmarkRemoved] + */ + fun handleBookmarkRemoved(bookmark: RecentBookmark) } /** @@ -41,7 +48,8 @@ interface RecentBookmarksController { */ class DefaultRecentBookmarksController( private val activity: HomeActivity, - private val navController: NavController + private val navController: NavController, + private val homeStore: HomeFragmentStore, ) : RecentBookmarksController { override fun handleBookmarkClicked(bookmark: RecentBookmark) { @@ -63,6 +71,10 @@ class DefaultRecentBookmarksController( ) } + override fun handleBookmarkRemoved(bookmark: RecentBookmark) { + homeStore.dispatch(HomeFragmentAction.RemoveRecentBookmark(bookmark)) + } + @VisibleForTesting(otherwise = PRIVATE) fun dismissSearchDialogIfDisplayed() { if (navController.currentDestination?.id == R.id.searchDialogFragment) { diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/interactor/RecentBookmarksInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/interactor/RecentBookmarksInteractor.kt index 4040c24569..810da7e14a 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/interactor/RecentBookmarksInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/interactor/RecentBookmarksInteractor.kt @@ -25,4 +25,12 @@ interface RecentBookmarksInteractor { * recently saved bookmarks on the home screen. */ fun onShowAllBookmarksClicked() + + /** + * Removes a bookmark from the recent bookmark list. Called when a user clicks the "Remove" + * button for recently saved bookmarks on the home screen. + * + * @param bookmark The bookmark that has been removed. + */ + fun onRecentBookmarkRemoved(bookmark: RecentBookmark) } diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarks.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarks.kt index e6dd29cd7b..de90d77404 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarks.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarks.kt @@ -4,15 +4,17 @@ package org.mozilla.fenix.home.recentbookmarks.view +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size @@ -21,8 +23,14 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -44,12 +52,14 @@ import org.mozilla.fenix.theme.FirefoxTheme * A list of recent bookmarks. * * @param bookmarks List of [RecentBookmark]s to display. + * @param menuItems List of [RecentBookmarksMenuItem] shown when long clicking a [RecentBookmarkItem] * @param onRecentBookmarkClick Invoked when the user clicks on a recent bookmark. */ @Composable fun RecentBookmarks( bookmarks: List, - onRecentBookmarkClick: (RecentBookmark) -> Unit = {} + menuItems: List, + onRecentBookmarkClick: (RecentBookmark) -> Unit = {}, ) { LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), @@ -58,7 +68,8 @@ fun RecentBookmarks( items(bookmarks) { bookmark -> RecentBookmarkItem( bookmark = bookmark, - onRecentBookmarkClick = onRecentBookmarkClick + menuItems = menuItems, + onRecentBookmarkClick = onRecentBookmarkClick, ) } } @@ -70,15 +81,23 @@ fun RecentBookmarks( * @param bookmark The [RecentBookmark] to display. * @param onRecentBookmarkClick Invoked when the user clicks on the recent bookmark item. */ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun RecentBookmarkItem( bookmark: RecentBookmark, + menuItems: List, onRecentBookmarkClick: (RecentBookmark) -> Unit = {} ) { + var isMenuExpanded by remember { mutableStateOf(false) } + Column( modifier = Modifier .width(156.dp) - .clickable { onRecentBookmarkClick(bookmark) } + .combinedClickable( + enabled = true, + onClick = { onRecentBookmarkClick(bookmark) }, + onLongClick = { isMenuExpanded = true } + ) ) { Card( modifier = Modifier @@ -98,6 +117,13 @@ private fun RecentBookmarkItem( overflow = TextOverflow.Ellipsis, maxLines = 1 ) + + RecentBookmarksMenu( + showMenu = isMenuExpanded, + menuItems = menuItems, + recentBookmark = bookmark, + onDismissRequest = { isMenuExpanded = false } + ) } } @@ -146,6 +172,49 @@ private fun RecentBookmarkImage(bookmark: RecentBookmark) { } } +/** + * Menu shown for a [RecentBookmark]. + * + * @see [DropdownMenu] + * + * @param showMenu Whether this is currently open and visible to the user. + * @param menuItems List of options shown. + * @param recentBookmark The [RecentBookmark] for which this menu is shown. + * @param onDismissRequest Called when the user chooses a menu option or requests to dismiss the menu. + */ +@Composable +private fun RecentBookmarksMenu( + showMenu: Boolean, + menuItems: List, + recentBookmark: RecentBookmark, + onDismissRequest: () -> Unit, +) { + DropdownMenu( + expanded = showMenu, + onDismissRequest = { onDismissRequest() }, + modifier = Modifier + .background(color = FirefoxTheme.colors.layer2) + ) { + for (item in menuItems) { + DropdownMenuItem( + onClick = { + onDismissRequest() + item.onClick(recentBookmark) + }, + ) { + Text( + text = item.title, + color = FirefoxTheme.colors.textPrimary, + maxLines = 1, + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterVertically) + ) + } + } + } +} + @Composable @Preview private fun RecentBookmarksPreview() { @@ -172,7 +241,8 @@ private fun RecentBookmarksPreview() { url = "https://www.example.com", previewImageUrl = null ) - ) + ), + menuItems = listOf() ) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksMenuItem.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksMenuItem.kt new file mode 100644 index 0000000000..c4965a04e8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksMenuItem.kt @@ -0,0 +1,18 @@ +/* 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.recentbookmarks.view + +import org.mozilla.fenix.home.recentbookmarks.RecentBookmark + +/** + * A menu item in the recent bookmarks dropdown menu. + * + * @property title The menu item title. + * @property onClick Invoked when the user clicks on the menu item. + */ +data class RecentBookmarksMenuItem( + val title: String, + val onClick: (RecentBookmark) -> Unit +) diff --git a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt index 218b8b82de..947e179096 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recentbookmarks/view/RecentBookmarksViewHolder.kt @@ -7,8 +7,10 @@ package org.mozilla.fenix.home.recentbookmarks.view import android.view.View import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.lifecycle.LifecycleOwner import mozilla.components.lib.state.ext.observeAsComposableState +import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.compose.ComposeViewHolder @@ -37,7 +39,13 @@ class RecentBookmarksViewHolder( RecentBookmarks( bookmarks = recentBookmarks.value ?: emptyList(), - onRecentBookmarkClick = interactor::onRecentBookmarkClicked + onRecentBookmarkClick = interactor::onRecentBookmarkClicked, + menuItems = listOf( + RecentBookmarksMenuItem( + stringResource(id = R.string.recently_saved_menu_item_remove), + onClick = { bookmark -> interactor.onRecentBookmarkRemoved(bookmark) } + ) + ) ) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt index b37de99fdc..01ced103e7 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/controller/RecentTabController.kt @@ -13,7 +13,10 @@ import org.mozilla.fenix.R import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.ext.inProgressMediaTab +import org.mozilla.fenix.home.HomeFragmentAction import org.mozilla.fenix.home.HomeFragmentDirections +import org.mozilla.fenix.home.HomeFragmentStore +import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor /** @@ -35,6 +38,11 @@ interface RecentTabController { * @see [RecentTabInteractor.onRecentTabShowAllClicked] */ fun handleRecentTabShowAllClicked() + + /** + * @see [RecentTabInteractor.onRemoveRecentTab] + */ + fun handleRecentTabRemoved(tab: RecentTab.Tab) } /** @@ -47,7 +55,8 @@ class DefaultRecentTabsController( private val selectTabUseCase: SelectTabUseCase, private val navController: NavController, private val metrics: MetricController, - private val store: BrowserStore + private val store: BrowserStore, + private val homeStore: HomeFragmentStore, ) : RecentTabController { override fun handleRecentTabClicked(tabId: String) { @@ -76,6 +85,10 @@ class DefaultRecentTabsController( ) } + override fun handleRecentTabRemoved(tab: RecentTab.Tab) { + homeStore.dispatch(HomeFragmentAction.RemoveRecentTab(tab)) + } + @VisibleForTesting(otherwise = PRIVATE) fun dismissSearchDialogIfDisplayed() { if (navController.currentDestination?.id == R.id.searchDialogFragment) { diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt index c40a3d44bf..006993acf3 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/interactor/RecentTabInteractor.kt @@ -4,6 +4,8 @@ package org.mozilla.fenix.home.recenttabs.interactor +import org.mozilla.fenix.home.recenttabs.RecentTab + /** * Interface for recent tab related actions in the Home screen. */ @@ -27,4 +29,12 @@ interface RecentTabInteractor { * tabs. */ fun onRecentTabShowAllClicked() + + /** + * Removes a bookmark from the recent bookmark list. Called when a user clicks the "Remove" + * button for recently saved bookmarks on the home screen. + * + * @param tab The tab that has been removed. + */ + fun onRemoveRecentTab(tab: RecentTab.Tab) } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabMenuItem.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabMenuItem.kt new file mode 100644 index 0000000000..62fb958533 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabMenuItem.kt @@ -0,0 +1,18 @@ +/* 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.recenttabs.view + +import org.mozilla.fenix.home.recenttabs.RecentTab + +/** +* A menu item in the recent tab dropdown menu. +* +* @property title The menu item title. +* @property onClick Invoked when the user clicks on the menu item. +*/ +class RecentTabMenuItem( + val title: String, + val onClick: (RecentTab.Tab) -> Unit +) diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt index efef0e61e4..6b35a3472a 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewHolder.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.home.recenttabs.view import android.view.View import androidx.compose.runtime.Composable import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.res.stringResource import androidx.lifecycle.LifecycleOwner import mozilla.components.lib.state.ext.observeAsComposableState import org.mozilla.fenix.R @@ -45,7 +46,13 @@ class RecentTabViewHolder( RecentTabs( recentTabs = recentTabs.value ?: emptyList(), onRecentTabClick = { interactor.onRecentTabClicked(it) }, - onRecentSearchGroupClicked = { interactor.onRecentSearchGroupClicked(it) } + onRecentSearchGroupClicked = { interactor.onRecentSearchGroupClicked(it) }, + menuItems = listOf( + RecentTabMenuItem( + title = stringResource(id = R.string.recent_tab_menu_item_remove), + onClick = { tab -> interactor.onRemoveRecentTab(tab) } + ) + ) ) } } diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt index 8a169069ad..660a89759e 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabs.kt @@ -7,15 +7,18 @@ package org.mozilla.fenix.home.recenttabs.view import android.graphics.Bitmap +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable 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.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -24,12 +27,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -37,6 +45,7 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource @@ -60,12 +69,14 @@ import org.mozilla.fenix.theme.FirefoxTheme * A list of recent tabs to jump back to. * * @param recentTabs List of [RecentTab] to display. + * @param menuItems List of [RecentTabMenuItem] shown long clicking a [RecentTab]. * @param onRecentTabClick Invoked when the user clicks on a recent tab. * @param onRecentSearchGroupClicked Invoked when the user clicks on a recent search group. */ @Composable fun RecentTabs( recentTabs: List, + menuItems: List, onRecentTabClick: (String) -> Unit = {}, onRecentSearchGroupClicked: (String) -> Unit = {} ) { @@ -78,6 +89,7 @@ fun RecentTabs( is RecentTab.Tab -> { RecentTabItem( tab = tab, + menuItems = menuItems, onRecentTabClick = onRecentTabClick ) } @@ -102,17 +114,25 @@ fun RecentTabs( * @param tab [RecentTab.Tab] that was recently viewed. * @param onRecentTabClick Invoked when the user clicks on a recent tab. */ +@OptIn(ExperimentalFoundationApi::class) @Suppress("LongParameterList") @Composable private fun RecentTabItem( tab: RecentTab.Tab, + menuItems: List, onRecentTabClick: (String) -> Unit = {} ) { + var isMenuExpanded by remember { mutableStateOf(false) } + Card( modifier = Modifier .fillMaxWidth() .height(112.dp) - .clickable { onRecentTabClick(tab.state.id) }, + .combinedClickable( + enabled = true, + onClick = { onRecentTabClick(tab.state.id) }, + onLongClick = { isMenuExpanded = true } + ), shape = RoundedCornerShape(8.dp), backgroundColor = FirefoxTheme.colors.layer2, elevation = 6.dp @@ -149,6 +169,13 @@ private fun RecentTabItem( RecentTabSubtitle(subtitle = tab.state.content.url) } } + + RecentTabMenu( + showMenu = isMenuExpanded, + menuItems = menuItems, + tab = tab, + onDismissRequest = { isMenuExpanded = false } + ) } } } @@ -300,6 +327,53 @@ private fun RecentTabImage( } } +/** + * Menu shown for a [RecentTab.Tab]. + * + * @see [DropdownMenu] + * + * @param showMenu Whether this is currently open and visible to the user. + * @param menuItems List of options shown. + * @param tab The [RecentTab.Tab] for which this menu is shown. + * @param onDismissRequest Called when the user chooses a menu option or requests to dismiss the menu. + */ +@Composable +private fun RecentTabMenu( + showMenu: Boolean, + menuItems: List, + tab: RecentTab.Tab, + onDismissRequest: () -> Unit, +) { + DisposableEffect(LocalConfiguration.current.orientation) { + onDispose { onDismissRequest() } + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { onDismissRequest() }, + modifier = Modifier + .background(color = FirefoxTheme.colors.layer2) + ) { + for (item in menuItems) { + DropdownMenuItem( + onClick = { + onDismissRequest() + item.onClick(tab) + }, + ) { + Text( + text = item.title, + color = FirefoxTheme.colors.textPrimary, + maxLines = 1, + modifier = Modifier + .fillMaxHeight() + .align(Alignment.CenterVertically) + ) + } + } + } +} + /** * A recent tab icon. * 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 61574ea0fe..69462d7670 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,7 @@ import org.mozilla.fenix.home.pocket.PocketStoriesInteractor import org.mozilla.fenix.home.recentbookmarks.RecentBookmark import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksController import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor +import org.mozilla.fenix.home.recenttabs.RecentTab import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor import org.mozilla.fenix.home.recentvisits.RecentlyVisitedItem.RecentHistoryGroup @@ -393,6 +394,10 @@ class SessionControlInteractor( recentTabController.handleRecentTabShowAllClicked() } + override fun onRemoveRecentTab(tab: RecentTab.Tab) { + recentTabController.handleRecentTabRemoved(tab) + } + override fun onRecentBookmarkClicked(bookmark: RecentBookmark) { recentBookmarksController.handleBookmarkClicked(bookmark) } @@ -401,6 +406,10 @@ class SessionControlInteractor( recentBookmarksController.handleShowAllBookmarksClicked() } + override fun onRecentBookmarkRemoved(bookmark: RecentBookmark) { + recentBookmarksController.handleBookmarkRemoved(bookmark) + } + override fun onHistoryShowAllClicked() { recentVisitsController.handleHistoryShowAllClicked() } diff --git a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt index 22180753ba..a5a9c0e5a7 100644 --- a/app/src/main/java/org/mozilla/fenix/utils/Settings.kt +++ b/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -25,6 +25,7 @@ import mozilla.components.support.ktx.android.content.floatPreference import mozilla.components.support.ktx.android.content.intPreference import mozilla.components.support.ktx.android.content.longPreference import mozilla.components.support.ktx.android.content.stringPreference +import mozilla.components.support.ktx.android.content.stringSetPreference import org.mozilla.fenix.BuildConfig import org.mozilla.fenix.Config import org.mozilla.fenix.FeatureFlags @@ -1308,4 +1309,12 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false, featureFlag = FeatureFlags.taskContinuityFeature, ) + + /** + * Blocklist used to filter items from the home screen that have previously been removed. + */ + var homescreenBlocklist by stringSetPreference( + appContext.getPreferenceKey(R.string.pref_key_home_blocklist), + default = setOf() + ) } diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 5eca23ac85..62707e9e71 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -67,6 +67,7 @@ pref_key_should_show_default_browser_notification pref_key_is_first_run pref_key_has_shown_home_onboarding + pref_key_home_blocklist pref_key_telemetry diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1f9959479c..58c6c31881 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,6 +46,8 @@ Show all saved bookmarks Show all saved bookmarks button + + Remove %1$s is produced by Mozilla. @@ -129,6 +131,9 @@ %d sites + + Remove