diff --git a/firefox-ios/Client/Frontend/Home/Homepage/HomepageDiffableDataSource.swift b/firefox-ios/Client/Frontend/Home/Homepage/HomepageDiffableDataSource.swift index 592323544eab9..2f3dc120cbe03 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/HomepageDiffableDataSource.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/HomepageDiffableDataSource.swift @@ -47,6 +47,7 @@ final class HomepageDiffableDataSource: UICollectionViewDiffableDataSource TopSitesSnapshotData? { guard topSitesState.shouldShowSection else { return nil } - let topSites: [HomeItem] = topSitesState.topSitesData.prefix( - topSitesState.numberOfRows * topSitesState.numberOfTilesPerRow + let maxVisibleItemCount = topSitesState.numberOfRows * topSitesState.numberOfTilesPerRow + guard maxVisibleItemCount > 0 else { return nil } + + let topSitesItems: [HomeItem] = topSitesState.topSitesData.prefix( + maxVisibleItemCount ).compactMap { .topSite($0, textColor) } - guard !topSites.isEmpty else { return nil } + let allItems = topSitesState.shouldShowAddShortcutTile + ? topSitesItems + [.addShortcutTile(textColor)] + : topSitesItems + let visibleItems = Array(allItems.prefix(maxVisibleItemCount)) + guard !visibleItems.isEmpty else { return nil } + return TopSitesSnapshotData( - items: topSites, + items: visibleItems, numberOfTilesPerRow: topSitesState.numberOfTilesPerRow, shouldShowSectionHeader: topSitesState.shouldShowSectionHeader ) diff --git a/firefox-ios/Client/Frontend/Home/Homepage/HomepageViewController.swift b/firefox-ios/Client/Frontend/Home/Homepage/HomepageViewController.swift index 91174a368fb7d..558397f8cf45d 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/HomepageViewController.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/HomepageViewController.swift @@ -640,6 +640,10 @@ final class HomepageViewController: UIViewController, return configuredCell(cellType: TopSiteCell.self, at: indexPath) { cell in cell.configure(site, position: indexPath.row, theme: currentTheme, textColor: textColor) } + case .addShortcutTile(let textColor): + return configuredCell(cellType: TopSiteCell.self, at: indexPath) { cell in + cell.configureAddShortcutTile(theme: currentTheme, textColor: textColor) + } case .topSiteEmpty: return configuredCell(cellType: EmptyTopSiteCell.self, at: indexPath) { cell in cell.applyTheme(theme: currentTheme) @@ -1001,7 +1005,7 @@ final class HomepageViewController: UIViewController, ) return } - if section.canHandleLongPress { + if section.canHandleLongPress && item.canHandleLongPress { navigateToContextMenu(for: item, sourceView: sourceView) } } @@ -1264,9 +1268,9 @@ final class HomepageViewController: UIViewController, ) return } - dispatchDidSelectCardItemAction(with: item) switch item { case .topSite(let config, _): + dispatchDidSelectCardItemAction(with: item) let destination = NavigationDestination( .link, url: config.site.url.asURL, @@ -1280,11 +1284,13 @@ final class HomepageViewController: UIViewController, actionType: TopSitesActionType.tapOnHomepageTopSitesCell ) case .searchBar: + dispatchDidSelectCardItemAction(with: item) dispatchNavigationBrowserAction( with: NavigationDestination(.homepageZeroSearch), actionType: NavigationBrowserActionType.tapOnHomepageSearchBar ) case .jumpBackIn(let config): + dispatchDidSelectCardItemAction(with: item) store.dispatch( JumpBackInAction( tab: config.tab, @@ -1293,6 +1299,7 @@ final class HomepageViewController: UIViewController, ) ) case .bookmark(let config): + dispatchDidSelectCardItemAction(with: item) let destination = NavigationDestination( .link, url: URIFixup.getURL(config.site.url), @@ -1301,6 +1308,7 @@ final class HomepageViewController: UIViewController, ) dispatchNavigationBrowserAction(with: destination, actionType: NavigationBrowserActionType.tapOnCell) case .merino(let story, _): + dispatchDidSelectCardItemAction(with: item) let destination = NavigationDestination( .link, url: story.url, diff --git a/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageLayoutMeasurementCache.swift b/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageLayoutMeasurementCache.swift index 2d2d29066f2da..849007401e8b1 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageLayoutMeasurementCache.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageLayoutMeasurementCache.swift @@ -15,6 +15,7 @@ struct HomepageLayoutMeasurementCache { let containerWidth: Double let isLandscape: Bool let shouldShowSection: Bool + let shouldShowAddShortcutTile: Bool let contentSizeCategory: UIContentSizeCategory } diff --git a/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageSectionLayoutProvider.swift b/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageSectionLayoutProvider.swift index 590b97f3f1f7f..e65c34179587c 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageSectionLayoutProvider.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/Layout/HomepageSectionLayoutProvider.swift @@ -633,6 +633,7 @@ final class HomepageSectionLayoutProvider: FeatureFlaggable { containerWidth: containerWidth, isLandscape: UIDevice.current.orientation.isLandscape, shouldShowSection: topSitesState.shouldShowSection, + shouldShowAddShortcutTile: topSitesState.shouldShowAddShortcutTile, contentSizeCategory: contentSizeCategory ) @@ -655,7 +656,7 @@ final class HomepageSectionLayoutProvider: FeatureFlaggable { } let cellsData = topSitesState.topSitesData.prefix(maxCells) - guard !cellsData.isEmpty else { + guard !cellsData.isEmpty || topSitesState.shouldShowAddShortcutTile else { measurementsCache.setHeight(0, for: measurementKey) return 0 } @@ -669,11 +670,17 @@ final class HomepageSectionLayoutProvider: FeatureFlaggable { } // Build array of configured cells for the data being displayed on the homepage - let allCells = cellsData.map { data in + var allCells = cellsData.map { data in let cell = TopSiteCell() cell.configure(data, position: 0, theme: LightTheme(), textColor: .black) return cell } + if topSitesState.shouldShowAddShortcutTile { + let cell = TopSiteCell() + cell.configureAddShortcutTile(theme: LightTheme(), textColor: .black) + allCells.append(cell) + } + allCells = Array(allCells.prefix(maxCells)) // Group into rows and compute each rows max height let rowHeights = stride(from: 0, to: allCells.count, by: cols).map { start in diff --git a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSiteCell.swift b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSiteCell.swift index 581482c6c633a..ce45df8df82ef 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSiteCell.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSiteCell.swift @@ -17,6 +17,7 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { struct UX { static let imageBackgroundSize = CGSize(width: 60, height: 60) + static let addShortcutIconSize = CGSize(width: 24, height: 24) static let pinIconSize = CGSize(width: 8, height: 8) static let textSafeSpace: CGFloat = 6 static let faviconCornerRadius: CGFloat = 16 @@ -37,6 +38,11 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { return imageView }() + private lazy var addShortcutImageView: UIImageView = .build { imageView in + imageView.image = UIImage.templateImageNamed(StandardImageIdentifiers.Large.plus) + imageView.isHidden = true + } + private lazy var descriptionWrapper: UIStackView = .build { stackView in stackView.backgroundColor = .clear stackView.axis = .vertical @@ -106,6 +112,8 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { titleLabel.text = nil sponsoredLabel.text = nil pinImageView.isHidden = true + imageView.isHidden = true + addShortcutImageView.isHidden = true imageViewConstraints.forEach { $0.constant = 0 } } @@ -132,6 +140,8 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { self.theme = theme homeTopSite = topSite titleLabel.text = topSite.title + imageView.isHidden = false + addShortcutImageView.isHidden = true selectedOverlay.isHidden = true accessibilityLabel = topSite.accessibilityLabel accessibilityTraits = .link @@ -173,6 +183,22 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { applyTheme(theme: theme) } + func configureAddShortcutTile(theme: Theme, textColor: UIColor?) { + self.theme = theme + self.textColor = textColor + homeTopSite = nil + titleLabel.text = .FirefoxHomepage.Shortcuts.AddShortcut.TileTitle + sponsoredLabel.text = nil + pinImageView.isHidden = true + imageView.isHidden = true + addShortcutImageView.isHidden = false + selectedOverlay.isHidden = true + accessibilityLabel = .FirefoxHomepage.Shortcuts.AddShortcut.TileTitle + accessibilityTraits = .button + + applyTheme(theme: theme) + } + // MARK: - Setup Helper methods private func setupLayout() { @@ -180,6 +206,7 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { descriptionWrapper.addArrangedSubview(sponsoredLabel) rootContainer.addSubview(imageView) + rootContainer.addSubview(addShortcutImageView) rootContainer.addSubview(selectedOverlay) rootContainer.addSubview(pinImageView) contentView.addSubview(rootContainer) @@ -201,6 +228,11 @@ class TopSiteCell: UICollectionViewCell, ReusableCell { selectedOverlay.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor), selectedOverlay.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor), + addShortcutImageView.centerXAnchor.constraint(equalTo: rootContainer.centerXAnchor), + addShortcutImageView.centerYAnchor.constraint(equalTo: rootContainer.centerYAnchor), + addShortcutImageView.widthAnchor.constraint(equalToConstant: UX.addShortcutIconSize.width), + addShortcutImageView.heightAnchor.constraint(equalToConstant: UX.addShortcutIconSize.height), + pinImageView.topAnchor.constraint(equalTo: rootContainer.topAnchor), pinImageView.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor), pinImageView.widthAnchor.constraint(equalToConstant: UX.pinIconSize.width), @@ -263,6 +295,7 @@ extension TopSiteCell: ThemeApplicable { sponsoredLabel.textColor = textColor ?? theme.colors.textPrimary selectedOverlay.backgroundColor = theme.colors.layer5Hover.withAlphaComponent(0.25) pinImageView.tintColor = theme.colors.iconSecondary + addShortcutImageView.tintColor = theme.colors.iconPrimary adjustBlur(theme: theme) } diff --git a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesAction.swift b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesAction.swift index 4440c4ccd5abf..a73d2e95abe3a 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesAction.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesAction.swift @@ -17,12 +17,14 @@ struct TopSitesAction: Action { let topSites: [TopSiteConfiguration]? let numberOfRows: Int? let isEnabled: Bool? + let shouldShowAddShortcutTile: Bool? let telemetryConfig: TopSitesTelemetryConfig? init( topSites: [TopSiteConfiguration]? = nil, numberOfRows: Int? = nil, isEnabled: Bool? = nil, + shouldShowAddShortcutTile: Bool? = nil, telemetryConfig: TopSitesTelemetryConfig? = nil, windowUUID: WindowUUID, actionType: any ActionType @@ -30,6 +32,7 @@ struct TopSitesAction: Action { self.windowUUID = windowUUID self.actionType = actionType self.isEnabled = isEnabled + self.shouldShowAddShortcutTile = shouldShowAddShortcutTile self.topSites = topSites self.numberOfRows = numberOfRows self.telemetryConfig = telemetryConfig diff --git a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesMiddleware.swift b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesMiddleware.swift index 1bbda5c68f1d1..9aa117840bc48 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesMiddleware.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesMiddleware.swift @@ -14,6 +14,7 @@ final class TopSitesMiddleware { private let homepageTelemetry: HomepageTelemetry private let bookmarksTelemetry: BookmarksTelemetry private let unifiedAdsTelemetry: UnifiedAdsCallbackTelemetry + private let featureFlagsProvider: FeatureFlagProviding private let logger: Logger private let profile: Profile @@ -23,6 +24,7 @@ final class TopSitesMiddleware { homepageTelemetry: HomepageTelemetry = HomepageTelemetry(), bookmarksTelemetry: BookmarksTelemetry = BookmarksTelemetry(), unifiedAdsTelemetry: UnifiedAdsCallbackTelemetry = DefaultUnifiedAdsCallbackTelemetry(), + featureFlagsProvider: FeatureFlagProviding = AppContainer.shared.resolve(), searchEnginesManager: SearchEnginesManager = AppContainer.shared.resolve(), logger: Logger = DefaultLogger.shared ) { @@ -37,6 +39,7 @@ final class TopSitesMiddleware { self.homepageTelemetry = homepageTelemetry self.bookmarksTelemetry = bookmarksTelemetry self.unifiedAdsTelemetry = unifiedAdsTelemetry + self.featureFlagsProvider = featureFlagsProvider self.logger = logger self.profile = profile } @@ -120,6 +123,7 @@ final class TopSitesMiddleware { store.dispatch( TopSitesAction( topSites: topSites, + shouldShowAddShortcutTile: featureFlagsProvider.isEnabled(.homepageAddShortcutTile), windowUUID: windowUUID, actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites ) diff --git a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesSectionState.swift b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesSectionState.swift index cebea60a3a99f..d4a6515a2fa1c 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesSectionState.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage/TopSites/TopSitesSectionState.swift @@ -19,6 +19,7 @@ struct TopSitesSectionState: StateType, Equatable { let numberOfTilesPerRow: Int let shouldShowSection: Bool let shouldShowSectionHeader: Bool + let shouldShowAddShortcutTile: Bool struct Constants { static let sectionHeaderConfiguration = SectionHeaderConfiguration( @@ -42,7 +43,8 @@ struct TopSitesSectionState: StateType, Equatable { numberOfRows: numberOfRows, numberOfTilesPerRow: TopSitesSectionLayoutProvider.UX.minCards, shouldShowSection: shouldShowSection, - shouldShowSectionHeader: false + shouldShowSectionHeader: false, + shouldShowAddShortcutTile: false ) } @@ -53,6 +55,7 @@ struct TopSitesSectionState: StateType, Equatable { numberOfTilesPerRow: Int, shouldShowSection: Bool, shouldShowSectionHeader: Bool, + shouldShowAddShortcutTile: Bool ) { self.windowUUID = windowUUID self.topSitesData = topSitesData @@ -60,6 +63,7 @@ struct TopSitesSectionState: StateType, Equatable { self.numberOfTilesPerRow = numberOfTilesPerRow self.shouldShowSection = shouldShowSection self.shouldShowSectionHeader = shouldShowSectionHeader + self.shouldShowAddShortcutTile = shouldShowAddShortcutTile } static let reducer: Reducer = { state, action in @@ -89,11 +93,18 @@ struct TopSitesSectionState: StateType, Equatable { return defaultState(from: state) } - let shouldShowSectionHeader = sites.count > state.numberOfRows * state.numberOfTilesPerRow + let shouldShowAddShortcutTile = topSitesAction.shouldShowAddShortcutTile ?? state.shouldShowAddShortcutTile + let shouldShowSectionHeader = getShouldShowSectionHeader( + siteCount: sites.count, + numberOfRows: state.numberOfRows, + numberOfTilesPerRow: state.numberOfTilesPerRow, + shouldShowAddShortcutTile: shouldShowAddShortcutTile + ) return state.copyWithUpdates( topSitesData: sites, - shouldShowSectionHeader: shouldShowSectionHeader + shouldShowSectionHeader: shouldShowSectionHeader, + shouldShowAddShortcutTile: shouldShowAddShortcutTile ) } @@ -104,7 +115,12 @@ struct TopSitesSectionState: StateType, Equatable { return defaultState(from: state) } - let shouldShowSectionHeader = state.topSitesData.count > numberOfRows * state.numberOfTilesPerRow + let shouldShowSectionHeader = getShouldShowSectionHeader( + siteCount: state.topSitesData.count, + numberOfRows: numberOfRows, + numberOfTilesPerRow: state.numberOfTilesPerRow, + shouldShowAddShortcutTile: state.shouldShowAddShortcutTile + ) return state.copyWithUpdates( numberOfRows: numberOfRows, @@ -119,7 +135,12 @@ struct TopSitesSectionState: StateType, Equatable { return defaultState(from: state) } - let shouldShowSectionHeader = state.topSitesData.count > state.numberOfRows * numberOfTilesPerRow + let shouldShowSectionHeader = getShouldShowSectionHeader( + siteCount: state.topSitesData.count, + numberOfRows: state.numberOfRows, + numberOfTilesPerRow: numberOfTilesPerRow, + shouldShowAddShortcutTile: state.shouldShowAddShortcutTile + ) return state.copyWithUpdates( numberOfTilesPerRow: numberOfTilesPerRow, @@ -142,4 +163,18 @@ struct TopSitesSectionState: StateType, Equatable { static func defaultState(from state: TopSitesSectionState) -> TopSitesSectionState { return state.copyWithUpdates() } + + /// Shows the shortcuts section header with shortcuts library affordance + /// when real shortcuts overflow the visible grid, + /// or when the Add Shortcut tile is displaced by a full visible grid. + private static func getShouldShowSectionHeader(siteCount: Int, + numberOfRows: Int, + numberOfTilesPerRow: Int, + shouldShowAddShortcutTile: Bool) -> Bool { + let maxVisibleTileCount = numberOfRows * numberOfTilesPerRow + guard maxVisibleTileCount > 0 else { return false } + + return siteCount > maxVisibleTileCount || + (shouldShowAddShortcutTile && siteCount >= maxVisibleTileCount) + } } diff --git a/firefox-ios/Client/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryState.swift b/firefox-ios/Client/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryState.swift index e1d898be2604c..8e32ad02b3335 100644 --- a/firefox-ios/Client/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryState.swift +++ b/firefox-ios/Client/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryState.swift @@ -10,6 +10,7 @@ import Redux struct ShortcutsLibraryState: ScreenState, Equatable { var windowUUID: WindowUUID let shortcuts: [TopSiteConfiguration] + let shouldShowAddShortcutTile: Bool let shouldRecordImpressionTelemetry: Bool init(appState: AppState, uuid: WindowUUID) { @@ -29,6 +30,7 @@ struct ShortcutsLibraryState: ScreenState, Equatable { self.init( windowUUID: windowUUID, shortcuts: [], + shouldShowAddShortcutTile: false, shouldRecordImpressionTelemetry: false ) } @@ -36,10 +38,12 @@ struct ShortcutsLibraryState: ScreenState, Equatable { private init( windowUUID: WindowUUID, shortcuts: [TopSiteConfiguration], + shouldShowAddShortcutTile: Bool, shouldRecordImpressionTelemetry: Bool ) { self.windowUUID = windowUUID self.shortcuts = shortcuts + self.shouldShowAddShortcutTile = shouldShowAddShortcutTile self.shouldRecordImpressionTelemetry = shouldRecordImpressionTelemetry } @@ -81,7 +85,8 @@ struct ShortcutsLibraryState: ScreenState, Equatable { } return state.copyWithUpdates( - shortcuts: sites + shortcuts: sites, + shouldShowAddShortcutTile: topSitesAction.shouldShowAddShortcutTile ?? state.shouldShowAddShortcutTile ) } diff --git a/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryDiffableDataSource.swift b/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryDiffableDataSource.swift index 5e8455149e286..d6be15c40a688 100644 --- a/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryDiffableDataSource.swift +++ b/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryDiffableDataSource.swift @@ -16,12 +16,22 @@ final class ShortcutsLibraryDiffableDataSource: enum Item: Hashable { case shortcut(TopSiteConfiguration) + case addShortcutTile static var cellTypes: [ReusableCell.Type] { return [ TopSiteCell.self, ] } + + var canHandleLongPress: Bool { + switch self { + case .addShortcutTile: + return false + case .shortcut: + return true + } + } } // MARK: - Private constants @@ -39,8 +49,11 @@ final class ShortcutsLibraryDiffableDataSource: } private func getShortcuts(with state: ShortcutsLibraryState) -> [ShortcutsLibraryDiffableDataSource.Item]? { - let shortcuts: [Item] = state.shortcuts.compactMap { .shortcut($0) } - guard !shortcuts.isEmpty else { return nil } - return Array(shortcuts.prefix(maxShortcutsToShow)) + let shouldShowAddShortcutTile = state.shouldShowAddShortcutTile + let numberOfShortcutsToShow = shouldShowAddShortcutTile ? max(maxShortcutsToShow - 1, 0) : maxShortcutsToShow + let visibleShortcuts: [Item] = state.shortcuts.prefix(numberOfShortcutsToShow).compactMap { .shortcut($0) } + let visibleItems = shouldShowAddShortcutTile ? visibleShortcuts + [.addShortcutTile] : visibleShortcuts + guard !visibleItems.isEmpty else { return nil } + return visibleItems } } diff --git a/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryViewController.swift b/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryViewController.swift index 4c9fb087c2de2..d86ab75dd5397 100644 --- a/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryViewController.swift +++ b/firefox-ios/Client/Frontend/ShortcutsLibrary/ShortcutsLibraryViewController.swift @@ -253,6 +253,19 @@ class ShortcutsLibraryViewController: UIViewController, return topSiteCell } + return UICollectionViewCell() + case .addShortcutTile: + let cellType: ReusableCell.Type = TopSiteCell.self + + guard let topSiteCell = collectionView?.dequeueReusableCell(cellType: cellType, for: indexPath) else { + return UICollectionViewCell() + } + + if let topSiteCell = topSiteCell as? TopSiteCell { + topSiteCell.configureAddShortcutTile(theme: currentTheme, textColor: nil) + return topSiteCell + } + return UICollectionViewCell() } } @@ -333,6 +346,7 @@ class ShortcutsLibraryViewController: UIViewController, return } + guard item.canHandleLongPress else { return } navigateToContextMenu(for: item, sourceView: sourceView) } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/HomepageDiffableDataSourceTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/HomepageDiffableDataSourceTests.swift index b434c68195233..adfc064d7aa6f 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/HomepageDiffableDataSourceTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/HomepageDiffableDataSourceTests.swift @@ -188,6 +188,110 @@ final class HomepageDiffableDataSourceTests: XCTestCase { XCTAssertEqual(snapshot.sectionIdentifiers, expectedSections) } + @MainActor + func test_updateSnapshot_withAddShortcutTileFlagEnabled_appendsTileWhenThereIsRoom() throws { + let dataSource = try XCTUnwrap(diffableDataSource) + + let stateWithRows = HomepageState.reducer( + HomepageState(windowUUID: .XCTestDefaultUUID), + TopSitesAction( + numberOfRows: 1, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesActionType.updatedNumberOfRows + ) + ) + let numberOfTilesPerRow = stateWithRows.topSitesState.numberOfTilesPerRow + + let updatedState = HomepageState.reducer( + stateWithRows, + TopSitesAction( + topSites: createSites(count: numberOfTilesPerRow - 1), + shouldShowAddShortcutTile: true, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + dataSource.updateSnapshot(state: updatedState, jumpBackInDisplayConfig: mockSectionConfig) + + let section = HomepageSection.topSites(nil, numberOfTilesPerRow, false) + let items = dataSource.snapshot().itemIdentifiers(inSection: section) + XCTAssertEqual(items.count, numberOfTilesPerRow) + let expectedTopSiteTitles = (0.. [String] { + items.compactMap { + guard case .topSite(let topSite, _) = $0 else { return nil } + return topSite.title + } + } + private var mockSectionConfig: JumpBackInSectionLayoutConfiguration { return JumpBackInSectionLayoutConfiguration( maxLocalTabsWhenSyncedTabExists: 1, diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesMiddlewareTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesMiddlewareTests.swift index 58883590beb87..91dadb4032ded 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesMiddlewareTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesMiddlewareTests.swift @@ -57,6 +57,28 @@ final class TopSitesMiddlewareTests: XCTestCase, StoreTestUtility { XCTAssertEqual(actionsCalled.last?.topSites?.count, 30) } + func test_homepageInitializeAction_whenAddShortcutTileFlagEnabled_dispatchesAddShortcutTileState() throws { + let featureFlags = MockNimbusFeatureFlags() + featureFlags.enabledFlags.insert(.homepageAddShortcutTile) + let subject = createSubject(topSitesManager: mockTopSitesManager, featureFlagsProvider: featureFlags) + let action = HomepageAction( + windowUUID: .XCTestDefaultUUID, + actionType: HomepageActionType.initialize + ) + + let dispatchExpectation = XCTestExpectation(description: "Top sites state update is dispatched") + mockStore.dispatchCalled = { + dispatchExpectation.fulfill() + } + + subject.topSitesProvider(appState, action) + + wait(for: [dispatchExpectation], timeout: 1) + + let actionsCalled = try XCTUnwrap(mockStore.dispatchedActions as? [TopSitesAction]) + XCTAssertTrue(actionsCalled.last?.shouldShowAddShortcutTile == true) + } + func test_homepageSectionSeenAction_withUnifiedAds_sendTelemetryData() { let unifiedAdsTelemetry = MockUnifiedAdsCallbackTelemetry() let subject = createSubject( @@ -379,13 +401,15 @@ final class TopSitesMiddlewareTests: XCTestCase, StoreTestUtility { // MARK: - Helpers private func createSubject( topSitesManager: MockTopSitesManager, - unifiedAdsTelemetry: UnifiedAdsCallbackTelemetry? = nil + unifiedAdsTelemetry: UnifiedAdsCallbackTelemetry? = nil, + featureFlagsProvider: FeatureFlagProviding = MockNimbusFeatureFlags() ) -> TopSitesMiddleware { return TopSitesMiddleware( topSitesManager: topSitesManager, homepageTelemetry: HomepageTelemetry(gleanWrapper: mockGleanWrapper), bookmarksTelemetry: BookmarksTelemetry(gleanWrapper: mockGleanWrapper), - unifiedAdsTelemetry: unifiedAdsTelemetry ?? MockUnifiedAdsCallbackTelemetry() + unifiedAdsTelemetry: unifiedAdsTelemetry ?? MockUnifiedAdsCallbackTelemetry(), + featureFlagsProvider: featureFlagsProvider ) } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesSectionStateTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesSectionStateTests.swift index 1c8bcc749751c..1a390e664911c 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesSectionStateTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage/Redux/TopSitesSectionStateTests.swift @@ -26,6 +26,7 @@ final class TopsSitesSectionStateTests: XCTestCase { XCTAssertEqual(initialState.topSitesData, []) XCTAssertEqual(TopSitesSectionState.Constants.sectionHeaderConfiguration.isButtonHidden, false) XCTAssertFalse(initialState.shouldShowSectionHeader) + XCTAssertFalse(initialState.shouldShowAddShortcutTile) } @MainActor @@ -91,6 +92,26 @@ final class TopsSitesSectionStateTests: XCTestCase { XCTAssertFalse(newState.shouldShowSectionHeader) } + @MainActor + func test_retrievedUpdatedSitesAction_withAddShortcutTileAndVisibleSitesOnly_showsSectionHeader() { + let initialState = createSubject() + let reducer = topSiteReducer() + let visibleTopSites = initialState.numberOfRows * initialState.numberOfTilesPerRow + + let newState = reducer( + initialState, + TopSitesAction( + topSites: createSites(count: visibleTopSites), + shouldShowAddShortcutTile: true, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + XCTAssertTrue(newState.shouldShowSectionHeader) + XCTAssertTrue(newState.shouldShowAddShortcutTile) + } + @MainActor func test_retrievedUpdatedSitesAction_returnsDefaultState() throws { let initialState = createSubject() diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryDiffableDataSourceTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryDiffableDataSourceTests.swift index ca0fddca619be..3b92f9b7a4f86 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryDiffableDataSourceTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryDiffableDataSourceTests.swift @@ -82,6 +82,76 @@ final class ShortcutsLibraryDiffableDataSourceTests: XCTestCase { XCTAssertEqual(snapshot.sectionIdentifiers, expectedSections) } + @MainActor + func test_updateSnapshot_withAddShortcutTileFlagEnabled_appendsTileToShortcuts() throws { + let dataSource = try XCTUnwrap(diffableDataSource) + + let state = ShortcutsLibraryState.reducer( + ShortcutsLibraryState(windowUUID: .XCTestDefaultUUID), + TopSitesAction( + topSites: createSites(count: 10), + shouldShowAddShortcutTile: true, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + dataSource.updateSnapshot(state: state) + + let items = dataSource.snapshot().itemIdentifiers(inSection: .shortcuts) + XCTAssertEqual(items.count, 11) + XCTAssertEqual(shortcutTitles(from: items), (0..<10).map { "Title \($0)" }) + guard case .addShortcutTile = items.last else { + return XCTFail("Expected Add Shortcut tile to be the last shortcut library item") + } + } + + @MainActor + func test_updateSnapshot_withAddShortcutTileFlagEnabled_returnsMaxItemsIncludingTile() throws { + let dataSource = try XCTUnwrap(diffableDataSource) + + let state = ShortcutsLibraryState.reducer( + ShortcutsLibraryState(windowUUID: .XCTestDefaultUUID), + TopSitesAction( + topSites: createSites(count: 20), + shouldShowAddShortcutTile: true, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + dataSource.updateSnapshot(state: state) + + let items = dataSource.snapshot().itemIdentifiers(inSection: .shortcuts) + XCTAssertEqual(items.count, 16) + XCTAssertEqual(shortcutTitles(from: items), (0..<15).map { "Title \($0)" }) + guard case .addShortcutTile = items.last else { + return XCTFail("Expected Add Shortcut tile to be the last shortcut library item") + } + } + + @MainActor + func test_updateSnapshot_withAddShortcutTileFlagEnabledAndNoShortcuts_showsAddShortcutTile() throws { + let dataSource = try XCTUnwrap(diffableDataSource) + let state = ShortcutsLibraryState.reducer( + ShortcutsLibraryState(windowUUID: .XCTestDefaultUUID), + TopSitesAction( + topSites: [], + shouldShowAddShortcutTile: true, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + dataSource.updateSnapshot(state: state) + + let items = dataSource.snapshot().itemIdentifiers(inSection: .shortcuts) + XCTAssertEqual(items.count, 1) + guard case .addShortcutTile = items.first else { + return XCTFail("Expected Add Shortcut tile to be the only shortcut library item") + } + } + private func createSites(count: Int) -> [TopSiteConfiguration] { var sites = [TopSiteConfiguration]() (0.. [String] { + items.compactMap { + guard case .shortcut(let topSite) = $0 else { return nil } + return topSite.title + } + } } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryStateTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryStateTests.swift index cd88e084a5dde..33de07140e5eb 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryStateTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/ShortcutsLibrary/Redux/ShortcutsLibraryStateTests.swift @@ -24,6 +24,7 @@ final class ShortcutsLibraryStateTests: XCTestCase { XCTAssertEqual(initialState.windowUUID, .XCTestDefaultUUID) XCTAssertEqual(initialState.shortcuts, []) + XCTAssertFalse(initialState.shouldShowAddShortcutTile) XCTAssertFalse(initialState.shouldRecordImpressionTelemetry) } @@ -80,6 +81,7 @@ final class ShortcutsLibraryStateTests: XCTestCase { initialState, TopSitesAction( topSites: [exampleShortcut], + shouldShowAddShortcutTile: true, windowUUID: .XCTestDefaultUUID, actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites ) @@ -88,6 +90,7 @@ final class ShortcutsLibraryStateTests: XCTestCase { XCTAssertEqual(newState.windowUUID, .XCTestDefaultUUID) XCTAssertEqual(newState.shortcuts.count, 1) XCTAssertEqual(newState.shortcuts.compactMap { $0.title }, ["hello"]) + XCTAssertTrue(newState.shouldShowAddShortcutTile) } @MainActor