From 0eaf1421c9a3383d05b089e7a51ada856d546983 Mon Sep 17 00:00:00 2001 From: Cyndi Chin Date: Tue, 5 Nov 2024 11:52:02 -0500 Subject: [PATCH] Add FXIOS-10165 [Homepage] Add initial Top Sites Section (#22621) --- firefox-ios/Client.xcodeproj/project.pbxproj | 36 +++ .../HomepageDiffableDataSource.swift | 7 + .../HomepageSectionLayoutProvider.swift | 47 ++- .../HomepageViewController.swift | 48 ++- .../Redux/HomepageState.swift | 10 + .../TopSites/TopSiteCell.swift | 280 ++++++++++++++++++ .../TopSites/TopSiteState.swift | 85 ++++++ .../TopSites/TopSitesAction.swift | 27 ++ .../TopSites/TopSitesManager.swift | 32 ++ .../TopSites/TopSitesMiddleware.swift | 42 +++ .../TopSites/TopSitesSectionState.swift | 58 ++++ .../Client/Redux/GlobalState/AppState.swift | 1 + .../HomepageDiffableDataSourceTests.swift | 1 - .../HomepageViewControllerTests.swift | 20 +- .../Redux/TopSitesSectionStateTests.swift | 71 +++++ 15 files changed, 744 insertions(+), 21 deletions(-) create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteState.swift create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesAction.swift create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesManager.swift create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesMiddleware.swift create mode 100644 firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesSectionState.swift create mode 100644 firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/TopSitesSectionStateTests.swift diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 9baf2c608135..750a5a4d1e35 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -757,6 +757,10 @@ 8A454D322CB8170D009436D9 /* PocketManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D312CB8170D009436D9 /* PocketManager.swift */; }; 8A454D362CB86993009436D9 /* PocketStoryState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D352CB86993009436D9 /* PocketStoryState.swift */; }; 8A454D372CB86B86009436D9 /* PocketStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D332CB85C7D009436D9 /* PocketStateTests.swift */; }; + 8A454D3F2CB9B8A0009436D9 /* TopSitesSectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D3E2CB9B8A0009436D9 /* TopSitesSectionState.swift */; }; + 8A454D412CB9B8AA009436D9 /* TopSitesAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D402CB9B8AA009436D9 /* TopSitesAction.swift */; }; + 8A454D432CB9B8F5009436D9 /* TopSitesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D422CB9B8F5009436D9 /* TopSitesManager.swift */; }; + 8A454D462CB9C83F009436D9 /* TopSitesMiddleware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A454D452CB9C83F009436D9 /* TopSitesMiddleware.swift */; }; 8A4593C72BF7BECA002758DE /* MicrosurveyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4593C32BF7BEC9002758DE /* MicrosurveyTableViewCell.swift */; }; 8A4593C82BF7BECA002758DE /* MicrosurveyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4593C42BF7BECA002758DE /* MicrosurveyViewController.swift */; }; 8A4593C92BF7BECA002758DE /* MicrosurveyTableHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A4593C52BF7BECA002758DE /* MicrosurveyTableHeaderView.swift */; }; @@ -991,6 +995,9 @@ 8AE80BBA2891C0C300BC12EA /* JumpBackInSectionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE80BB92891C0C300BC12EA /* JumpBackInSectionLayout.swift */; }; 8AE80BBC2891C20D00BC12EA /* JumpBackInList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE80BBB2891C20D00BC12EA /* JumpBackInList.swift */; }; 8AE80BBE2891C21A00BC12EA /* JumpBackInSyncedTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE80BBD2891C21A00BC12EA /* JumpBackInSyncedTab.swift */; }; + 8AE938192CD91D5A0020E6CF /* TopSiteState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE938182CD91D5A0020E6CF /* TopSiteState.swift */; }; + 8AE9381B2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE9381A2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift */; }; + 8AE9381D2CD920310020E6CF /* TopSiteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AE9381C2CD920310020E6CF /* TopSiteCell.swift */; }; 8AEAD9F32C3D7B3E001A2C5A /* FeatureFlagsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEAD9F22C3D7B3E001A2C5A /* FeatureFlagsSettings.swift */; }; 8AEAD9F52C3D7BA9001A2C5A /* FeatureFlagsDebugViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEAD9F42C3D7BA9001A2C5A /* FeatureFlagsDebugViewController.swift */; }; 8AEAD9F92C3DB0CD001A2C5A /* MicrosurveyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEAD9F62C3DB0BF001A2C5A /* MicrosurveyTests.swift */; }; @@ -7018,6 +7025,10 @@ 8A454D312CB8170D009436D9 /* PocketManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketManager.swift; sourceTree = ""; }; 8A454D332CB85C7D009436D9 /* PocketStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketStateTests.swift; sourceTree = ""; }; 8A454D352CB86993009436D9 /* PocketStoryState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketStoryState.swift; sourceTree = ""; }; + 8A454D3E2CB9B8A0009436D9 /* TopSitesSectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesSectionState.swift; sourceTree = ""; }; + 8A454D402CB9B8AA009436D9 /* TopSitesAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesAction.swift; sourceTree = ""; }; + 8A454D422CB9B8F5009436D9 /* TopSitesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesManager.swift; sourceTree = ""; }; + 8A454D452CB9C83F009436D9 /* TopSitesMiddleware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesMiddleware.swift; sourceTree = ""; }; 8A4593C32BF7BEC9002758DE /* MicrosurveyTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MicrosurveyTableViewCell.swift; sourceTree = ""; }; 8A4593C42BF7BECA002758DE /* MicrosurveyViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MicrosurveyViewController.swift; sourceTree = ""; }; 8A4593C52BF7BECA002758DE /* MicrosurveyTableHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MicrosurveyTableHeaderView.swift; sourceTree = ""; }; @@ -7258,6 +7269,9 @@ 8AE80BB92891C0C300BC12EA /* JumpBackInSectionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpBackInSectionLayout.swift; sourceTree = ""; }; 8AE80BBB2891C20D00BC12EA /* JumpBackInList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpBackInList.swift; sourceTree = ""; }; 8AE80BBD2891C21A00BC12EA /* JumpBackInSyncedTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpBackInSyncedTab.swift; sourceTree = ""; }; + 8AE938182CD91D5A0020E6CF /* TopSiteState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSiteState.swift; sourceTree = ""; }; + 8AE9381A2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSitesSectionStateTests.swift; sourceTree = ""; }; + 8AE9381C2CD920310020E6CF /* TopSiteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopSiteCell.swift; sourceTree = ""; }; 8AEAD9F22C3D7B3E001A2C5A /* FeatureFlagsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsSettings.swift; sourceTree = ""; }; 8AEAD9F42C3D7BA9001A2C5A /* FeatureFlagsDebugViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagsDebugViewController.swift; sourceTree = ""; }; 8AEAD9F62C3DB0BF001A2C5A /* MicrosurveyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosurveyTests.swift; sourceTree = ""; }; @@ -11016,6 +11030,19 @@ path = Header; sourceTree = ""; }; + 8A454D3D2CB9B896009436D9 /* TopSites */ = { + isa = PBXGroup; + children = ( + 8A454D3E2CB9B8A0009436D9 /* TopSitesSectionState.swift */, + 8A454D422CB9B8F5009436D9 /* TopSitesManager.swift */, + 8A454D402CB9B8AA009436D9 /* TopSitesAction.swift */, + 8A454D452CB9C83F009436D9 /* TopSitesMiddleware.swift */, + 8AE938182CD91D5A0020E6CF /* TopSiteState.swift */, + 8AE9381C2CD920310020E6CF /* TopSiteCell.swift */, + ); + path = TopSites; + sourceTree = ""; + }; 8A46F5A62C9E4389005B6422 /* RemoteRecords */ = { isa = PBXGroup; children = ( @@ -11061,6 +11088,7 @@ 8A552AC52CB43AB300564C98 /* HomepageStateTests.swift */, 8A454D332CB85C7D009436D9 /* PocketStateTests.swift */, 8A87B42E2CC1A3AA003A9239 /* PocketMiddlewareTests.swift */, + 8AE9381A2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift */, ); path = Redux; sourceTree = ""; @@ -11178,6 +11206,7 @@ 8A7D08E12CAAF79F0035999C /* Homepage Rebuild */ = { isa = PBXGroup; children = ( + 8A454D3D2CB9B896009436D9 /* TopSites */, 8A454D2A2CB7079A009436D9 /* Header */, 8ABDBAA42CB6BF3A00B51F63 /* Pocket */, 8A7D08E22CAAF7C30035999C /* HomepageViewController.swift */, @@ -15338,6 +15367,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8AE9381B2CD91FDB0020E6CF /* TopSitesSectionStateTests.swift in Sources */, 4590912E2A2E4F7700061F0C /* AutopushTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -15826,6 +15856,7 @@ 8AAEBA062BF51141000C02B5 /* MicrosurveyMiddleware.swift in Sources */, 431C0CA925C890E500395CE4 /* DefaultBrowserOnboardingViewModel.swift in Sources */, DFACBF81277B916B003D5F41 /* ConfigurableGradientView.swift in Sources */, + 8A454D432CB9B8F5009436D9 /* TopSitesManager.swift in Sources */, 7482205C1DBAB56300EEEA72 /* MailProviders.swift in Sources */, 8A93F87029D3A597004159D9 /* SceneCoordinator.swift in Sources */, C88E7A602A05551B0072E638 /* NimbusOnboardingFeatureLayerProtocol.swift in Sources */, @@ -15936,6 +15967,7 @@ 392ED7E61D0AEFEF009D9B62 /* HomePageAccessors.swift in Sources */, 8A0017C128A3FF6100FEFC8B /* MessageCardDataAdaptor.swift in Sources */, 7BA8D1C71BA037F500C8AE9E /* DownloadHelper.swift in Sources */, + 8AE938192CD91D5A0020E6CF /* TopSiteState.swift in Sources */, 8A0D32842A61E1CC007D976D /* StatusBarOverlay.swift in Sources */, 8A5D1CA42A30D69A005AD35C /* SearchSetting.swift in Sources */, 74BBDF472A17979000D3BEFE /* OnboardingDefaultBrowserModelProtocol.swift in Sources */, @@ -16199,6 +16231,7 @@ C8DC90C52A066B6A0008832B /* MarkupTokenizingUtility.swift in Sources */, EBC4869D2195F58300CDA48D /* ErrorPageHelper.swift in Sources */, C84655E8288739CB00861B4A /* WallpaperCollectionAvailability.swift in Sources */, + 8A454D462CB9C83F009436D9 /* TopSitesMiddleware.swift in Sources */, 8ABC5AEE284532C900FEA552 /* PocketDiscoverCell.swift in Sources */, ABE856AD2C75029F00C56F47 /* TrackingProtectionStatusView.swift in Sources */, C834330026BAD32800ABAAA6 /* EnhancedTrackingProtectionDetailsVM.swift in Sources */, @@ -16300,6 +16333,7 @@ E68AEDB01B18F81A00133D99 /* SwipeAnimator.swift in Sources */, 1DDE3DB32AC34E1E0039363B /* TabCell.swift in Sources */, 5A1947152B8FA9E0009C7A6C /* BrowserViewType.swift in Sources */, + 8A454D412CB9B8AA009436D9 /* TopSitesAction.swift in Sources */, 3BF56D271CDBBE1F00AC4D75 /* SimpleToast.swift in Sources */, 8C4B0F5D2C076B12008B3E74 /* UpdatableAddressFields+Decodable.swift in Sources */, C8B0F5F6283B7CCE007AE65D /* PocketStory.swift in Sources */, @@ -16380,6 +16414,7 @@ 0A6875152C91886A00606F53 /* CertificatesHeaderView.swift in Sources */, C8445A14264428DC00B83F53 /* LibraryPanelViewState.swift in Sources */, 8A8158CB2C2C77B000281F72 /* MicrosurveyTelemetry.swift in Sources */, + 8AE9381D2CD920310020E6CF /* TopSiteCell.swift in Sources */, D5D052D92645ABF400759F85 /* ExperimentsSettingsView.swift in Sources */, E134D5802B31FF3100C6B17B /* FakespotAdLinkButton.swift in Sources */, 8AE0BF4F2819B10E00F33EC4 /* TopSitesSettingsViewController.swift in Sources */, @@ -16487,6 +16522,7 @@ E4C358551AF144BA00299F7E /* FSReadingList.m in Sources */, E17798A22BD804D300F6F0EB /* AddressToolbarContainerModel.swift in Sources */, 8AE1E1CB27B18F560024C45E /* SearchBarSettingsViewController.swift in Sources */, + 8A454D3F2CB9B8A0009436D9 /* TopSitesSectionState.swift in Sources */, 8AD40FC527BADC1F00672675 /* TabToolbarHelper.swift in Sources */, 9609F4CA26B57CE800F81493 /* Calendar+Extension.swift in Sources */, E663D5781BB341C4001EF30E /* ToggleButton.swift in Sources */, diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift index b1bb4fc1fbda..484fc5c22907 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageDiffableDataSource.swift @@ -20,6 +20,8 @@ final class HomepageDiffableDataSource: enum HomeItem: Hashable { case header + case topSite(TopSiteState) + case topSiteEmpty case pocket(PocketStoryState) case pocketDiscover case customizeHomepage @@ -27,6 +29,8 @@ final class HomepageDiffableDataSource: static var cellTypes: [ReusableCell.Type] { return [ HomepageHeaderCell.self, + TopSiteCell.self, + EmptyTopSiteCell.self, PocketStandardCell.self, PocketDiscoverCell.self, CustomizeHomepageSectionCell.self @@ -41,6 +45,9 @@ final class HomepageDiffableDataSource: snapshot.appendItems([.header], toSection: .header) snapshot.appendItems([], toSection: .topSites) + let topSites: [HomeItem] = state.topSitesState.topSitesData.compactMap { .topSite($0) } + snapshot.appendItems(topSites, toSection: .topSites) + let stories: [HomeItem] = state.pocketState.pocketData.compactMap { .pocket($0) } snapshot.appendItems(stories, toSection: .pocket) snapshot.appendItems([.pocketDiscover], toSection: .pocket) diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageSectionLayoutProvider.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageSectionLayoutProvider.swift index b163d366640e..9804c086de03 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageSectionLayoutProvider.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageSectionLayoutProvider.swift @@ -9,6 +9,8 @@ import Common final class HomepageSectionLayoutProvider { struct UX { static let standardInset: CGFloat = 16 + static let standardSpacing: CGFloat = 16 + static let interGroupSpacing: CGFloat = 8 static let iPadInset: CGFloat = 50 static let spacingBetweenSections: CGFloat = 62 @@ -34,7 +36,6 @@ final class HomepageSectionLayoutProvider { static let fractionalWidthiPhoneLandscape: CGFloat = 0.46 static let headerFooterHeight: CGFloat = 34 static let interItemSpacing = NSCollectionLayoutSpacing.fixed(8) - static let interGroupSpacing: CGFloat = 8 // The dimension of a cell // Fractions for iPhone to only show a slight portion of the next column @@ -49,6 +50,11 @@ final class HomepageSectionLayoutProvider { } } } + + struct TopSitesConstants { + static let cellEstimatedSize = CGSize(width: 85, height: 94) + static let numberOfTilesPerRow = 4 + } } private var logger: Logger @@ -80,7 +86,10 @@ final class HomepageSectionLayoutProvider { case .header: return createHeaderSectionLayout(for: traitCollection) case .topSites: - return createDefaultSectionLayout() + return createTopSitesSectionLayout( + for: traitCollection, + numberOfTilesPerRow: UX.TopSitesConstants.numberOfTilesPerRow + ) case .pocket: return createPocketSectionLayout(for: traitCollection) case .customizeHomepage: @@ -130,7 +139,7 @@ final class HomepageSectionLayoutProvider { top: 0, leading: 0, bottom: 0, - trailing: UX.PocketConstants.interGroupSpacing) + trailing: UX.interGroupSpacing) let section = NSCollectionLayoutSection(group: group) @@ -153,18 +162,36 @@ final class HomepageSectionLayoutProvider { return section } - // TODO: FXIOS-10161 - Update with proper section layout - private func createDefaultSectionLayout() -> NSCollectionLayoutSection { - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)) + func createTopSitesSectionLayout( + for traitCollection: UITraitCollection, + numberOfTilesPerRow: Int + ) -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(UX.TopSitesConstants.cellEstimatedSize.height) + ) let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .estimated(UX.TopSitesConstants.cellEstimatedSize.height) + ) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(100)) - let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitem: item, + count: numberOfTilesPerRow) + group.interItemSpacing = NSCollectionLayoutSpacing.fixed(UX.standardSpacing) let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) + + let leadingInset = UX.leadingInset(traitCollection: traitCollection) + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: leadingInset, + bottom: UX.spacingBetweenSections - UX.interGroupSpacing, + trailing: leadingInset + ) + section.interGroupSpacing = UX.standardSpacing return section } diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift index d25d499b0560..7da5cdfc7dea 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/HomepageViewController.swift @@ -51,8 +51,12 @@ final class HomepageViewController: UIViewController, homepageState = HomepageState(windowUUID: windowUUID) super.init(nibName: nil, bundle: nil) - setupNotifications(forObserver: self, observing: [UIApplication.didBecomeActiveNotification]) - + setupNotifications(forObserver: self, observing: [UIApplication.didBecomeActiveNotification, + .FirefoxAccountChanged, + .PrivateDataClearedHistory, + .ProfileDidFinishSyncing, + .TopSitesUpdated, + .DefaultSearchEngineUpdated]) subscribeToRedux() } @@ -201,7 +205,7 @@ final class HomepageViewController: UIViewController, guard let headerCell = collectionView?.dequeueReusableCell( cellType: HomepageHeaderCell.self, for: indexPath - ) else { + ) else { return UICollectionViewCell() } @@ -211,14 +215,36 @@ final class HomepageViewController: UIViewController, ) { [weak self] in self?.toggleHomepageMode() } + headerCell.applyTheme(theme: currentTheme) return headerCell + + case .topSite(let site): + guard let topSiteCell = collectionView?.dequeueReusableCell(cellType: TopSiteCell.self, for: indexPath) else { + return UICollectionViewCell() + } + // TODO: FXIOS-10312 - Handle textColor when working on wallpapers + topSiteCell.configure( + site, + position: indexPath.row, + theme: currentTheme, + textColor: .systemPink + ) + return topSiteCell + + case .topSiteEmpty: + guard let emptyCell = collectionView?.dequeueReusableCell(cellType: EmptyTopSiteCell.self, for: indexPath) else { + return UICollectionViewCell() + } + emptyCell.applyTheme(theme: currentTheme) + return emptyCell + case .pocket(let story): guard let pocketCell = collectionView?.dequeueReusableCell( cellType: PocketStandardCell.self, for: indexPath - ) else { + ) else { return UICollectionViewCell() } pocketCell.configure(story: story, theme: currentTheme) @@ -228,13 +254,14 @@ final class HomepageViewController: UIViewController, guard let pocketDiscoverCell = collectionView?.dequeueReusableCell( cellType: PocketDiscoverCell.self, for: indexPath - ) else { + ) else { return UICollectionViewCell() } pocketDiscoverCell.configure(text: homepageState.pocketState.pocketDiscoverItem.title, theme: currentTheme) return pocketDiscoverCell + case .customizeHomepage: guard let customizeHomeCell = collectionView?.dequeueReusableCell( cellType: CustomizeHomepageSectionCell.self, @@ -375,6 +402,17 @@ final class HomepageViewController: UIViewController, actionType: PocketActionType.enteredForeground ) ) + case .ProfileDidFinishSyncing, + .PrivateDataClearedHistory, + .FirefoxAccountChanged, + .TopSitesUpdated, + .DefaultSearchEngineUpdated: + store.dispatch( + TopSitesAction( + windowUUID: self.windowUUID, + actionType: TopSitesActionType.fetchTopSites + ) + ) default: break } } diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageState.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageState.swift index dcff4045ad39..23e658b263f1 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageState.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/Redux/HomepageState.swift @@ -7,7 +7,10 @@ import Redux struct HomepageState: ScreenState, Equatable { var windowUUID: WindowUUID + + // Homepage sections state in the order they appear on the collection view var headerState: HeaderState + var topSitesState: TopSitesSectionState var pocketState: PocketState init(appState: AppState, uuid: WindowUUID) { @@ -23,6 +26,7 @@ struct HomepageState: ScreenState, Equatable { self.init( windowUUID: homepageState.windowUUID, headerState: homepageState.headerState, + topSitesState: homepageState.topSitesState, pocketState: homepageState.pocketState ) } @@ -31,6 +35,7 @@ struct HomepageState: ScreenState, Equatable { self.init( windowUUID: windowUUID, headerState: HeaderState(windowUUID: windowUUID), + topSitesState: TopSitesSectionState(windowUUID: windowUUID), pocketState: PocketState(windowUUID: windowUUID) ) } @@ -38,10 +43,12 @@ struct HomepageState: ScreenState, Equatable { private init( windowUUID: WindowUUID, headerState: HeaderState, + topSitesState: TopSitesSectionState, pocketState: PocketState ) { self.windowUUID = windowUUID self.headerState = headerState + self.topSitesState = topSitesState self.pocketState = pocketState } @@ -51,6 +58,7 @@ struct HomepageState: ScreenState, Equatable { return HomepageState( windowUUID: state.windowUUID, headerState: HeaderState.reducer(state.headerState, action), + topSitesState: TopSitesSectionState.reducer(state.topSitesState, action), pocketState: PocketState.reducer(state.pocketState, action) ) } @@ -60,12 +68,14 @@ struct HomepageState: ScreenState, Equatable { return HomepageState( windowUUID: state.windowUUID, headerState: HeaderState.reducer(state.headerState, action), + topSitesState: TopSitesSectionState.reducer(state.topSitesState, action), pocketState: PocketState.reducer(state.pocketState, action) ) default: return HomepageState( windowUUID: state.windowUUID, headerState: HeaderState.reducer(state.headerState, action), + topSitesState: TopSitesSectionState.reducer(state.topSitesState, action), pocketState: PocketState.reducer(state.pocketState, action) ) } diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift new file mode 100644 index 000000000000..3b9cedab365d --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift @@ -0,0 +1,280 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Shared +import SiteImageView +import Storage +import UIKit + +/// The TopSite cell that appears for the homepage rebuild project. +class TopSiteCell: UICollectionViewCell, ReusableCell { + // MARK: - Variables + + private var homeTopSite: TopSiteState? + + struct UX { + static let titleOffset: CGFloat = 4 + static let iconSize = CGSize(width: 36, height: 36) + static let imageBackgroundSize = CGSize(width: 60, height: 60) + static let pinAlignmentSpacing: CGFloat = 2 + static let pinIconSize = CGSize(width: 12, height: 12) + static let textSafeSpace: CGFloat = 6 + static let bottomSpace: CGFloat = 8 + static let imageTopSpace: CGFloat = 12 + static let imageBottomSpace: CGFloat = 12 + static let imageLeadingTrailingSpace: CGFloat = 12 + } + + private var rootContainer: UIView = .build { view in + view.backgroundColor = .clear + view.layer.cornerRadius = HomepageViewModel.UX.generalCornerRadius + } + + lazy var imageView: FaviconImageView = .build { _ in } + + // Holds the title and the pin image of the top site + private lazy var titlePinWrapper: UIStackView = .build { stackView in + stackView.backgroundColor = .clear + stackView.axis = .horizontal + stackView.alignment = .top + stackView.distribution = .fillProportionally + } + + // Holds the titlePinWrapper and the Sponsored text for a sponsored tile + private lazy var descriptionWrapper: UIStackView = .build { stackView in + stackView.backgroundColor = .clear + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .fillProportionally + } + + private lazy var pinViewHolder: UIView = .build { view in + view.isHidden = true + } + + private lazy var pinImageView: UIImageView = .build { imageView in + imageView.image = UIImage.templateImageNamed(StandardImageIdentifiers.Small.pinBadgeFill) + imageView.isHidden = true + } + + private lazy var titleLabel: UILabel = .build { titleLabel in + titleLabel.textAlignment = .center + titleLabel.font = FXFontStyles.Regular.caption1.scaledFont() + titleLabel.adjustsFontForContentSizeCategory = true + titleLabel.preferredMaxLayoutWidth = UX.imageBackgroundSize.width + HomepageViewModel.UX.shadowRadius + titleLabel.backgroundColor = .clear + titleLabel.setContentHuggingPriority(UILayoutPriority(1000), for: .vertical) + } + + private lazy var sponsoredLabel: UILabel = .build { sponsoredLabel in + sponsoredLabel.textAlignment = .center + sponsoredLabel.font = FXFontStyles.Regular.caption2.scaledFont() + sponsoredLabel.adjustsFontForContentSizeCategory = true + sponsoredLabel.preferredMaxLayoutWidth = UX.imageBackgroundSize.width + HomepageViewModel.UX.shadowRadius + } + + private lazy var selectedOverlay: UIView = .build { selectedOverlay in + selectedOverlay.isHidden = true + selectedOverlay.layer.cornerRadius = HomepageViewModel.UX.generalCornerRadius + } + + override var isSelected: Bool { + didSet { + selectedOverlay.isHidden = !isSelected + } + } + + override var isHighlighted: Bool { + didSet { + selectedOverlay.isHidden = !isHighlighted + } + } + + private var textColor: UIColor? + + // MARK: - Inits + + override init(frame: CGRect) { + super.init(frame: frame) + isAccessibilityElement = true + accessibilityIdentifier = AccessibilityIdentifiers.FirefoxHomepage.TopSites.itemCell + + setupLayout() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + titleLabel.text = nil + sponsoredLabel.text = nil + pinViewHolder.isHidden = true + pinImageView.isHidden = true + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + selectedOverlay.isHidden = true + } + + override func layoutSubviews() { + super.layoutSubviews() + + rootContainer.setNeedsLayout() + rootContainer.layoutIfNeeded() + + rootContainer.layer.shadowPath = UIBezierPath(roundedRect: rootContainer.bounds, + cornerRadius: HomepageViewModel.UX.generalCornerRadius).cgPath + } + + // MARK: - Public methods + + func configure(_ topSite: TopSiteState, + position: Int, + theme: Theme, + textColor: UIColor?) { + homeTopSite = topSite + titleLabel.text = topSite.title + accessibilityLabel = topSite.accessibilityLabel + + let siteURLString = topSite.site.url + var imageResource: SiteResource? + + if let site = topSite.site as? SponsoredTile, + let url = URL(string: site.imageURL, invalidCharacters: false) { + imageResource = .remoteURL(url: url) + } else if let site = topSite.site as? PinnedSite { + imageResource = site.faviconResource + } else if let site = topSite.site as? SuggestedSite { + imageResource = site.faviconResource + } else if let siteURL = URL(string: siteURLString), + let domainNoTLD = siteURL.baseDomain?.split(separator: ".").first, + domainNoTLD == "google" { + // Exception for Google top sites, which all return blurry low quality favicons that on the home screen. + // Return our bundled G icon for all of the Google Suite. + // Parse example: "https://drive.google.com/drive/home" > "drive.google.com" > "google" + imageResource = GoogleTopSiteManager.Constants.faviconResource + } + + let viewModel = FaviconImageViewModel(siteURLString: siteURLString, + siteResource: imageResource) + imageView.setFavicon(viewModel) + self.textColor = textColor + + configurePinnedSite(topSite) + configureSponsoredSite(topSite) + + applyTheme(theme: theme) + } + + // MARK: - Setup Helper methods + + private func setupLayout() { + titlePinWrapper.addArrangedSubview(pinViewHolder) + titlePinWrapper.addArrangedSubview(titleLabel) + pinViewHolder.addSubview(pinImageView) + + descriptionWrapper.addArrangedSubview(titlePinWrapper) + descriptionWrapper.addArrangedSubview(sponsoredLabel) + + rootContainer.addSubview(imageView) + rootContainer.addSubview(selectedOverlay) + contentView.addSubview(rootContainer) + contentView.addSubview(descriptionWrapper) + + NSLayoutConstraint.activate([ + rootContainer.topAnchor.constraint(equalTo: contentView.topAnchor), + rootContainer.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + rootContainer.widthAnchor.constraint(greaterThanOrEqualToConstant: UX.imageBackgroundSize.width), + rootContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: UX.imageBackgroundSize.height), + + imageView.topAnchor.constraint(equalTo: rootContainer.topAnchor, + constant: UX.imageTopSpace), + imageView.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor, + constant: UX.imageLeadingTrailingSpace), + imageView.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor, + constant: -UX.imageLeadingTrailingSpace), + imageView.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor, + constant: -UX.imageBottomSpace), + imageView.widthAnchor.constraint(equalToConstant: UX.iconSize.width), + imageView.heightAnchor.constraint(equalToConstant: UX.iconSize.height), + + descriptionWrapper.topAnchor.constraint(equalTo: rootContainer.bottomAnchor, + constant: UX.textSafeSpace), + descriptionWrapper.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + descriptionWrapper.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + descriptionWrapper.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + selectedOverlay.topAnchor.constraint(equalTo: rootContainer.topAnchor), + selectedOverlay.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor), + selectedOverlay.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor), + selectedOverlay.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor), + + pinViewHolder.bottomAnchor.constraint(equalTo: titleLabel.firstBaselineAnchor, + constant: UX.pinAlignmentSpacing), + pinViewHolder.leadingAnchor.constraint(equalTo: pinImageView.leadingAnchor), + pinViewHolder.trailingAnchor.constraint(equalTo: pinImageView.trailingAnchor, + constant: UX.titleOffset), + pinViewHolder.topAnchor.constraint(equalTo: pinImageView.topAnchor), + + pinImageView.widthAnchor.constraint(equalToConstant: UX.pinIconSize.width), + pinImageView.heightAnchor.constraint(equalToConstant: UX.pinIconSize.height), + ]) + } + + private func configurePinnedSite(_ topSite: TopSiteState) { + guard topSite.isPinned else { return } + + pinViewHolder.isHidden = false + pinImageView.isHidden = false + } + + private func configureSponsoredSite(_ topSite: TopSiteState) { + guard topSite.isSponsoredTile else { return } + + sponsoredLabel.text = topSite.sponsoredText + } + + private func setupShadow(theme: Theme) { + rootContainer.layer.cornerRadius = HomepageViewModel.UX.generalCornerRadius + rootContainer.layer.shadowPath = UIBezierPath(roundedRect: rootContainer.bounds, + cornerRadius: HomepageViewModel.UX.generalCornerRadius).cgPath + rootContainer.layer.shadowColor = theme.colors.shadowDefault.cgColor + rootContainer.layer.shadowOpacity = HomepageViewModel.UX.shadowOpacity + rootContainer.layer.shadowOffset = HomepageViewModel.UX.shadowOffset + rootContainer.layer.shadowRadius = HomepageViewModel.UX.shadowRadius + } +} + +// MARK: ThemeApplicable +extension TopSiteCell: ThemeApplicable { + func applyTheme(theme: Theme) { + pinImageView.tintColor = textColor ?? theme.colors.iconPrimary + titleLabel.textColor = textColor ?? theme.colors.textPrimary + sponsoredLabel.textColor = textColor ?? theme.colors.textSecondary + selectedOverlay.backgroundColor = theme.colors.layer5Hover.withAlphaComponent(0.25) + + adjustBlur(theme: theme) + } +} + +// MARK: - Blurrable +extension TopSiteCell: Blurrable { + func adjustBlur(theme: Theme) { + if shouldApplyWallpaperBlur { + rootContainer.layoutIfNeeded() + rootContainer.addBlurEffectWithClearBackgroundAndClipping(using: .systemThickMaterial) + } else { + // If blur is disabled set background color + rootContainer.removeVisualEffectView() + rootContainer.backgroundColor = theme.colors.layer5 + setupShadow(theme: theme) + } + } +} diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteState.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteState.swift new file mode 100644 index 000000000000..a491f7515e79 --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteState.swift @@ -0,0 +1,85 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Foundation +import Storage +import Shared + +/// Top site UI class, used in the homepage top site section +final class TopSiteState: Hashable, Equatable { + var site: Site + var title: String + + var sponsoredText: String { + return .FirefoxHomepage.Shortcuts.Sponsored + } + + var accessibilityLabel: String? { + return isSponsoredTile ? "\(title), \(sponsoredText)" : title + } + + var isPinned: Bool { + return (site as? PinnedSite) != nil + } + + var isSuggested: Bool { + return (site as? SuggestedSite) != nil + } + + var isSponsoredTile: Bool { + return (site as? SponsoredTile) != nil + } + + var isGoogleGUID: Bool { + return site.guid == GoogleTopSiteManager.Constants.googleGUID + } + + var isGoogleURL: Bool { + return site.url == GoogleTopSiteManager.Constants.usUrl || site.url == GoogleTopSiteManager.Constants.rowUrl + } + + var identifier = UUID().uuidString + + init(site: Site) { + self.site = site + if let provider = site.metadata?.providerName { + title = provider.lowercased().capitalized + } else { + title = site.title + } + } + + // MARK: Telemetry + + func impressionTracking(position: Int) { + // Only sending sponsored tile impressions for now + guard let tile = site as? SponsoredTile else { return } + + SponsoredTileTelemetry.sendImpressionTelemetry(tile: tile, position: position) + } + + func getTelemetrySiteType() -> String { + if isPinned && isGoogleGUID { + return "google" + } else if isPinned { + return "user-added" + } else if isSuggested { + return "suggested" + } else if isSponsoredTile { + return "sponsored" + } + + return "history-based" + } + + // MARK: - Equatable + static func == (lhs: TopSiteState, rhs: TopSiteState) -> Bool { + lhs.site == rhs.site + } + + // MARK: - Hashable + func hash(into hasher: inout Hasher) { + hasher.combine(self.site) + } +} diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesAction.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesAction.swift new file mode 100644 index 000000000000..0bc792e26d05 --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesAction.swift @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ +import Common +import Foundation +import Redux + +final class TopSitesAction: Action { + var topSites: [TopSiteState]? + + init( + topSites: [TopSiteState]? = nil, + windowUUID: WindowUUID, + actionType: any ActionType + ) { + self.topSites = topSites + super.init(windowUUID: windowUUID, actionType: actionType) + } +} + +enum TopSitesActionType: ActionType { + case fetchTopSites +} + +enum TopSitesMiddlewareActionType: ActionType { + case retrievedUpdatedSites +} diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesManager.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesManager.swift new file mode 100644 index 000000000000..844210bfe37f --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesManager.swift @@ -0,0 +1,32 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Foundation +import Shared +import Storage + +// TODO: FXIOS-10165 - Add full logic + tests for retrieving top sites +class TopSitesManager { + private let googleTopSiteManager: GoogleTopSiteManager + + init( + googleTopSiteManager: GoogleTopSiteManager + ) { + self.googleTopSiteManager = googleTopSiteManager + } + + func getTopSites() async -> [TopSiteState] { + guard let googleTopSite = addGoogleTopSite() else { + return [] + } + return [googleTopSite] + } + + private func addGoogleTopSite() -> TopSiteState? { + guard let googleSite = googleTopSiteManager.suggestedSiteData else { + return nil + } + return TopSiteState(site: googleSite) + } +} diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesMiddleware.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesMiddleware.swift new file mode 100644 index 000000000000..fd4163c87d23 --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesMiddleware.swift @@ -0,0 +1,42 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Redux + +final class TopSitesMiddleware { + private let topSitesManager: TopSitesManager + + init(profile: Profile = AppContainer.shared.resolve()) { + self.topSitesManager = TopSitesManager( + googleTopSiteManager: GoogleTopSiteManager( + prefs: profile.prefs + ) + ) + } + + lazy var topSitesProvider: Middleware = { state, action in + switch action.actionType { + case HomepageActionType.initialize, + TopSitesActionType.fetchTopSites: + self.getTopSitesDataAndUpdateState(for: action) + default: + break + } + } + + private func getTopSitesDataAndUpdateState(for action: Action) { + Task { + let topSites = await topSitesManager.getTopSites() + store.dispatch( + TopSitesAction( + topSites: topSites, + windowUUID: action.windowUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + } + } +} diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesSectionState.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesSectionState.swift new file mode 100644 index 000000000000..1a3ffa934e13 --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSitesSectionState.swift @@ -0,0 +1,58 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Common +import Foundation +import Redux + +/// State for the top sites section that is used in the homepage +struct TopSitesSectionState: StateType, Equatable { + var windowUUID: WindowUUID + var topSitesData: [TopSiteState] + + init(windowUUID: WindowUUID) { + self.init( + windowUUID: windowUUID, + topSitesData: [] + ) + } + + private init( + windowUUID: WindowUUID, + topSitesData: [TopSiteState] + ) { + self.windowUUID = windowUUID + self.topSitesData = topSitesData + } + + static let reducer: Reducer = { state, action in + guard action.windowUUID == .unavailable || action.windowUUID == state.windowUUID + else { + return defaultState(fromPreviousState: state) + } + + switch action.actionType { + case TopSitesMiddlewareActionType.retrievedUpdatedSites: + guard let topSitesAction = action as? TopSitesAction, + let sites = topSitesAction.topSites + else { + return defaultState(fromPreviousState: state) + } + + return TopSitesSectionState( + windowUUID: state.windowUUID, + topSitesData: sites + ) + default: + return defaultState(fromPreviousState: state) + } + } + + static func defaultState(fromPreviousState state: TopSitesSectionState) -> TopSitesSectionState { + return TopSitesSectionState( + windowUUID: state.windowUUID, + topSitesData: state.topSitesData + ) + } +} diff --git a/firefox-ios/Client/Redux/GlobalState/AppState.swift b/firefox-ios/Client/Redux/GlobalState/AppState.swift index af6b75f943e4..308e902f4fc7 100644 --- a/firefox-ios/Client/Redux/GlobalState/AppState.swift +++ b/firefox-ios/Client/Redux/GlobalState/AppState.swift @@ -65,6 +65,7 @@ let middlewares = [ ThemeManagerMiddleware().themeManagerProvider, ToolbarMiddleware().toolbarProvider, SearchEngineSelectionMiddleware().searchEngineSelectionProvider, + TopSitesMiddleware().topSitesProvider, TrackingProtectionMiddleware().trackingProtectionProvider, PasswordGeneratorMiddleware().passwordGeneratorProvider, PocketMiddleware().pocketSectionProvider diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageDiffableDataSourceTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageDiffableDataSourceTests.swift index 5d2a5b1263ec..37b4bdbb4a61 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageDiffableDataSourceTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageDiffableDataSourceTests.swift @@ -39,7 +39,6 @@ final class HomepageDiffableDataSourceTests: XCTestCase { XCTAssertEqual(snapshot.sectionIdentifiers, [.header, .topSites, .pocket, .customizeHomepage]) XCTAssertEqual(snapshot.itemIdentifiers(inSection: .header).count, 1) - XCTAssertEqual(snapshot.itemIdentifiers(inSection: .topSites).count, 0) XCTAssertEqual(snapshot.itemIdentifiers(inSection: .pocket).count, 1) XCTAssertEqual(snapshot.itemIdentifiers(inSection: .customizeHomepage).count, 1) } diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift index af2c88c2ce9c..0d9d663cf1cb 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/HomepageViewControllerTests.swift @@ -41,15 +41,25 @@ final class HomepageViewControllerTests: XCTestCase { let sut = createSubject() XCTAssertEqual(mockThemeManager?.getCurrentThemeCallCount, 0) - XCTAssertEqual(mockNotificationCenter?.addObserverCallCount, 1) - XCTAssertEqual(mockNotificationCenter?.observers, [UIApplication.didBecomeActiveNotification]) + XCTAssertEqual(mockNotificationCenter?.addObserverCallCount, 6) + XCTAssertEqual(mockNotificationCenter?.observers, [UIApplication.didBecomeActiveNotification, + .FirefoxAccountChanged, + .PrivateDataClearedHistory, + .ProfileDidFinishSyncing, + .TopSitesUpdated, + .DefaultSearchEngineUpdated]) sut.loadViewIfNeeded() - // Called in listenForThemeChange() and applyTheme(), so counted twice XCTAssertEqual(mockThemeManager?.getCurrentThemeCallCount, 1) - XCTAssertEqual(mockNotificationCenter?.addObserverCallCount, 2) - XCTAssertEqual(mockNotificationCenter?.observers, [UIApplication.didBecomeActiveNotification, .ThemeDidChange]) + XCTAssertEqual(mockNotificationCenter?.addObserverCallCount, 7) + XCTAssertEqual(mockNotificationCenter?.observers, [UIApplication.didBecomeActiveNotification, + .FirefoxAccountChanged, + .PrivateDataClearedHistory, + .ProfileDidFinishSyncing, + .TopSitesUpdated, + .DefaultSearchEngineUpdated, + .ThemeDidChange]) } // MARK: - Deinit State diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/TopSitesSectionStateTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/TopSitesSectionStateTests.swift new file mode 100644 index 000000000000..de58267c14c4 --- /dev/null +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Frontend/Homepage Rebuild/Redux/TopSitesSectionStateTests.swift @@ -0,0 +1,71 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/ + +import Redux +import Storage +import XCTest + +@testable import Client + +final class TopsSitesSectionStateTests: XCTestCase { + func tests_initialState_returnsExpectedState() { + let initialState = createSubject() + + XCTAssertEqual(initialState.windowUUID, .XCTestDefaultUUID) + XCTAssertEqual(initialState.topSitesData, []) + } + + func test_retrievedUpdatedStoriesAction_returnsExpectedState() throws { + let initialState = createSubject() + let reducer = topSiteReducer() + + let exampleTopSite = TopSiteState(site: Site(url: "https://www.example.com", title: "hello", bookmarked: false, guid: nil)) + + let newState = reducer( + initialState, + TopSitesAction( + topSites: [exampleTopSite], + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + XCTAssertEqual(newState.windowUUID, .XCTestDefaultUUID) + XCTAssertEqual(newState.topSitesData.count, 1) + XCTAssertEqual(newState.topSitesData.compactMap { $0.title }, ["hello"]) + } + + func test_retrievedUpdatedStoriesAction_returnsDefaultState() throws { + let initialState = createSubject() + let reducer = topSiteReducer() + + let newState = reducer( + initialState, + TopSitesAction( + topSites: nil, + windowUUID: .XCTestDefaultUUID, + actionType: TopSitesMiddlewareActionType.retrievedUpdatedSites + ) + ) + + XCTAssertEqual(newState.windowUUID, .XCTestDefaultUUID) + + XCTAssertEqual(newState, defaultState(with: initialState)) + XCTAssertEqual(newState.topSitesData.count, 0) + XCTAssertEqual(newState.topSitesData.compactMap { $0.title }, []) + } + + // MARK: - Private + private func createSubject() -> TopSitesSectionState { + return TopSitesSectionState(windowUUID: .XCTestDefaultUUID) + } + + private func topSiteReducer() -> Reducer { + return TopSitesSectionState.reducer + } + + private func defaultState(with state: TopSitesSectionState) -> TopSitesSectionState { + return TopSitesSectionState.defaultState(fromPreviousState: state) + } +}