diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9714641358..3285b74c3f 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1105,6 +1105,8 @@ 371BBC5F2D00CA3E008FA0C7 /* NewTabPageWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371BBC5D2D00CA3E008FA0C7 /* NewTabPageWebViewModel.swift */; }; 371C0A2927E33EDC0070591F /* FeedbackPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371C0A2827E33EDC0070591F /* FeedbackPresenter.swift */; }; 371D00E129D8509400EC8598 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = 371D00E029D8509400EC8598 /* OpenSSL */; }; + 371E1D672D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371E1D662D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift */; }; + 371E1D682D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 371E1D662D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift */; }; 37219B342CBFBBE800C9D7A8 /* NewTabPageSearchBoxExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37219B332CBFBBDB00C9D7A8 /* NewTabPageSearchBoxExperiment.swift */; }; 37219B352CBFBBE800C9D7A8 /* NewTabPageSearchBoxExperiment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37219B332CBFBBDB00C9D7A8 /* NewTabPageSearchBoxExperiment.swift */; }; 37219B372CBFBC8200C9D7A8 /* NewTabSearchBoxExperimentPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37219B362CBFBC8200C9D7A8 /* NewTabSearchBoxExperimentPixel.swift */; }; @@ -1126,6 +1128,8 @@ 372BC2A22A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */; }; 372D15EC2D00FA1A00A11576 /* AppearancePreferences+NewTabPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372D15EB2D00FA1400A11576 /* AppearancePreferences+NewTabPage.swift */; }; 372D15ED2D00FA1A00A11576 /* AppearancePreferences+NewTabPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372D15EB2D00FA1400A11576 /* AppearancePreferences+NewTabPage.swift */; }; + 3730F20F2D2E725D00239F96 /* NewTabPageCustomizationProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730F20E2D2E725A00239F96 /* NewTabPageCustomizationProviderTests.swift */; }; + 3730F2102D2E725D00239F96 /* NewTabPageCustomizationProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730F20E2D2E725A00239F96 /* NewTabPageCustomizationProviderTests.swift */; }; 3739326529AE4B39009346AE /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 3739326429AE4B39009346AE /* DDGSync */; }; 3739326729AE4B42009346AE /* DDGSync in Frameworks */ = {isa = PBXBuildFile; productRef = 3739326629AE4B42009346AE /* DDGSync */; }; 373A1AA8283ED1B900586521 /* BookmarkHTMLReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373A1AA7283ED1B900586521 /* BookmarkHTMLReader.swift */; }; @@ -1157,6 +1161,8 @@ 37534CA3281132CB002621E7 /* TabLazyLoaderDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */; }; 37534CA52811987D002621E7 /* AdjacentItemEnumeratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */; }; 37534CA8281198CD002621E7 /* AdjacentItemEnumerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */; }; + 3758A38C2D108233001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3758A38B2D108229001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift */; }; + 3758A38D2D108233001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3758A38B2D108229001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift */; }; 376113CC2B29CD5B00E794BB /* CriticalPathsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565E46DF2B2725DD0013AC2A /* CriticalPathsTests.swift */; }; 376705AF27EB488600DD8D76 /* RoundedSelectionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */; }; 376731822C7E226A00EB097B /* HomePageViewBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 376731812C7E226A00EB097B /* HomePageViewBackground.swift */; }; @@ -1315,6 +1321,8 @@ 37F44A5F298C17830025E7FE /* Navigation in Frameworks */ = {isa = PBXBuildFile; productRef = 37F44A5E298C17830025E7FE /* Navigation */; }; 37F8ABD32CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */; }; 37F8ABD42CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */; }; + 37F9AEB12D131705007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F9AEB02D1316FE007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift */; }; + 37F9AEB22D131705007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37F9AEB02D1316FE007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift */; }; 37FC2A182CF903080048E226 /* MockPrivacyStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FC2A172CF903060048E226 /* MockPrivacyStats.swift */; }; 37FC2A192CF903080048E226 /* MockPrivacyStats.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FC2A172CF903060048E226 /* MockPrivacyStats.swift */; }; 37FD78112A29EBD100B36DB1 /* SyncErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FD78102A29EBD100B36DB1 /* SyncErrorHandler.swift */; }; @@ -3666,6 +3674,7 @@ 371BBC5A2D00C911008FA0C7 /* NewTabPageActionsManagerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageActionsManagerExtension.swift; sourceTree = ""; }; 371BBC5D2D00CA3E008FA0C7 /* NewTabPageWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageWebViewModel.swift; sourceTree = ""; }; 371C0A2827E33EDC0070591F /* FeedbackPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackPresenter.swift; sourceTree = ""; }; + 371E1D662D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizationProvider.swift; sourceTree = ""; }; 3720B7F82D00DA4500D20F23 /* WebKitExtensions */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = WebKitExtensions; sourceTree = ""; }; 37219B332CBFBBDB00C9D7A8 /* NewTabPageSearchBoxExperiment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSearchBoxExperiment.swift; sourceTree = ""; }; 37219B362CBFBC8200C9D7A8 /* NewTabSearchBoxExperimentPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabSearchBoxExperimentPixel.swift; sourceTree = ""; }; @@ -3674,6 +3683,7 @@ 372A0FEB2B2379310033BF7F /* SyncMetricsEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetricsEventsHandler.swift; sourceTree = ""; }; 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCredentialsAdapter.swift; sourceTree = ""; }; 372D15EB2D00FA1400A11576 /* AppearancePreferences+NewTabPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppearancePreferences+NewTabPage.swift"; sourceTree = ""; }; + 3730F20E2D2E725A00239F96 /* NewTabPageCustomizationProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizationProviderTests.swift; sourceTree = ""; }; 373A1AA7283ED1B900586521 /* BookmarkHTMLReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkHTMLReader.swift; sourceTree = ""; }; 373A1AA9283ED86C00586521 /* BookmarksHTMLReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksHTMLReaderTests.swift; sourceTree = ""; }; 373A1AAF2842C4EA00586521 /* BookmarkHTMLImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkHTMLImporter.swift; sourceTree = ""; }; @@ -3693,6 +3703,7 @@ 37534CA2281132CB002621E7 /* TabLazyLoaderDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabLazyLoaderDataSource.swift; sourceTree = ""; }; 37534CA42811987D002621E7 /* AdjacentItemEnumeratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumeratorTests.swift; sourceTree = ""; }; 37534CA62811988E002621E7 /* AdjacentItemEnumerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdjacentItemEnumerator.swift; sourceTree = ""; }; + 3758A38B2D108229001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageFreemiumDBPBannerProvider.swift; sourceTree = ""; }; 376113C52B29BCD600E794BB /* SyncE2EUITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SyncE2EUITests.xcconfig; sourceTree = ""; }; 376113D42B29CD5B00E794BB /* SyncE2EUITests App Store.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "SyncE2EUITests App Store.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 376113D72B29D0F800E794BB /* SyncE2EUITestsAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = SyncE2EUITestsAppStore.xcconfig; sourceTree = ""; }; @@ -3809,6 +3820,7 @@ 37F19A6628E1B43200740DC6 /* DuckPlayerPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerPreferences.swift; sourceTree = ""; }; 37F19A6928E2F2D000740DC6 /* DuckPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayer.swift; sourceTree = ""; }; 37F8ABD22CE3EE5B00CB0294 /* FeatureFlagOverridesMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlagOverridesMenu.swift; sourceTree = ""; }; + 37F9AEB02D1316FE007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift"; sourceTree = ""; }; 37FC2A172CF903060048E226 /* MockPrivacyStats.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrivacyStats.swift; sourceTree = ""; }; 37FD78102A29EBD100B36DB1 /* SyncErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandler.swift; sourceTree = ""; }; 467D16662D0C98D5007C020A /* CrashReportSenderExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReportSenderExtensions.swift; sourceTree = ""; }; @@ -5868,6 +5880,9 @@ 376788092CECCAD900F59D83 /* NewTabPage */ = { isa = PBXGroup; children = ( + 37F9AEB02D1316FE007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift */, + 3758A38B2D108229001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift */, + 371E1D662D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift */, 37BE08792D09C0EA00C77B8E /* NewTabPageNextStepsCardsProvider.swift */, 37E307B12D075B5E00599500 /* NewTabPagePrivacyStatsEventHandler.swift */, 374EFDF22D01C99700B30939 /* ActiveRemoteMessageModel+NewTabPage.swift */, @@ -5913,6 +5928,7 @@ 377D7BC32D0AC7F100ADFD06 /* NewTabPage */ = { isa = PBXGroup; children = ( + 3730F20E2D2E725A00239F96 /* NewTabPageCustomizationProviderTests.swift */, 377D7BC02D0AC7E000ADFD06 /* NewTabPageNextStepsCardsProviderTests.swift */, ); path = NewTabPage; @@ -11339,6 +11355,7 @@ 3706FA91293F65D500E42796 /* PasswordManagementLoginItemView.swift in Sources */, 3706FA92293F65D500E42796 /* UserText.swift in Sources */, 3706FA93293F65D500E42796 /* WKWebView+Download.swift in Sources */, + 371E1D672D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift in Sources */, 3706FA94293F65D500E42796 /* TabShadowConfig.swift in Sources */, 3706FA97293F65D500E42796 /* WindowDraggingView.swift in Sources */, BB7B5F992C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift in Sources */, @@ -12201,6 +12218,7 @@ F1FD5B682C2B0AAA0040FA0D /* SubscriptionPagesUseSubscriptionFeature.swift in Sources */, 3706FC82293F65D500E42796 /* PermissionAuthorizationPopover.swift in Sources */, 4BCBE4552BA7E16600FC75A1 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, + 37F9AEB22D131705007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift in Sources */, 371209312C233D69003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, 3706FC83293F65D500E42796 /* PopoverMessageViewController.swift in Sources */, 7B5A23702C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, @@ -12254,6 +12272,7 @@ 372A0FED2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */, 3706FCA4293F65D500E42796 /* RecentlyClosedMenu.swift in Sources */, 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, + 3758A38C2D108233001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift in Sources */, 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, C181945D2C7CDCC700381092 /* PromotionView.swift in Sources */, ); @@ -12539,6 +12558,7 @@ 857E5AFB2A79628A00FC0FB4 /* PixelExperimentTests.swift in Sources */, 1D638D622C44F2BA00530DD5 /* ApplicationUpdateDetectorTests.swift in Sources */, 56A054142C1C3796007D8FAB /* CapturingDockCustomizer.swift in Sources */, + 3730F20F2D2E725D00239F96 /* NewTabPageCustomizationProviderTests.swift in Sources */, 3706FE64293F661700E42796 /* DownloadListStoreTests.swift in Sources */, 3706FE65293F661700E42796 /* ContentBlockingUpdatingTests.swift in Sources */, 3706FE67293F661700E42796 /* EncryptionMocks.swift in Sources */, @@ -13681,6 +13701,7 @@ CD33012C2C89B588009AA127 /* ErrorPageHTMLFactory.swift in Sources */, AA7EB6E527E7D6DC00036718 /* AnimationView.swift in Sources */, 8562599A269CA0A600EE44BC /* NSRectExtension.swift in Sources */, + 371E1D682D0B2E9F00F9205B /* NewTabPageCustomizationProvider.swift in Sources */, 4B37EE5F2B4CFC3C00A89A61 /* HomePageRemoteMessagingStorage.swift in Sources */, 31F28C5128C8EEC500119F70 /* YoutubeOverlayUserScript.swift in Sources */, B6ABD0CA2BC03F610000EB69 /* SecurityScopedFileURLController.swift in Sources */, @@ -13749,6 +13770,7 @@ 9F9C49F92BC7BC970099738D /* BookmarkAllTabsDialogView.swift in Sources */, B64C85422694590B0048FEBE /* PermissionButton.swift in Sources */, AAA0CC472533833C0079BC96 /* MoreOptionsMenu.swift in Sources */, + 37F9AEB12D131705007FD19B /* DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift in Sources */, B64C84E32692DC9F0048FEBE /* PermissionAuthorizationViewController.swift in Sources */, 4B92929D26670D2A00AD2C21 /* BookmarkNode.swift in Sources */, 1DEDB3642C19934C006B6D1B /* MoreOptionsMenuButton.swift in Sources */, @@ -13843,6 +13865,7 @@ B6ABD0CE2BC042CE0000EB69 /* NSURL+sandboxExtensionRetainCount.m in Sources */, 4B4D60DF2A0C875F00BCD287 /* NetworkProtectionOptionKeyExtension.swift in Sources */, 85AC3AEF25D5CE9800C7D2AA /* UserScripts.swift in Sources */, + 3758A38D2D108233001CEAA1 /* NewTabPageFreemiumDBPBannerProvider.swift in Sources */, B643BF1427ABF772000BACEC /* NSWorkspaceExtension.swift in Sources */, B6C00ECB292F839D009C73A6 /* AutofillTabExtension.swift in Sources */, B6E319382953446000DD3BCF /* Assertions.swift in Sources */, @@ -13976,6 +13999,7 @@ 31A2FD172BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift in Sources */, 85F1B0C925EF9759004792B6 /* URLEventHandlerTests.swift in Sources */, 4B9292BD2667103100AD2C21 /* BookmarkOutlineViewDataSourceTests.swift in Sources */, + 3730F2102D2E725D00239F96 /* NewTabPageCustomizationProviderTests.swift in Sources */, C17CA7B22B9B5317008EC3C1 /* MockAutofillPopoverPresenter.swift in Sources */, 560C6ED62CCA5CE100D411E2 /* FindInView.swift in Sources */, 4BF6961D28BE911100D402D4 /* RecentlyVisitedSiteModelTests.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/Contents.json new file mode 100644 index 0000000000..912fd7ad56 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "GradientTone02+01.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/GradientTone02+01.jpg b/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/GradientTone02+01.jpg new file mode 100644 index 0000000000..2c2da7d36a Binary files /dev/null and b/DuckDuckGo/Assets.xcassets/Images/HomePage/Gradients/HomePageBackgroundGradient02+01.imageset/GradientTone02+01.jpg differ diff --git a/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift b/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift index c9119ed6b1..807271c3dc 100644 --- a/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift +++ b/DuckDuckGo/Freemium/DBP/Extensions/PromotionView+FreemiumDBP.swift @@ -19,7 +19,7 @@ import Foundation extension PromotionViewModel { - static func freemiumDBPPromotion(proceedAction: @escaping () -> Void, + static func freemiumDBPPromotion(proceedAction: @escaping () async -> Void, closeAction: @escaping () -> Void) -> PromotionViewModel { let title = UserText.homePagePromotionFreemiumDBPTitle @@ -41,7 +41,7 @@ extension PromotionViewModel { static func freemiumDBPPromotionScanEngagementResults(resultCount: Int, brokerCount: Int, - proceedAction: @escaping () -> Void, + proceedAction: @escaping () async -> Void, closeAction: @escaping () -> Void) -> PromotionViewModel { var description = "" @@ -65,7 +65,7 @@ extension PromotionViewModel { closeAction: closeAction) } - static func freemiumDBPPromotionScanEngagementNoResults(proceedAction: @escaping () -> Void, + static func freemiumDBPPromotionScanEngagementNoResults(proceedAction: @escaping () async -> Void, closeAction: @escaping () -> Void) -> PromotionViewModel { let description = UserText.homePagePromotionFreemiumDBPPostScanEngagementNoResultsDescription diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift index 06654880bf..3b92893c47 100644 --- a/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPPresenter.swift @@ -21,6 +21,7 @@ import Freemium /// Conforming types provide functionality to show Freemium DBP protocol FreemiumDBPPresenter { + @MainActor func showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManagerProtocol?) } diff --git a/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift b/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift index 56631b6b0c..365a66f075 100644 --- a/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift +++ b/DuckDuckGo/Freemium/DBP/FreemiumDBPPromotionViewCoordinator.swift @@ -25,17 +25,14 @@ import Common /// Default implementation of `FreemiumDBPPromotionViewCoordinating`, responsible for managing /// the visibility of the promotion and responding to user interactions with the promotion view. -@MainActor final class FreemiumDBPPromotionViewCoordinator: ObservableObject { /// Published property that determines whether the promotion is visible on the home page. @Published var isHomePagePromotionVisible: Bool = false /// The view model representing the promotion, which updates based on the user's state. Returns `nil` if the feature is not enabled - var viewModel: PromotionViewModel? { - guard freemiumDBPFeature.isAvailable else { return nil } - return createViewModel() - } + @Published + private(set) var viewModel: PromotionViewModel? /// Stores whether the user has dismissed the home page promotion. private var didDismissHomePagePromotion: Bool { @@ -89,14 +86,15 @@ final class FreemiumDBPPromotionViewCoordinator: ObservableObject { setInitialPromotionVisibilityState() subscribeToFeatureAvailabilityUpdates() observeFreemiumDBPNotifications() + setUpViewModelRefreshing() } } private extension FreemiumDBPPromotionViewCoordinator { /// Action to be executed when the user proceeds with the promotion (e.g opens DBP) - var proceedAction: () -> Void { - { [weak self] in + var proceedAction: () async -> Void { + { @MainActor [weak self] in guard let self else { return } execute(resultsAction: { @@ -130,6 +128,7 @@ private extension FreemiumDBPPromotionViewCoordinator { } /// Shows the Freemium DBP user interface via the presenter. + @MainActor func showFreemiumDBP() { freemiumDBPPresenter.showFreemiumDBPAndSetActivated(windowControllerManager: WindowControllersManager.shared) } @@ -148,7 +147,10 @@ private extension FreemiumDBPPromotionViewCoordinator { /// Creates the view model for the promotion, updating based on the user's scan results. /// /// - Returns: The `PromotionViewModel` that represents the current state of the promotion. - func createViewModel() -> PromotionViewModel { + func createViewModel() -> PromotionViewModel? { + guard freemiumDBPFeature.isAvailable, isHomePagePromotionVisible else { + return nil + } if let results = freemiumDBPUserStateManager.firstScanResults { if results.matchesCount > 0 { @@ -172,12 +174,24 @@ private extension FreemiumDBPPromotionViewCoordinator { } } + /// This method defines the entry point to updating `viewModel` which is every change to `isHomePagePromotionVisible`. + func setUpViewModelRefreshing() { + $isHomePagePromotionVisible.dropFirst().asVoid() + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.viewModel = self?.createViewModel() + } + .store(in: &cancellables) + } + /// Subscribes to feature availability updates from the `freemiumDBPFeature`'s availability publisher. /// /// This method listens to the `isAvailablePublisher` of the `freemiumDBPFeature`, which publishes /// changes to the feature's availability. It performs the following actions when an update is received: func subscribeToFeatureAvailabilityUpdates() { freemiumDBPFeature.isAvailablePublisher + .prepend(freemiumDBPFeature.isAvailable) + .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] isAvailable in guard let self else { return } diff --git a/DuckDuckGo/HomePage/Model/HomePageSettings/CustomBackgrounds/GradientBackground.swift b/DuckDuckGo/HomePage/Model/HomePageSettings/CustomBackgrounds/GradientBackground.swift index 31ce3e1275..403ea10734 100644 --- a/DuckDuckGo/HomePage/Model/HomePageSettings/CustomBackgrounds/GradientBackground.swift +++ b/DuckDuckGo/HomePage/Model/HomePageSettings/CustomBackgrounds/GradientBackground.swift @@ -26,6 +26,7 @@ enum GradientBackground: String, Equatable, Identifiable, CaseIterable, ColorSch case gradient01 case gradient02 + case gradient0201 = "gradient02.01" case gradient03 case gradient04 case gradient05 @@ -46,6 +47,8 @@ enum GradientBackground: String, Equatable, Identifiable, CaseIterable, ColorSch Gradient01() case .gradient02: Gradient02() + case .gradient0201: + Gradient0201() case .gradient03: Gradient03() case .gradient04: @@ -68,6 +71,8 @@ enum GradientBackground: String, Equatable, Identifiable, CaseIterable, ColorSch Image(nsImage: .homePageBackgroundGradient01) case .gradient02: Image(nsImage: .homePageBackgroundGradient02) + case .gradient0201: + Image(nsImage: .homePageBackgroundGradient0201) case .gradient03: Image(nsImage: .homePageBackgroundGradient03) case .gradient04: @@ -83,7 +88,7 @@ enum GradientBackground: String, Equatable, Identifiable, CaseIterable, ColorSch var colorScheme: ColorScheme { switch self { - case .gradient01, .gradient02, .gradient03: + case .gradient01, .gradient02, .gradient0201, .gradient03: .light case .gradient04, .gradient05, .gradient06, .gradient07: .dark @@ -191,6 +196,35 @@ private struct Gradient02: View { } } +@available(macOS 12.0, *) +private struct Gradient0201: View { + var body: some View { + ZStack { + EllipticalGradient( + colors: [Color(red: 1, green: 0.8, blue: 0.2).opacity(0.8), .clear], + center: UnitPoint(x: 1.04, y: 1.08), + endRadiusFraction: 1 + ) + + EllipticalGradient( + colors: [ + Color(red: 1, green: 0.84, blue: 0.36).opacity(0.7), + Color(red: 1, green: 0.84, blue: 0.8).opacity(0.2) + ], + center: UnitPoint(x: 0.56, y: 0.5), + endRadiusFraction: 1 + ) + + EllipticalGradient( + colors: [Color(red: 0.95, green: 0.63, blue: 0.54).opacity(0.6), .clear], + center: UnitPoint(x: -0.26, y: 0.5), + endRadiusFraction: 1 + ) + } + .background(Color(red: 1, green: 0.87, blue: 0.48)) + } +} + @available(macOS 12.0, *) private struct Gradient03: View { var body: some View { @@ -320,6 +354,7 @@ private struct Gradient06: View { .background(Color(red: 0.07, green: 0.01, blue: 0.21)) } } + @available(macOS 12.0, *) private struct Gradient07: View { var body: some View { @@ -358,6 +393,8 @@ private struct Gradient07: View { .frame(width: 640, height: 400) GradientBackground.gradient02.view .frame(width: 640, height: 400) + GradientBackground.gradient0201.view + .frame(width: 640, height: 400) GradientBackground.gradient03.view .frame(width: 640, height: 400) GradientBackground.gradient04.view diff --git a/DuckDuckGo/HomePage/Model/HomePageSettings/HomePageSettingsModel.swift b/DuckDuckGo/HomePage/Model/HomePageSettings/HomePageSettingsModel.swift index cdec35634c..568d0fea8d 100644 --- a/DuckDuckGo/HomePage/Model/HomePageSettings/HomePageSettingsModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageSettings/HomePageSettingsModel.swift @@ -18,6 +18,7 @@ import Combine import Foundation +import NewTabPage import os.log import PixelKit import SwiftUI @@ -59,7 +60,7 @@ extension HomePage.Models { final class SettingsModel: ObservableObject { enum Const { - static let maximumNumberOfUserImages = 4 + static let maximumNumberOfUserImages = 8 static let defaultColorPickerColor = NSColor.white } @@ -88,6 +89,7 @@ extension HomePage.Models { let userColorProvider: () -> UserColorProviding let showAddImageFailedAlert: () -> Void let navigator: HomePageSettingsModelNavigator + let customizerOpener = NewTabPageCustomizerOpener() @Published var settingsButtonWidth: CGFloat = .infinity @Published private(set) var availableUserBackgroundImages: [UserBackgroundImage] = [] @@ -244,8 +246,13 @@ extension HomePage.Models { @Published var customBackground: CustomBackground? { didSet { appearancePreferences.homePageCustomBackground = customBackground - if case .userImage(let userBackgroundImage) = customBackground { + switch customBackground { + case .solidColor(let solidColorBackground) where solidColorBackground.predefinedColorName == nil: + lastPickedCustomColor = solidColorBackground.color + case .userImage(let userBackgroundImage): customImagesManager?.updateSelectedTimestamp(for: userBackgroundImage) + default: + break } if let customBackground { Logger.homePageSettings.debug("Home page background updated: \(customBackground), color scheme: \(customBackground.colorScheme)") @@ -298,9 +305,6 @@ extension HomePage.Models { provider.showColorPanel(with: lastPickedCustomColorHexValue.flatMap(NSColor.init(hex:)) ?? Const.defaultColorPickerColor) userColorCancellable = provider.colorPublisher - .handleEvents(receiveOutput: { [weak self] color in - self?.lastPickedCustomColor = color - }) .map { CustomBackground.solidColor(.init(color: $0)) } .assign(to: \.customBackground, onWeaklyHeld: self) } diff --git a/DuckDuckGo/HomePage/Model/PromotionViewModel.swift b/DuckDuckGo/HomePage/Model/PromotionViewModel.swift index b0f57261cb..0a43082af0 100644 --- a/DuckDuckGo/HomePage/Model/PromotionViewModel.swift +++ b/DuckDuckGo/HomePage/Model/PromotionViewModel.swift @@ -27,10 +27,10 @@ extension HomePage.Models { let title: String? let description: String let proceedButtonText: String - let proceedAction: () -> Void + let proceedAction: () async -> Void let closeAction: () -> Void - init(image: ImageResource, title: String? = nil, description: String, proceedButtonText: String, proceedAction: @escaping () -> Void, closeAction: @escaping () -> Void) { + init(image: ImageResource, title: String? = nil, description: String, proceedButtonText: String, proceedAction: @escaping () async -> Void, closeAction: @escaping () -> Void) { self.image = image self.title = title self.description = description diff --git a/DuckDuckGo/HomePage/View/PromotionView.swift b/DuckDuckGo/HomePage/View/PromotionView.swift index 13b8b646b6..1b13010342 100644 --- a/DuckDuckGo/HomePage/View/PromotionView.swift +++ b/DuckDuckGo/HomePage/View/PromotionView.swift @@ -108,7 +108,11 @@ extension HomePage.Views { private var button: some View { Group { - Button(action: viewModel.proceedAction) { + Button { + Task { @MainActor in + await viewModel.proceedAction() + } + } label: { Text(verbatim: viewModel.proceedButtonText) } .controlSize(.large) diff --git a/DuckDuckGo/NewTabPage/DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift b/DuckDuckGo/NewTabPage/DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift new file mode 100644 index 0000000000..7bf52aab38 --- /dev/null +++ b/DuckDuckGo/NewTabPage/DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift @@ -0,0 +1,29 @@ +// +// DefaultHomePageSettingsModelNavigator+NewTabPageLinkOpening.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import NewTabPage + +extension DefaultHomePageSettingsModelNavigator: NewTabPageLinkOpening { + + func openLink(_ target: NewTabPageDataModel.OpenAction.Target) async { + switch target { + case .settings: + openAppearanceSettings() + } + } +} diff --git a/DuckDuckGo/NewTabPage/NewTabPageActionsManagerExtension.swift b/DuckDuckGo/NewTabPage/NewTabPageActionsManagerExtension.swift index c061569cd7..71f0290ce4 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageActionsManagerExtension.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageActionsManagerExtension.swift @@ -41,9 +41,18 @@ extension NewTabPageActionsManager { getLegacyIsViewExpandedSetting: UserDefaultsWrapper(key: .homePageShowAllFavorites, defaultValue: false).wrappedValue ) + let customizationProvider = NewTabPageCustomizationProvider(homePageSettingsModel: NSApp.delegateTyped.homePageSettingsModel) + let freemiumDBPBannerProvider = NewTabPageFreemiumDBPBannerProvider(model: NSApp.delegateTyped.freemiumDBPPromotionViewCoordinator) + self.init(scriptClients: [ - NewTabPageConfigurationClient(sectionsVisibilityProvider: appearancePreferences), + NewTabPageConfigurationClient( + sectionsVisibilityProvider: appearancePreferences, + customBackgroundProvider: customizationProvider, + linkOpener: DefaultHomePageSettingsModelNavigator() + ), + NewTabPageCustomBackgroundClient(model: customizationProvider), NewTabPageRMFClient(remoteMessageProvider: activeRemoteMessageModel), + NewTabPageFreemiumDBPClient(provider: freemiumDBPBannerProvider), NewTabPageNextStepsCardsClient(model: NewTabPageNextStepsCardsProvider(continueSetUpModel: HomePage.Models.ContinueSetUpModel(tabOpener: NewTabPageTabOpener()))), NewTabPageFavoritesClient(favoritesModel: favoritesModel, preferredFaviconSize: Int(Favicon.SizeCategory.medium.rawValue)), NewTabPagePrivacyStatsClient(model: privacyStatsModel) diff --git a/DuckDuckGo/NewTabPage/NewTabPageCustomizationProvider.swift b/DuckDuckGo/NewTabPage/NewTabPageCustomizationProvider.swift new file mode 100644 index 0000000000..336d51c8ab --- /dev/null +++ b/DuckDuckGo/NewTabPage/NewTabPageCustomizationProvider.swift @@ -0,0 +1,191 @@ +// +// NewTabPageCustomizationProvider.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import NewTabPage +import SwiftUI + +final class NewTabPageCustomizationProvider: NewTabPageCustomBackgroundProviding { + let homePageSettingsModel: HomePage.Models.SettingsModel + let appearancePreferences: AppearancePreferences + + init(homePageSettingsModel: HomePage.Models.SettingsModel, appearancePreferences: AppearancePreferences = .shared) { + self.homePageSettingsModel = homePageSettingsModel + self.appearancePreferences = appearancePreferences + } + + var customizerOpener: NewTabPageCustomizerOpener { + homePageSettingsModel.customizerOpener + } + + var customizerData: NewTabPageDataModel.CustomizerData { + .init( + background: .init(homePageSettingsModel.customBackground), + theme: .init(appearancePreferences.currentThemeName), + userColor: homePageSettingsModel.lastPickedCustomColor, + userImages: homePageSettingsModel.availableUserBackgroundImages.map(NewTabPageDataModel.UserImage.init) + ) + } + + var background: NewTabPageDataModel.Background { + get { + .init(homePageSettingsModel.customBackground) + } + set { + homePageSettingsModel.customBackground = .init(newValue) + } + } + + var backgroundPublisher: AnyPublisher { + homePageSettingsModel.$customBackground.dropFirst().removeDuplicates() + .map(NewTabPageDataModel.Background.init) + .eraseToAnyPublisher() + } + + var theme: NewTabPageDataModel.Theme? { + get { + .init(appearancePreferences.currentThemeName) + } + set { + appearancePreferences.currentThemeName = .init(newValue) + } + } + + var themePublisher: AnyPublisher { + appearancePreferences.$currentThemeName.dropFirst().removeDuplicates() + .map(NewTabPageDataModel.Theme.init) + .eraseToAnyPublisher() + } + + var userImagesPublisher: AnyPublisher<[NewTabPageDataModel.UserImage], Never> { + homePageSettingsModel.$availableUserBackgroundImages.dropFirst().removeDuplicates() + .map { $0.map(NewTabPageDataModel.UserImage.init) } + .eraseToAnyPublisher() + } + + @MainActor + func presentUploadDialog() async { + await homePageSettingsModel.addNewImage() + } + + func deleteImage(with imageID: String) async { + guard let image = homePageSettingsModel.availableUserBackgroundImages.first(where: { $0.id == imageID }) else { + return + } + homePageSettingsModel.customImagesManager?.deleteImage(image) + } +} + +extension NewTabPageDataModel.Background { + init(_ customBackground: CustomBackground?) { + switch customBackground { + case .gradient(let gradient): + self = .gradient(gradient.rawValue) + case .solidColor(let solidColor): + if let predefinedColorName = solidColor.predefinedColorName { + self = .solidColor(predefinedColorName) + } else { + self = .hexColor(solidColor.description) + } + case .userImage(let userBackgroundImage): + self = .userImage(.init(userBackgroundImage)) + case .none: + self = .default + } + } +} + +extension CustomBackground { + init?(_ background: NewTabPageDataModel.Background) { + switch background { + case .default: + return nil + case .solidColor(let color), .hexColor(let color): + guard let solidColor = SolidColorBackground(color) else { + return nil + } + self = .solidColor(solidColor) + case .gradient(let gradient): + guard let gradient = GradientBackground(rawValue: gradient) else { + return nil + } + self = .gradient(gradient) + case .userImage(let userImage): + self = .userImage(.init(fileName: userImage.id, colorScheme: .init(userImage.colorScheme))) + } + } +} + +extension NewTabPageDataModel.UserImage { + init(_ userBackgroundImage: UserBackgroundImage) { + self.init( + colorScheme: .init(userBackgroundImage.colorScheme), + id: userBackgroundImage.id, + src: "/background/images/\(userBackgroundImage.fileName)", + thumb: "/background/thumbnails/\(userBackgroundImage.fileName)" + ) + } +} + +extension ColorScheme { + init(_ theme: NewTabPageDataModel.Theme) { + switch theme { + case .dark: + self = .dark + case .light: + self = .light + } + } +} + +extension ThemeName { + init(_ theme: NewTabPageDataModel.Theme?) { + switch theme { + case .dark: + self = .dark + case .light: + self = .light + default: + self = .systemDefault + } + } +} + +extension NewTabPageDataModel.Theme { + init(_ colorScheme: ColorScheme) { + switch colorScheme { + case .dark: + self = .dark + case .light: + self = .light + @unknown default: + self = .light + } + } + + init?(_ themeName: ThemeName) { + switch themeName { + case .light: + self = .light + case .dark: + self = .dark + case .systemDefault: + return nil + } + } +} diff --git a/DuckDuckGo/NewTabPage/NewTabPageFreemiumDBPBannerProvider.swift b/DuckDuckGo/NewTabPage/NewTabPageFreemiumDBPBannerProvider.swift new file mode 100644 index 0000000000..1a93f53671 --- /dev/null +++ b/DuckDuckGo/NewTabPage/NewTabPageFreemiumDBPBannerProvider.swift @@ -0,0 +1,66 @@ +// +// NewTabPageFreemiumDBPBannerProvider.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import NewTabPage + +final class NewTabPageFreemiumDBPBannerProvider: NewTabPageFreemiumDBPBannerProviding { + + var bannerMessage: NewTabPageDataModel.FreemiumPIRBannerMessage? { + guard let viewModel = model.viewModel else { + return nil + } + return .init(viewModel) + } + + var bannerMessagePublisher: AnyPublisher { + model.$viewModel.dropFirst() + .map { viewModel in + guard let viewModel else { + return nil + } + return NewTabPageDataModel.FreemiumPIRBannerMessage(viewModel) + } + .eraseToAnyPublisher() + } + + func dismiss() async { + model.viewModel?.closeAction() + } + + func action() async { + await model.viewModel?.proceedAction() + } + + let model: FreemiumDBPPromotionViewCoordinator + + init(model: FreemiumDBPPromotionViewCoordinator) { + self.model = model + } +} + +extension NewTabPageDataModel.FreemiumPIRBannerMessage { + init(_ promotionViewModel: PromotionViewModel) { + + self.init( + titleText: promotionViewModel.title, + descriptionText: promotionViewModel.description, + actionText: promotionViewModel.proceedButtonText + ) + } +} diff --git a/DuckDuckGo/NewTabPage/NewTabPageNextStepsCardsProvider.swift b/DuckDuckGo/NewTabPage/NewTabPageNextStepsCardsProvider.swift index b56558e98e..2187dbbe16 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageNextStepsCardsProvider.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageNextStepsCardsProvider.swift @@ -44,44 +44,44 @@ final class NewTabPageNextStepsCardsProvider: NewTabPageNextStepsCardsProviding continueSetUpModel.shouldShowAllFeaturesPublisher.eraseToAnyPublisher() } - var cards: [NewTabPageNextStepsCardsClient.CardID] { + var cards: [NewTabPageDataModel.CardID] { guard !appearancePreferences.isContinueSetUpCardsViewOutdated else { return [] } - return continueSetUpModel.featuresMatrix.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + return continueSetUpModel.featuresMatrix.flatMap { $0.map(NewTabPageDataModel.CardID.init) } } - var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + var cardsPublisher: AnyPublisher<[NewTabPageDataModel.CardID], Never> { let features = continueSetUpModel.$featuresMatrix.dropFirst().removeDuplicates() let cardsDidBecomeOutdated = appearancePreferences.$isContinueSetUpCardsViewOutdated.removeDuplicates() return Publishers.CombineLatest(features, cardsDidBecomeOutdated) - .map { features, isOutdated -> [NewTabPageNextStepsCardsClient.CardID] in + .map { features, isOutdated -> [NewTabPageDataModel.CardID] in guard !isOutdated else { return [] } - return features.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + return features.flatMap { $0.map(NewTabPageDataModel.CardID.init) } } .eraseToAnyPublisher() } @MainActor - func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + func handleAction(for card: NewTabPageDataModel.CardID) { continueSetUpModel.performAction(for: .init(card)) } @MainActor - func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + func dismiss(_ card: NewTabPageDataModel.CardID) { continueSetUpModel.removeItem(for: .init(card)) } @MainActor - func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + func willDisplayCards(_ cards: [NewTabPageDataModel.CardID]) { appearancePreferences.continueSetUpCardsViewDidAppear() fireAddToDockPixelIfNeeded(cards) } - private func fireAddToDockPixelIfNeeded(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + private func fireAddToDockPixelIfNeeded(_ cards: [NewTabPageDataModel.CardID]) { guard cards.contains(.addAppToDockMac) else { return } @@ -92,7 +92,7 @@ final class NewTabPageNextStepsCardsProvider: NewTabPageNextStepsCardsProviding } extension HomePage.Models.FeatureType { - init(_ card: NewTabPageNextStepsCardsClient.CardID) { + init(_ card: NewTabPageDataModel.CardID) { switch card { case .bringStuff: self = .importBookmarksAndPasswords @@ -108,7 +108,7 @@ extension HomePage.Models.FeatureType { } } -extension NewTabPageNextStepsCardsClient.CardID { +extension NewTabPageDataModel.CardID { init(_ feature: HomePage.Models.FeatureType) { switch feature { case .duckplayer: diff --git a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift index 8f87ad4e1c..ebfaef6897 100644 --- a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift +++ b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift @@ -117,6 +117,11 @@ final class DefaultHomePageNavigator: HomePageNavigator { if let window = WindowControllersManager.shared.lastKeyMainWindowController { let homePageViewController = window.mainViewController.browserTabViewController.homePageViewController homePageViewController?.settingsVisibilityModel.isSettingsVisible = true + + if NSApp.delegateTyped.featureFlagger.isFeatureOn(.htmlNewTabPage) { + let newTabPageViewModel = window.mainViewController.browserTabViewController.newTabPageWebViewModel + NSApp.delegateTyped.homePageSettingsModel.customizerOpener.openSettings(for: newTabPageViewModel.webView) + } } } } diff --git a/DuckDuckGo/Tab/Navigation/DuckURLSchemeHandler.swift b/DuckDuckGo/Tab/Navigation/DuckURLSchemeHandler.swift index 8222f8a7e9..bd3a2962dc 100644 --- a/DuckDuckGo/Tab/Navigation/DuckURLSchemeHandler.swift +++ b/DuckDuckGo/Tab/Navigation/DuckURLSchemeHandler.swift @@ -28,15 +28,18 @@ final class DuckURLSchemeHandler: NSObject, WKURLSchemeHandler { let featureFlagger: FeatureFlagger let faviconManager: FaviconManagement let isNTPSpecialPageSupported: Bool + let userBackgroundImagesManager: UserBackgroundImagesManaging? init( featureFlagger: FeatureFlagger, faviconManager: FaviconManagement = FaviconManager.shared, - isNTPSpecialPageSupported: Bool = false + isNTPSpecialPageSupported: Bool = false, + userBackgroundImagesManager: UserBackgroundImagesManaging? = NSApp.delegateTyped.homePageSettingsModel.customImagesManager ) { self.featureFlagger = featureFlagger self.faviconManager = faviconManager self.isNTPSpecialPageSupported = isNTPSpecialPageSupported + self.userBackgroundImagesManager = userBackgroundImagesManager } func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { @@ -54,9 +57,14 @@ final class DuckURLSchemeHandler: NSObject, WKURLSchemeHandler { case .error: handleErrorPage(urlSchemeTask: urlSchemeTask) case .newTab where isNTPSpecialPageSupported && featureFlagger.isFeatureOn(.htmlNewTabPage): - if requestURL.type == .favicon { + switch requestURL.type { + case .favicon: handleFavicon(urlSchemeTask: urlSchemeTask) - } else { + case .customBackgroundImage: + handleCustomBackgroundImage(urlSchemeTask: urlSchemeTask) + case .customBackgroundImageThumbnail: + handleCustomBackgroundImage(urlSchemeTask: urlSchemeTask, isThumbnail: true) + default: handleSpecialPages(urlSchemeTask: urlSchemeTask) } default: @@ -176,6 +184,49 @@ private extension DuckURLSchemeHandler { } } +// MARK: - Custom Background Images + +private extension DuckURLSchemeHandler { + /** + * This handler supports Duck custom background image URL and uses `UserBackgroundImagesManager` + * to return an image in response, based on the image ID (file name) that's the last component of the URL path. + + * Custom Background image has the format of `duck://new-tab/background/images/`. + * Custom Background image thumbnail has the format of `duck://new-tab/background/thumbnails/`. + * + * If an image is not found, an `HTTP 404` response is returned. + */ + func handleCustomBackgroundImage(urlSchemeTask: WKURLSchemeTask, isThumbnail: Bool = false) { + guard let requestURL = urlSchemeTask.request.url else { + assertionFailure("No URL for Favicon scheme handler") + return + } + + let fileName = requestURL.lastPathComponent + + guard let (response, data) = response(for: requestURL, withFileName: fileName, isThumbnail: isThumbnail) else { return } + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + + func response(for requestURL: URL, withFileName fileName: String, isThumbnail: Bool) -> (URLResponse, Data)? { + guard let userBackgroundImagesManager, + let userBackgroundImage = userBackgroundImagesManager.availableImages.first(where: { $0.fileName == fileName }), + let image = isThumbnail ? userBackgroundImagesManager.thumbnailImage(for: userBackgroundImage) : userBackgroundImagesManager.image(for: userBackgroundImage), + let imageJPEGData = image.jpegData + else { + guard let response = HTTPURLResponse(url: requestURL, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil) else { + return nil + } + return (response, Data()) + } + + let response = URLResponse(url: requestURL, mimeType: "image/jpeg", expectedContentLength: imageJPEGData.count, textEncodingName: nil) + return (response, imageJPEGData) + } +} + // MARK: - Onboarding & Release Notes private extension DuckURLSchemeHandler { func handleSpecialPages(urlSchemeTask: WKURLSchemeTask) { @@ -276,6 +327,8 @@ private extension URL { enum URLType { case newTab case favicon + case customBackgroundImage + case customBackgroundImageThumbnail case onboarding case duckPlayer case releaseNotes @@ -292,6 +345,12 @@ private extension URL { } else if self.isReleaseNotes { return .releaseNotes } else if self.isNewTabPage { + if self.isCustomBackgroundImage { + return .customBackgroundImage + } + if self.isCustomBackgroundImageThumbnail { + return .customBackgroundImageThumbnail + } return .newTab } else if self.isFavicon { return .favicon @@ -316,4 +375,12 @@ private extension URL { return isDuckURLScheme && host == "favicon" } + var isCustomBackgroundImage: Bool { + return isNewTabPage && pathComponents.prefix(3) == ["/", "background", "images"] + } + + var isCustomBackgroundImageThumbnail: Bool { + return isNewTabPage && pathComponents.prefix(3) == ["/", "background", "thumbnails"] + } + } diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 88d3928293..2305135c74 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -811,7 +811,8 @@ final class BrowserTabViewController: NSViewController { updateTabIfNeeded(tabViewModel: tabViewModel) case .newtab: - if featureFlagger.isFeatureOn(.htmlNewTabPage) { + // We only use HTML New Tab Page in regular windows for now + if featureFlagger.isFeatureOn(.htmlNewTabPage) && !tabCollectionViewModel.isBurner { updateTabIfNeeded(tabViewModel: tabViewModel) } else { removeAllTabContent() diff --git a/LocalPackages/AppKitExtensions/Sources/AppKitExtensions/NSImageExtension.swift b/LocalPackages/AppKitExtensions/Sources/AppKitExtensions/NSImageExtension.swift index 063f86f2da..dc238b284f 100644 --- a/LocalPackages/AppKitExtensions/Sources/AppKitExtensions/NSImageExtension.swift +++ b/LocalPackages/AppKitExtensions/Sources/AppKitExtensions/NSImageExtension.swift @@ -29,6 +29,15 @@ extension NSImage { return bitmapImage.representation(using: .png, properties: [:]) } + public var jpegData: Data? { + guard let tiffData = self.tiffRepresentation, + let bitmapImage = NSBitmapImageRep(data: tiffData) else { + return nil + } + + return bitmapImage.representation(using: .jpeg, properties: [:]) + } + /** * This function calculates image brightness using relative luminance formula. * diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageConfigurationClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageConfigurationClient.swift similarity index 79% rename from LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageConfigurationClient.swift rename to LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageConfigurationClient.swift index fa7911b433..78cb6f9470 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageConfigurationClient.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageConfigurationClient.swift @@ -31,20 +31,30 @@ public protocol NewTabPageSectionsVisibilityProviding: AnyObject { var isPrivacyStatsVisiblePublisher: AnyPublisher { get } } +public protocol NewTabPageLinkOpening { + func openLink(_ target: NewTabPageDataModel.OpenAction.Target) async +} + public final class NewTabPageConfigurationClient: NewTabPageScriptClient { public weak var userScriptsSource: NewTabPageUserScriptsSource? private var cancellables = Set() private let sectionsVisibilityProvider: NewTabPageSectionsVisibilityProviding + private let customBackgroundProvider: NewTabPageCustomBackgroundProviding private let contextMenuPresenter: NewTabPageContextMenuPresenting + private let linkOpener: NewTabPageLinkOpening public init( sectionsVisibilityProvider: NewTabPageSectionsVisibilityProviding, - contextMenuPresenter: NewTabPageContextMenuPresenting = DefaultNewTabPageContextMenuPresenter() + customBackgroundProvider: NewTabPageCustomBackgroundProviding, + contextMenuPresenter: NewTabPageContextMenuPresenting = DefaultNewTabPageContextMenuPresenter(), + linkOpener: NewTabPageLinkOpening ) { self.sectionsVisibilityProvider = sectionsVisibilityProvider + self.customBackgroundProvider = customBackgroundProvider self.contextMenuPresenter = contextMenuPresenter + self.linkOpener = linkOpener Publishers.Merge(sectionsVisibilityProvider.isFavoritesVisiblePublisher, sectionsVisibilityProvider.isPrivacyStatsVisiblePublisher) .receive(on: DispatchQueue.main) @@ -57,6 +67,7 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { enum MessageName: String, CaseIterable { case contextMenu case initialSetup + case open case reportInitException case reportPageException case widgetsSetConfig = "widgets_setConfig" @@ -67,6 +78,7 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { userScript.registerMessageHandlers([ MessageName.contextMenu.rawValue: { [weak self] in try await self?.showContextMenu(params: $0, original: $1) }, MessageName.initialSetup.rawValue: { [weak self] in try await self?.initialSetup(params: $0, original: $1) }, + MessageName.open.rawValue: { [weak self] in try await self?.open(params: $0, original: $1) }, MessageName.reportInitException.rawValue: { [weak self] in try await self?.reportException(params: $0, original: $1) }, MessageName.reportPageException.rawValue: { [weak self] in try await self?.reportException(params: $0, original: $1) }, MessageName.widgetsSetConfig.rawValue: { [weak self] in try await self?.widgetsSetConfig(params: $0, original: $1) } @@ -74,7 +86,7 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { } private func notifyWidgetConfigsDidChange() { - let widgetConfigs: [NewTabPageUserScript.NewTabPageConfiguration.WidgetConfig] = [ + let widgetConfigs: [NewTabPageDataModel.NewTabPageConfiguration.WidgetConfig] = [ .init(id: .favorites, isVisible: sectionsVisibilityProvider.isFavoritesVisible), .init(id: .privacyStats, isVisible: sectionsVisibilityProvider.isPrivacyStatsVisible) ] @@ -84,7 +96,7 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { @MainActor private func showContextMenu(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let params: NewTabPageUserScript.ContextMenuParams = DecodableHelper.decode(from: params) else { return nil } + guard let params: NewTabPageDataModel.ContextMenuParams = DecodableHelper.decode(from: params) else { return nil } let menu = NSMenu() @@ -115,7 +127,7 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { } @objc private func toggleVisibility(_ sender: NSMenuItem) { - switch sender.representedObject as? NewTabPageUserScript.WidgetId { + switch sender.representedObject as? NewTabPageDataModel.WidgetId { case .favorites: sectionsVisibilityProvider.isFavoritesVisible.toggle() case .privacyStats: @@ -132,9 +144,12 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { #else let env = "production" #endif - return NewTabPageUserScript.NewTabPageConfiguration( + + let customizerData = customBackgroundProvider.customizerData + let config = NewTabPageDataModel.NewTabPageConfiguration( widgets: [ .init(id: .rmf), + .init(id: .freemiumPIRBanner), .init(id: .nextSteps), .init(id: .favorites), .init(id: .privacyStats) @@ -145,13 +160,16 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { ], env: env, locale: Bundle.main.preferredLocalizations.first ?? "en", - platform: .init(name: "macos") + platform: .init(name: "macos"), + settings: .init(customizerDrawer: .init(state: .enabled)), + customizer: customizerData ) + return config } @MainActor private func widgetsSetConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let widgetConfigs: [NewTabPageUserScript.NewTabPageConfiguration.WidgetConfig] = DecodableHelper.decode(from: params) else { + guard let widgetConfigs: [NewTabPageDataModel.NewTabPageConfiguration.WidgetConfig] = DecodableHelper.decode(from: params) else { return nil } for widgetConfig in widgetConfigs { @@ -167,6 +185,14 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { return nil } + private func open(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let openAction: NewTabPageDataModel.OpenAction = DecodableHelper.decode(from: params) else { + return nil + } + await linkOpener.openLink(openAction.target) + return nil + } + private func reportException(params: Any, original: WKScriptMessage) async throws -> Encodable? { guard let params = params as? [String: String] else { return nil } let message = params["message"] ?? "" @@ -175,54 +201,3 @@ public final class NewTabPageConfigurationClient: NewTabPageScriptClient { return nil } } - -extension NewTabPageUserScript { - - enum WidgetId: String, Codable { - case rmf, nextSteps, favorites, privacyStats - } - - struct ContextMenuParams: Codable { - let visibilityMenuItems: [ContextMenuItem] - - struct ContextMenuItem: Codable { - let id: WidgetId - let title: String - } - } - - struct NewTabPageConfiguration: Encodable { - var widgets: [Widget] - var widgetConfigs: [WidgetConfig] - var env: String - var locale: String - var platform: Platform - - struct Widget: Encodable, Equatable { - public var id: WidgetId - } - - struct WidgetConfig: Codable, Equatable { - - enum WidgetVisibility: String, Codable { - case visible, hidden - - var isVisible: Bool { - self == .visible - } - } - - init(id: WidgetId, isVisible: Bool) { - self.id = id - self.visibility = isVisible ? .visible : .hidden - } - - var id: WidgetId - var visibility: WidgetVisibility - } - - struct Platform: Encodable, Equatable { - var name: String - } - } -} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageDataModel+Configuration.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageDataModel+Configuration.swift new file mode 100644 index 0000000000..ca96bc506f --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/Configuration/NewTabPageDataModel+Configuration.swift @@ -0,0 +1,98 @@ +// +// NewTabPageDataModel+Configuration.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public extension NewTabPageDataModel { + struct OpenAction: Codable { + let target: Target + + public enum Target: String, Codable { + case settings + } + } +} + +extension NewTabPageDataModel { + + enum WidgetId: String, Codable { + case rmf, freemiumPIRBanner, nextSteps, favorites, privacyStats + } + + struct ContextMenuParams: Codable { + let visibilityMenuItems: [ContextMenuItem] + + struct ContextMenuItem: Codable { + let id: WidgetId + let title: String + } + } + + struct NewTabPageConfiguration: Encodable { + var widgets: [Widget] + var widgetConfigs: [WidgetConfig] + var env: String + var locale: String + var platform: Platform + var settings: Settings? + var customizer: NewTabPageDataModel.CustomizerData? + + struct Widget: Encodable, Equatable { + public var id: WidgetId + } + + struct WidgetConfig: Codable, Equatable { + + enum WidgetVisibility: String, Codable { + case visible, hidden + + var isVisible: Bool { + self == .visible + } + } + + init(id: WidgetId, isVisible: Bool) { + self.id = id + self.visibility = isVisible ? .visible : .hidden + } + + var id: WidgetId + var visibility: WidgetVisibility + } + + struct Platform: Encodable, Equatable { + var name: String + } + + struct Settings: Encodable, Equatable { + let customizerDrawer: Setting + } + + struct Setting: Encodable, Equatable { + let state: BooleanSetting + } + + enum BooleanSetting: String, Encodable { + case enabled, disabled + + var isEnabled: Bool { + self == .enabled + } + } + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomBackgroundClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomBackgroundClient.swift new file mode 100644 index 0000000000..9f86ec64e9 --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomBackgroundClient.swift @@ -0,0 +1,155 @@ +// +// NewTabPageCustomBackgroundClient.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Common +import Combine +import UserScript +import WebKit + +public protocol NewTabPageCustomBackgroundProviding: AnyObject { + var customizerOpener: NewTabPageCustomizerOpener { get } + var customizerData: NewTabPageDataModel.CustomizerData { get } + + var background: NewTabPageDataModel.Background { get set } + var backgroundPublisher: AnyPublisher { get } + + var theme: NewTabPageDataModel.Theme? { get set } + var themePublisher: AnyPublisher { get } + + var userImagesPublisher: AnyPublisher<[NewTabPageDataModel.UserImage], Never> { get } + + @MainActor func presentUploadDialog() async + func deleteImage(with imageID: String) async +} + +public final class NewTabPageCustomBackgroundClient: NewTabPageScriptClient { + + let model: NewTabPageCustomBackgroundProviding + public weak var userScriptsSource: NewTabPageUserScriptsSource? + + private var cancellables: Set = [] + + public init(model: NewTabPageCustomBackgroundProviding) { + self.model = model + + model.backgroundPublisher + .sink { [weak self] background in + Task { @MainActor in + self?.notifyBackgroundUpdated(background) + } + } + .store(in: &cancellables) + + model.themePublisher + .sink { [weak self] theme in + Task { @MainActor in + self?.notifyThemeUpdated(theme) + } + } + .store(in: &cancellables) + + model.userImagesPublisher + .sink { [weak self] images in + Task { @MainActor in + self?.notifyImagesUpdated(images) + } + } + .store(in: &cancellables) + + model.customizerOpener.openSettingsPublisher + .sink { [weak self] webView in + Task { @MainActor in + self?.openSettings(in: webView) + } + } + .store(in: &cancellables) + } + + enum MessageName: String, CaseIterable { + case autoOpen = "customizer_autoOpen" + case deleteImage = "customizer_deleteImage" + case onBackgroundUpdate = "customizer_onBackgroundUpdate" + case onImagesUpdate = "customizer_onImagesUpdate" + case onThemeUpdate = "customizer_onThemeUpdate" + case setBackground = "customizer_setBackground" + case setTheme = "customizer_setTheme" + case upload = "customizer_upload" + } + + public func registerMessageHandlers(for userScript: any SubfeatureWithExternalMessageHandling) { + userScript.registerMessageHandlers([ + MessageName.deleteImage.rawValue: { [weak self] in try await self?.deleteImage(params: $0, original: $1) }, + MessageName.setBackground.rawValue: { [weak self] in try await self?.setBackground(params: $0, original: $1) }, + MessageName.setTheme.rawValue: { [weak self] in try await self?.setTheme(params: $0, original: $1) }, + MessageName.upload.rawValue: { [weak self] in try await self?.upload(params: $0, original: $1) }, + ]) + } + + @MainActor + func deleteImage(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let data: NewTabPageDataModel.DeleteImageData = DecodableHelper.decode(from: params) else { + return nil + } + await model.deleteImage(with: data.id) + return nil + } + + @MainActor + private func setBackground(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let data: NewTabPageDataModel.BackgroundData = DecodableHelper.decode(from: params) else { + return nil + } + model.background = data.background + return nil + } + + @MainActor + private func setTheme(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let data: NewTabPageDataModel.ThemeData = DecodableHelper.decode(from: params) else { + return nil + } + model.theme = data.theme + return nil + } + + @MainActor + private func upload(params: Any, original: WKScriptMessage) async throws -> Encodable? { + await model.presentUploadDialog() + return nil + } + + @MainActor + private func notifyBackgroundUpdated(_ background: NewTabPageDataModel.Background) { + pushMessage(named: MessageName.onBackgroundUpdate.rawValue, params: NewTabPageDataModel.BackgroundData(background: background)) + } + + @MainActor + private func notifyThemeUpdated(_ theme: NewTabPageDataModel.Theme?) { + pushMessage(named: MessageName.onThemeUpdate.rawValue, params: NewTabPageDataModel.ThemeData(theme: theme)) + } + + @MainActor + private func notifyImagesUpdated(_ images: [NewTabPageDataModel.UserImage]) { + pushMessage(named: MessageName.onImagesUpdate.rawValue, params: NewTabPageDataModel.UserImagesData(userImages: images)) + } + + @MainActor + private func openSettings(in webView: WKWebView) { + pushMessage(named: MessageName.autoOpen.rawValue, params: nil, to: webView) + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomizerOpener.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomizerOpener.swift new file mode 100644 index 0000000000..e6bf41214d --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageCustomizerOpener.swift @@ -0,0 +1,42 @@ +// +// NewTabPageCustomizerOpener.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import WebKit + +/** + * This small class exposes an interface that allows for triggering + * events that should open New Tab Page settings. + * + * It's a requirement in `NewTabPageCustomBackgroundProviding` protocol + * and must be provided by classes implementing that protocol on the client app side. + * `NewTabPageCustomBackgroundClient` connects to the opener and forwards + * open settings requests to the JS side. + */ +public final class NewTabPageCustomizerOpener { + public init() { + openSettingsPublisher = openSettingsSubject.eraseToAnyPublisher() + } + + public func openSettings(for webView: WKWebView) { + openSettingsSubject.send(webView) + } + + let openSettingsPublisher: AnyPublisher + private let openSettingsSubject = PassthroughSubject() +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageDataModel+CustomBackground.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageDataModel+CustomBackground.swift new file mode 100644 index 0000000000..69ee33149e --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/CustomBackground/NewTabPageDataModel+CustomBackground.swift @@ -0,0 +1,195 @@ +// +// NewTabPageDataModel+CustomBackground.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKitExtensions +import Foundation + +public extension NewTabPageDataModel { + + struct CustomizerData: Encodable, Equatable { + public let background: Background + public let theme: Theme? + public let userColor: Background? + public let userImages: [UserImage] + + public init(background: Background, theme: Theme?, userColor: NSColor?, userImages: [UserImage]) { + self.background = background + self.theme = theme + self.userImages = userImages + if let hex = userColor?.hex() { + self.userColor = Background.hexColor(hex) + } else { + self.userColor = nil + } + } + + enum CodingKeys: CodingKey { + case background + case theme + case userColor + case userImages + } + + public func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.background, forKey: CodingKeys.background) + try container.encode(self.theme?.rawValue ?? "system", forKey: CodingKeys.theme) + try container.encode(self.userColor, forKey: CodingKeys.userColor) + try container.encode(self.userImages, forKey: CodingKeys.userImages) + } + } + + struct ThemeData: Codable, Equatable { + let theme: Theme? + + enum CodingKeys: CodingKey { + case theme + } + + public init(theme: Theme?) { + self.theme = theme + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.theme?.rawValue ?? "system", forKey: CodingKeys.theme) + } + + public init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + let themeRawValue = try container.decode(String.self, forKey: CodingKeys.theme) + theme = Theme(rawValue: themeRawValue) + } + } + + enum Theme: String, Codable { + case dark, light + } + + enum Background: Codable, Equatable { + case `default` + case solidColor(String) + case hexColor(String) + case gradient(String) + case userImage(UserImage) + + /** + * Custom implementation of this function is here to perform case-insensitive comparison for hex colors. + */ + public static func == (lhs: Background, rhs: Background) -> Bool { + switch (lhs, rhs) { + case (.default, .default): + return true + case (.solidColor(let lColor), .solidColor(let rColor)): + return lColor == rColor + case (.hexColor(let lColor), .hexColor(let rColor)): + return lColor.lowercased() == rColor.lowercased() + case (.gradient(let lGradient), .gradient(let rGradient)): + return lGradient == rGradient + case (.userImage(let lUserImage), .userImage(let rUserImage)): + return lUserImage == rUserImage + default: + return false + } + } + + enum CodingKeys: CodingKey { + case kind + case value + } + + enum Kind: String, Codable { + case `default`, color, hex, gradient, userImage + } + + var kind: Kind { + switch self { + case .default: + return .default + case .solidColor: + return .color + case .hexColor: + return .hex + case .gradient: + return .gradient + case .userImage: + return .userImage + } + } + + public init(from decoder: any Decoder) throws { + let container: KeyedDecodingContainer = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: CodingKeys.kind) + switch kind { + case .color, .hex: + let value = try container.decode(String.self, forKey: CodingKeys.value) + self = .solidColor(value) + case .gradient: + let value = try container.decode(String.self, forKey: CodingKeys.value) + self = .gradient(value) + case .userImage: + let value = try container.decode(UserImage.self, forKey: CodingKeys.value) + self = .userImage(value) + default: + self = .default + } + } + + public func encode(to encoder: any Encoder) throws { + var container: KeyedEncodingContainer = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: CodingKeys.kind) + switch self { + case .default: + break + case .solidColor(let value), .hexColor(let value), .gradient(let value): + try container.encode(value, forKey: CodingKeys.value) + case .userImage(let image): + try container.encode(image, forKey: CodingKeys.value) + } + } + } + + struct UserImage: Codable, Equatable { + public let colorScheme: Theme + public let id: String + public let src: String + public let thumb: String + + public init(colorScheme: Theme, id: String, src: String, thumb: String) { + self.colorScheme = colorScheme + self.id = id + self.src = src + self.thumb = thumb + } + } +} + +extension NewTabPageDataModel { + + struct BackgroundData: Codable, Equatable { + let background: Background + } + + struct UserImagesData: Codable, Equatable { + let userImages: [UserImage] + } + + struct DeleteImageData: Codable, Equatable { + let id: String + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageDataModel+Favorites.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageDataModel+Favorites.swift new file mode 100644 index 0000000000..d57d112b31 --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageDataModel+Favorites.swift @@ -0,0 +1,87 @@ +// +// NewTabPageDataModel+Favorites.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension NewTabPageDataModel { + + struct FavoritesContextMenuAction: Codable { + let id: String + } + + struct FavoritesOpenAction: Codable { + let id: String + let url: String + } + + struct FavoritesMoveAction: Codable { + let id: String + let fromIndex: Int + let targetIndex: Int + } + + struct FavoritesConfig: Codable { + let expansion: Expansion + + enum Expansion: String, Codable { + case expanded, collapsed + } + } + + struct FavoritesData: Encodable { + let favorites: [Favorite] + } + + struct Favorite: Encodable, Equatable { + let favicon: FavoriteFavicon? + let id: String + let title: String + let url: String + + init(id: String, title: String, url: String, favicon: NewTabPageDataModel.FavoriteFavicon? = nil) { + self.id = id + self.title = title + self.url = url + self.favicon = favicon + } + + @MainActor + init(_ bookmark: NewTabPageFavorite, preferredFaviconSize: Int, onFaviconMissing: () -> Void) { + id = bookmark.id + title = bookmark.title + url = bookmark.url + + if let url = bookmark.urlObject, let duckFaviconURL = URL.duckFavicon(for: url) { + favicon = FavoriteFavicon(maxAvailableSize: preferredFaviconSize, src: duckFaviconURL.absoluteString) + } else { + onFaviconMissing() + favicon = nil + } + } + } + + struct FavoriteFavicon: Encodable, Equatable { + let maxAvailableSize: Int + let src: String + + init(maxAvailableSize: Int, src: String) { + self.maxAvailableSize = maxAvailableSize + self.src = src + } + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageFavoritesClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageFavoritesClient.swift index 7aa3c1e977..628d32a2e5 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageFavoritesClient.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/Favorites/NewTabPageFavoritesClient.swift @@ -99,17 +99,17 @@ public final class NewTabPageFavoritesClient: NewTa @MainActor private func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { let favorites = favoritesModel.favorites.map { - NewTabPageFavoritesClient.Favorite($0, preferredFaviconSize: preferredFaviconSize, onFaviconMissing: favoritesModel.onFaviconMissing) + NewTabPageDataModel.Favorite($0, preferredFaviconSize: preferredFaviconSize, onFaviconMissing: favoritesModel.onFaviconMissing) } - return NewTabPageFavoritesClient.FavoritesData(favorites: favorites) + return NewTabPageDataModel.FavoritesData(favorites: favorites) } @MainActor private func notifyDataUpdated(_ favorites: [NewTabPageFavorite]) { let favorites = favoritesModel.favorites.map { - NewTabPageFavoritesClient.Favorite($0, preferredFaviconSize: preferredFaviconSize, onFaviconMissing: favoritesModel.onFaviconMissing) + NewTabPageDataModel.Favorite($0, preferredFaviconSize: preferredFaviconSize, onFaviconMissing: favoritesModel.onFaviconMissing) } - pushMessage(named: MessageName.onDataUpdate.rawValue, params: NewTabPageFavoritesClient.FavoritesData(favorites: favorites)) + pushMessage(named: MessageName.onDataUpdate.rawValue, params: NewTabPageDataModel.FavoritesData(favorites: favorites)) } @MainActor @@ -121,7 +121,7 @@ public final class NewTabPageFavoritesClient: NewTa @MainActor private func move(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let action: NewTabPageFavoritesClient.FavoritesMoveAction = DecodableHelper.decode(from: params) else { + guard let action: NewTabPageDataModel.FavoritesMoveAction = DecodableHelper.decode(from: params) else { return nil } favoritesModel.moveFavorite(withID: action.id, fromIndex: action.fromIndex, toIndex: action.targetIndex) @@ -130,7 +130,7 @@ public final class NewTabPageFavoritesClient: NewTa @MainActor private func open(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let action: NewTabPageFavoritesClient.FavoritesOpenAction = DecodableHelper.decode(from: params) else { + guard let action: NewTabPageDataModel.FavoritesOpenAction = DecodableHelper.decode(from: params) else { return nil } favoritesModel.openFavorite(withURL: action.url) @@ -139,7 +139,7 @@ public final class NewTabPageFavoritesClient: NewTa @MainActor private func openContextMenu(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let contextMenuAction: NewTabPageFavoritesClient.FavoritesContextMenuAction = DecodableHelper.decode(from: params) else { + guard let contextMenuAction: NewTabPageDataModel.FavoritesContextMenuAction = DecodableHelper.decode(from: params) else { return nil } favoritesModel.showContextMenu(for: contextMenuAction.id) @@ -147,74 +147,6 @@ public final class NewTabPageFavoritesClient: NewTa } } -public extension NewTabPageFavoritesClient { - - struct FavoritesContextMenuAction: Codable { - let id: String - } - - struct FavoritesOpenAction: Codable { - let id: String - let url: String - } - - struct FavoritesMoveAction: Codable { - let id: String - let fromIndex: Int - let targetIndex: Int - } - - struct FavoritesConfig: Codable { - let expansion: Expansion - - enum Expansion: String, Codable { - case expanded, collapsed - } - } - - struct FavoritesData: Encodable { - let favorites: [Favorite] - } - - struct Favorite: Encodable, Equatable { - let favicon: FavoriteFavicon? - let id: String - let title: String - let url: String - - init(id: String, title: String, url: String, favicon: NewTabPageFavoritesClient.FavoriteFavicon? = nil) { - self.id = id - self.title = title - self.url = url - self.favicon = favicon - } - - @MainActor - init(_ bookmark: NewTabPageFavorite, preferredFaviconSize: Int, onFaviconMissing: () -> Void) { - id = bookmark.id - title = bookmark.title - url = bookmark.url - - if let url = bookmark.urlObject, let duckFaviconURL = URL.duckFavicon(for: url) { - favicon = FavoriteFavicon(maxAvailableSize: preferredFaviconSize, src: duckFaviconURL.absoluteString) - } else { - onFaviconMissing() - favicon = nil - } - } - } - - struct FavoriteFavicon: Encodable, Equatable { - let maxAvailableSize: Int - let src: String - - init(maxAvailableSize: Int, src: String) { - self.maxAvailableSize = maxAvailableSize - self.src = src - } - } -} - extension URL { static func duckFavicon(for faviconURL: URL) -> URL? { let encodedURL = faviconURL.absoluteString.percentEncoded(withAllowedCharacters: .urlPathAllowed) diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageDataModel+FreemiumDBP.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageDataModel+FreemiumDBP.swift new file mode 100644 index 0000000000..a5524e509c --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageDataModel+FreemiumDBP.swift @@ -0,0 +1,43 @@ +// +// NewTabPageDataModel+FreemiumDBP.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public extension NewTabPageDataModel { + + struct FreemiumPIRBannerMessage: Encodable, Equatable { + let messageType = "big_single_action" + + let id = "banner_message" + let titleText: String? + let descriptionText: String + let actionText: String + + public init(titleText: String?, descriptionText: String, actionText: String) { + self.titleText = titleText + self.descriptionText = descriptionText + self.actionText = actionText + } + } +} + +extension NewTabPageDataModel { + struct FreemiumPIRBannerMessageData: Encodable, Equatable { + let content: FreemiumPIRBannerMessage? + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageFreemiumDBPClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageFreemiumDBPClient.swift new file mode 100644 index 0000000000..c472a31096 --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/FreemiumDBP/NewTabPageFreemiumDBPClient.swift @@ -0,0 +1,89 @@ +// +// NewTabPageFreemiumDBPClient.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Common +import UserScript +import WebKit + +public protocol NewTabPageFreemiumDBPBannerProviding { + + var bannerMessage: NewTabPageDataModel.FreemiumPIRBannerMessage? { get } + + var bannerMessagePublisher: AnyPublisher { get } + + func dismiss() async + + func action() async +} + +public final class NewTabPageFreemiumDBPClient: NewTabPageScriptClient { + + let freemiumDBPBannerProvider: NewTabPageFreemiumDBPBannerProviding + public weak var userScriptsSource: NewTabPageUserScriptsSource? + + private var cancellables = Set() + + public init(provider: NewTabPageFreemiumDBPBannerProviding) { + self.freemiumDBPBannerProvider = provider + + freemiumDBPBannerProvider.bannerMessagePublisher + .sink { [weak self] message in + self?.notifyMessageDidChange(message) + } + .store(in: &cancellables) + } + + enum MessageName: String, CaseIterable { + case getData = "freemiumPIRBanner_getData" + case onDataUpdate = "freemiumPIRBanner_onDataUpdate" + case dismiss = "freemiumPIRBanner_dismiss" + case action = "freemiumPIRBanner_action" + } + + public func registerMessageHandlers(for userScript: any SubfeatureWithExternalMessageHandling) { + userScript.registerMessageHandlers([ + MessageName.action.rawValue: { [weak self] in try await self?.action(params: $0, original: $1) }, + MessageName.dismiss.rawValue: { [weak self] in try await self?.dismiss(params: $0, original: $1) }, + MessageName.getData.rawValue: { [weak self] in try await self?.getData(params: $0, original: $1) }, + ]) + } + + @MainActor + private func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let message = freemiumDBPBannerProvider.bannerMessage else { + return NewTabPageDataModel.FreemiumPIRBannerMessageData(content: nil) + } + + return NewTabPageDataModel.FreemiumPIRBannerMessageData(content: message) + } + + private func dismiss(params: Any, original: WKScriptMessage) async throws -> Encodable? { + await freemiumDBPBannerProvider.dismiss() + return nil + } + + private func action(params: Any, original: WKScriptMessage) async throws -> Encodable? { + await freemiumDBPBannerProvider.action() + return nil + } + + private func notifyMessageDidChange(_ message: NewTabPageDataModel.FreemiumPIRBannerMessage?) { + pushMessage(named: MessageName.onDataUpdate.rawValue, params: NewTabPageDataModel.FreemiumPIRBannerMessageData(content: message)) + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageActionsManager.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageActionsManager.swift index 6903b36648..135a7bf6a0 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageActionsManager.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageActionsManager.swift @@ -20,6 +20,7 @@ import Foundation import Combine import Common import os.log +import WebKit /** * This protocol describes a feature or set of features that use HTML New Tab Page. @@ -56,6 +57,17 @@ public extension NewTabPageScriptClient { userScript.broker?.push(method: method, params: params, for: userScript, into: webView) } } + + /** + * Convenience method to push a message with specific parameters to the user script + * associated with the given `webView`. + */ + func pushMessage(named method: String, params: Encodable?, to webView: WKWebView) { + guard let userScript = userScriptsSource?.userScripts.first(where: { $0.webView === webView }) else { + return + } + userScript.broker?.push(method: method, params: params, for: userScript, into: webView) + } } /** diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageDataModel.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageDataModel.swift new file mode 100644 index 0000000000..0cc6c1afdd --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/NewTabPageDataModel.swift @@ -0,0 +1,19 @@ +// +// NewTabPageDataModel.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +public enum NewTabPageDataModel {} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/NewTabPageTestsHelper.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageDataModel+NextStepsCards.swift similarity index 54% rename from LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/NewTabPageTestsHelper.swift rename to LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageDataModel+NextStepsCards.swift index abf110eb36..b1097365a9 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/NewTabPageTestsHelper.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageDataModel+NextStepsCards.swift @@ -1,5 +1,5 @@ // -// NewTabPageTestsHelper.swift +// NewTabPageDataModel+NextStepsCards.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -17,19 +17,25 @@ // import Foundation -import XCTest -enum NewTabPageTestsHelper { +public extension NewTabPageDataModel { - static func asJSON(_ value: Any, file: StaticString = #file, line: UInt = #line) throws -> Any { - if JSONSerialization.isValidJSONObject(value) { - return value - } - if let encodableValue = value as? Encodable { - let jsonData = try JSONEncoder().encode(encodableValue) - return try JSONSerialization.jsonObject(with: jsonData) - } - XCTFail("invalid JSON value", file: file, line: line) - return [] + enum CardID: String, Codable { + case bringStuff + case defaultApp + case emailProtection + case duckplayer + case addAppToDockMac + } +} + +extension NewTabPageDataModel { + + struct Card: Codable, Equatable { + let id: CardID + } + + struct NextStepsData: Codable, Equatable { + public let content: [Card]? } } diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift index 46b11553bc..50fd73de61 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift @@ -24,13 +24,13 @@ import WebKit public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { let model: NewTabPageNextStepsCardsProviding - let willDisplayCardsPublisher: AnyPublisher<[CardID], Never> + let willDisplayCardsPublisher: AnyPublisher<[NewTabPageDataModel.CardID], Never> public weak var userScriptsSource: NewTabPageUserScriptsSource? - private let willDisplayCardsSubject = PassthroughSubject<[CardID], Never>() - private let getDataSubject = PassthroughSubject<[CardID], Never>() + private let willDisplayCardsSubject = PassthroughSubject<[NewTabPageDataModel.CardID], Never>() + private let getDataSubject = PassthroughSubject<[NewTabPageDataModel.CardID], Never>() private let getConfigSubject = PassthroughSubject() - private let notifyDataUpdatedSubject = PassthroughSubject<[CardID], Never>() + private let notifyDataUpdatedSubject = PassthroughSubject<[NewTabPageDataModel.CardID], Never>() private let notifyConfigUpdatedSubject = PassthroughSubject() private var cancellables: Set = [] @@ -83,7 +83,7 @@ public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { // only notify about cards revealed by expanding the view (i.e. other than the first 2) let cardsOnConfigUpdated = notifyConfigUpdatedSubject .drop(untilOutputFrom: firstInitialCards) - .compactMap { [weak self] isViewExpanded -> [CardID]? in + .compactMap { [weak self] isViewExpanded -> [NewTabPageDataModel.CardID]? in guard let self, isViewExpanded, model.cards.count > 2 else { return nil } @@ -120,7 +120,7 @@ public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { @MainActor private func action(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let card: Card = DecodableHelper.decode(from: params) else { + guard let card: NewTabPageDataModel.Card = DecodableHelper.decode(from: params) else { return nil } model.handleAction(for: card.id) @@ -129,7 +129,7 @@ public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { @MainActor private func dismiss(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let card: Card = DecodableHelper.decode(from: params) else { + guard let card: NewTabPageDataModel.Card = DecodableHelper.decode(from: params) else { return nil } model.dismiss(card.id) @@ -155,16 +155,16 @@ public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { @MainActor private func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { let cardIDs = model.cards - let cards = cardIDs.map(Card.init(id:)) + let cards = cardIDs.map(NewTabPageDataModel.Card.init(id:)) getDataSubject.send(cardIDs) - return NextStepsData(content: cards.isEmpty ? nil : cards) + return NewTabPageDataModel.NextStepsData(content: cards.isEmpty ? nil : cards) } @MainActor - private func notifyDataUpdated(_ cardIDs: [CardID]) { - let cards = cardIDs.map(Card.init(id:)) - let params = NextStepsData(content: cards.isEmpty ? nil : cards) + private func notifyDataUpdated(_ cardIDs: [NewTabPageDataModel.CardID]) { + let cards = cardIDs.map(NewTabPageDataModel.Card.init(id:)) + let params = NewTabPageDataModel.NextStepsData(content: cards.isEmpty ? nil : cards) notifyDataUpdatedSubject.send(cardIDs) pushMessage(named: MessageName.onDataUpdate.rawValue, params: params) @@ -179,22 +179,3 @@ public final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { pushMessage(named: MessageName.onConfigUpdate.rawValue, params: config) } } - -extension NewTabPageNextStepsCardsClient { - - public enum CardID: String, Codable { - case bringStuff - case defaultApp - case emailProtection - case duckplayer - case addAppToDockMac - } - - struct Card: Codable, Equatable { - let id: CardID - } - - struct NextStepsData: Codable, Equatable { - public let content: [Card]? - } -} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift index a6843ef2ba..fbf71212af 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift @@ -25,15 +25,15 @@ public protocol NewTabPageNextStepsCardsProviding: AnyObject { var isViewExpanded: Bool { get set } var isViewExpandedPublisher: AnyPublisher { get } - var cards: [NewTabPageNextStepsCardsClient.CardID] { get } - var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { get } + var cards: [NewTabPageDataModel.CardID] { get } + var cardsPublisher: AnyPublisher<[NewTabPageDataModel.CardID], Never> { get } @MainActor - func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) + func handleAction(for card: NewTabPageDataModel.CardID) @MainActor - func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) + func dismiss(_ card: NewTabPageDataModel.CardID) @MainActor - func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) + func willDisplayCards(_ cards: [NewTabPageDataModel.CardID]) } diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPageDataModel+PrivacyStats.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPageDataModel+PrivacyStats.swift new file mode 100644 index 0000000000..61b76e5bcc --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPageDataModel+PrivacyStats.swift @@ -0,0 +1,40 @@ +// +// NewTabPageDataModel+PrivacyStats.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +extension NewTabPageDataModel { + + struct PrivacyStatsData: Encodable, Equatable { + let totalCount: Int64 + let trackerCompanies: [TrackerCompany] + + static func == (lhs: PrivacyStatsData, rhs: PrivacyStatsData) -> Bool { + lhs.totalCount == rhs.totalCount && Set(lhs.trackerCompanies) == Set(rhs.trackerCompanies) + } + } + + struct TrackerCompany: Encodable, Equatable, Hashable { + let count: Int64 + let displayName: String + + static func otherCompanies(count: Int64) -> TrackerCompany { + TrackerCompany(count: count, displayName: "__other__") + } + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsClient.swift index f28582afc5..62fc3804b3 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsClient.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsClient.swift @@ -111,24 +111,3 @@ public final class NewTabPagePrivacyStatsClient: NewTabPageScriptClient { return nil } } - -extension NewTabPagePrivacyStatsClient { - - struct PrivacyStatsData: Encodable, Equatable { - let totalCount: Int64 - let trackerCompanies: [TrackerCompany] - - static func == (lhs: PrivacyStatsData, rhs: PrivacyStatsData) -> Bool { - lhs.totalCount == rhs.totalCount && Set(lhs.trackerCompanies) == Set(rhs.trackerCompanies) - } - } - - struct TrackerCompany: Encodable, Equatable, Hashable { - let count: Int64 - let displayName: String - - static func otherCompanies(count: Int64) -> TrackerCompany { - TrackerCompany(count: count, displayName: "__other__") - } - } -} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsModel.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsModel.swift index d2aee1d6f5..56a6cdff80 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsModel.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/PrivacyStats/NewTabPagePrivacyStatsModel.swift @@ -129,25 +129,25 @@ public final class NewTabPagePrivacyStatsModel { eventMapping?.fire(.showMore) } - func calculatePrivacyStats() async -> NewTabPagePrivacyStatsClient.PrivacyStatsData { + func calculatePrivacyStats() async -> NewTabPageDataModel.PrivacyStatsData { let stats = await privacyStats.fetchPrivacyStats() var totalCount: Int64 = 0 var otherCount: Int64 = 0 - var companiesStats: [NewTabPagePrivacyStatsClient.TrackerCompany] = stats.compactMap { key, value in + var companiesStats: [NewTabPageDataModel.TrackerCompany] = stats.compactMap { key, value in totalCount += value guard topCompanies.contains(key) else { otherCount += value return nil } - return NewTabPagePrivacyStatsClient.TrackerCompany(count: value, displayName: key) + return NewTabPageDataModel.TrackerCompany(count: value, displayName: key) } if otherCount > 0 { companiesStats.append(.otherCompanies(count: otherCount)) } - return NewTabPagePrivacyStatsClient.PrivacyStatsData(totalCount: totalCount, trackerCompanies: companiesStats) + return NewTabPageDataModel.PrivacyStatsData(totalCount: totalCount, trackerCompanies: companiesStats) } private func refreshTopCompanies() { diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageDataModel+RMF.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageDataModel+RMF.swift new file mode 100644 index 0000000000..2d2ede6230 --- /dev/null +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageDataModel+RMF.swift @@ -0,0 +1,138 @@ +// +// NewTabPageDataModel+RMF.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import RemoteMessaging + +extension NewTabPageDataModel { + + struct RemoteMessageParams: Codable { + let id: String + } + + struct RMFData: Encodable { + let content: RMFMessage? + } + + enum RMFMessage: Encodable, Equatable { + case small(SmallMessage), medium(MediumMessage), bigSingleAction(BigSingleActionMessage), bigTwoAction(BigTwoActionMessage) + + func encode(to encoder: any Encoder) throws { + try message.encode(to: encoder) + } + + var message: Encodable { + switch self { + case .small(let message): + return message + case .medium(let message): + return message + case .bigSingleAction(let message): + return message + case .bigTwoAction(let message): + return message + } + } + + init?(_ remoteMessageModel: RemoteMessageModel) { + guard let modelType = remoteMessageModel.content else { + return nil + } + + switch modelType { + case let .small(titleText, descriptionText): + self = .small(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText)) + + case let .medium(titleText, descriptionText, placeholder): + self = .medium(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder))) + + case let .bigSingleAction(titleText, descriptionText, placeholder, primaryActionText, _): + self = .bigSingleAction(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder), primaryActionText: primaryActionText)) + + case let .bigTwoAction(titleText, descriptionText, placeholder, primaryActionText, _, secondaryActionText, _): + self = .bigTwoAction(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder), primaryActionText: primaryActionText, secondaryActionText: secondaryActionText)) + + default: + return nil + } + } + } + + struct SmallMessage: Encodable, Equatable { + let messageType = "small" + + let id: String + let titleText: String + let descriptionText: String + } + + struct MediumMessage: Encodable, Equatable { + let messageType = "medium" + + let id: String + let titleText: String + let descriptionText: String + let icon: RMFIcon + } + + struct BigSingleActionMessage: Encodable, Equatable { + let messageType = "big_single_action" + + let id: String + let titleText: String + let descriptionText: String + let icon: RMFIcon + let primaryActionText: String + } + + struct BigTwoActionMessage: Encodable, Equatable { + let messageType = "big_two_action" + + let id: String + let titleText: String + let descriptionText: String + let icon: RMFIcon + let primaryActionText: String + let secondaryActionText: String + } + + enum RMFIcon: String, Encodable { + case announce = "Announce" + case ddgAnnounce = "DDGAnnounce" + case criticalUpdate = "CriticalUpdate" + case appUpdate = "AppUpdate" + case privacyPro = "PrivacyPro" + + init(_ placeholder: RemotePlaceholder) { + switch placeholder { + case .announce: + self = .announce + case .ddgAnnounce: + self = .ddgAnnounce + case .criticalUpdate: + self = .criticalUpdate + case .appUpdate: + self = .appUpdate + case .privacyShield: + self = .privacyPro + default: + self = .ddgAnnounce + } + } + } +} diff --git a/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageRMFClient.swift b/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageRMFClient.swift index aa0ff4acc5..8fd4f170f9 100644 --- a/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageRMFClient.swift +++ b/LocalPackages/NewTabPage/Sources/NewTabPage/RMF/NewTabPageRMFClient.swift @@ -63,14 +63,14 @@ public final class NewTabPageRMFClient: NewTabPageScriptClient { @MainActor private func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { guard let remoteMessage = remoteMessageProvider.newTabPageRemoteMessage else { - return NewTabPageUserScript.RMFData(content: nil) + return NewTabPageDataModel.RMFData(content: nil) } - return NewTabPageUserScript.RMFData(content: .init(remoteMessage)) + return NewTabPageDataModel.RMFData(content: .init(remoteMessage)) } private func dismiss(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let remoteMessageParams: NewTabPageUserScript.RemoteMessageParams = DecodableHelper.decode(from: params), + guard let remoteMessageParams: NewTabPageDataModel.RemoteMessageParams = DecodableHelper.decode(from: params), remoteMessageParams.id == remoteMessageProvider.newTabPageRemoteMessage?.id else { return nil @@ -81,7 +81,7 @@ public final class NewTabPageRMFClient: NewTabPageScriptClient { } private func primaryAction(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let remoteMessageParams: NewTabPageUserScript.RemoteMessageParams = DecodableHelper.decode(from: params), + guard let remoteMessageParams: NewTabPageDataModel.RemoteMessageParams = DecodableHelper.decode(from: params), remoteMessageParams.id == remoteMessageProvider.newTabPageRemoteMessage?.id else { return nil @@ -99,7 +99,7 @@ public final class NewTabPageRMFClient: NewTabPageScriptClient { } private func secondaryAction(params: Any, original: WKScriptMessage) async throws -> Encodable? { - guard let remoteMessageParams: NewTabPageUserScript.RemoteMessageParams = DecodableHelper.decode(from: params), + guard let remoteMessageParams: NewTabPageDataModel.RemoteMessageParams = DecodableHelper.decode(from: params), remoteMessageParams.id == remoteMessageProvider.newTabPageRemoteMessage?.id else { return nil @@ -115,131 +115,13 @@ public final class NewTabPageRMFClient: NewTabPageScriptClient { } private func notifyRemoteMessageDidChange(_ remoteMessage: RemoteMessageModel?) { - let data: NewTabPageUserScript.RMFData = { + let data: NewTabPageDataModel.RMFData = { guard let remoteMessage, remoteMessageProvider.isMessageSupported(remoteMessage) else { return .init(content: nil) } - return .init(content: NewTabPageUserScript.RMFMessage(remoteMessage)) + return .init(content: NewTabPageDataModel.RMFMessage(remoteMessage)) }() pushMessage(named: MessageName.rmfOnDataUpdate.rawValue, params: data) } } - -extension NewTabPageUserScript { - - struct RemoteMessageParams: Codable { - let id: String - } - - struct RMFData: Encodable { - let content: RMFMessage? - } - - enum RMFMessage: Encodable, Equatable { - case small(SmallMessage), medium(MediumMessage), bigSingleAction(BigSingleActionMessage), bigTwoAction(BigTwoActionMessage) - - func encode(to encoder: any Encoder) throws { - try message.encode(to: encoder) - } - - var message: Encodable { - switch self { - case .small(let message): - return message - case .medium(let message): - return message - case .bigSingleAction(let message): - return message - case .bigTwoAction(let message): - return message - } - } - - init?(_ remoteMessageModel: RemoteMessageModel) { - guard let modelType = remoteMessageModel.content else { - return nil - } - - switch modelType { - case let .small(titleText, descriptionText): - self = .small(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText)) - - case let .medium(titleText, descriptionText, placeholder): - self = .medium(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder))) - - case let .bigSingleAction(titleText, descriptionText, placeholder, primaryActionText, _): - self = .bigSingleAction(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder), primaryActionText: primaryActionText)) - - case let .bigTwoAction(titleText, descriptionText, placeholder, primaryActionText, _, secondaryActionText, _): - self = .bigTwoAction(.init(id: remoteMessageModel.id, titleText: titleText, descriptionText: descriptionText, icon: .init(placeholder), primaryActionText: primaryActionText, secondaryActionText: secondaryActionText)) - - default: - return nil - } - } - } - - struct SmallMessage: Encodable, Equatable { - let messageType = "small" - - let id: String - let titleText: String - let descriptionText: String - } - - struct MediumMessage: Encodable, Equatable { - let messageType = "medium" - - let id: String - let titleText: String - let descriptionText: String - let icon: RMFIcon - } - - struct BigSingleActionMessage: Encodable, Equatable { - let messageType = "big_single_action" - - let id: String - let titleText: String - let descriptionText: String - let icon: RMFIcon - let primaryActionText: String - } - - struct BigTwoActionMessage: Encodable, Equatable { - let messageType = "big_two_action" - - let id: String - let titleText: String - let descriptionText: String - let icon: RMFIcon - let primaryActionText: String - let secondaryActionText: String - } - - enum RMFIcon: String, Encodable { - case announce = "Announce" - case ddgAnnounce = "DDGAnnounce" - case criticalUpdate = "CriticalUpdate" - case appUpdate = "AppUpdate" - case privacyPro = "PrivacyPro" - - init(_ placeholder: RemotePlaceholder) { - switch placeholder { - case .announce: - self = .announce - case .ddgAnnounce: - self = .ddgAnnounce - case .criticalUpdate: - self = .criticalUpdate - case .appUpdate: - self = .appUpdate - case .privacyShield: - self = .privacyPro - default: - self = .ddgAnnounce - } - } - } -} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/MessageHelper.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/MessageHelper.swift new file mode 100644 index 0000000000..5d2996ec48 --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Helpers/MessageHelper.swift @@ -0,0 +1,57 @@ +// +// MessageHelper.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import NewTabPage +import XCTest + +final class MessageHelper where MessageName.RawValue == String { + let userScript: NewTabPageUserScript + + init(userScript: NewTabPageUserScript) { + self.userScript = userScript + } + + func handleMessage(named methodName: MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { + let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) + let response = try await handler(Self.asJSON(parameters), .init()) + return try XCTUnwrap(response as? Response, file: file, line: line) + } + + func handleMessageIgnoringResponse(named methodName: MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { + let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) + _ = try await handler(Self.asJSON(parameters), .init()) + } + + func handleMessageExpectingNilResponse(named methodName: MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { + let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) + let response = try await handler(Self.asJSON(parameters), .init()) + XCTAssertNil(response, file: file, line: line) + } + + private static func asJSON(_ value: Any, file: StaticString = #file, line: UInt = #line) throws -> Any { + if JSONSerialization.isValidJSONObject(value) { + return value + } + if let encodableValue = value as? Encodable { + let jsonData = try JSONEncoder().encode(encodableValue) + return try JSONSerialization.jsonObject(with: jsonData) + } + XCTFail("invalid JSON value", file: file, line: line) + return [] + } +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageCustomBackgroundProvider.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageCustomBackgroundProvider.swift new file mode 100644 index 0000000000..072fa781d1 --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageCustomBackgroundProvider.swift @@ -0,0 +1,58 @@ +// +// CapturingNewTabPageCustomBackgroundProvider.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import NewTabPage + +final class CapturingNewTabPageCustomBackgroundProvider: NewTabPageCustomBackgroundProviding { + var customizerOpener: NewTabPageCustomizerOpener = NewTabPageCustomizerOpener() + + var customizerData: NewTabPageDataModel.CustomizerData = .init(background: .default, theme: .none, userColor: nil, userImages: []) + + @Published + var background: NewTabPageDataModel.Background = .default + + var backgroundPublisher: AnyPublisher { + $background.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + @Published + var theme: NewTabPageDataModel.Theme? + + var themePublisher: AnyPublisher { + $theme.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + @Published + var userImages: [NewTabPageDataModel.UserImage] = [] + + var userImagesPublisher: AnyPublisher<[NewTabPageDataModel.UserImage], Never> { + $userImages.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + func presentUploadDialog() async { + presentUploadDialogCallsCount += 1 + } + + func deleteImage(with imageID: String) async { + deleteImageCalls.append(imageID) + } + + var presentUploadDialogCallsCount: Int = 0 + var deleteImageCalls: [String] = [] +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageFreemiumDBPBannerProvider.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageFreemiumDBPBannerProvider.swift new file mode 100644 index 0000000000..7e07ec9546 --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageFreemiumDBPBannerProvider.swift @@ -0,0 +1,47 @@ +// +// CapturingNewTabPageFreemiumDBPBannerProvider.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import Foundation +import NewTabPage + +final class CapturingNewTabPageFreemiumDBPBannerProvider: NewTabPageFreemiumDBPBannerProviding { + @Published var bannerMessage: NewTabPageDataModel.FreemiumPIRBannerMessage? + + var bannerMessagePublisher: AnyPublisher { + $bannerMessage.dropFirst().eraseToAnyPublisher() + } + + func dismiss() async { + dismissCallCount += 1 + await _dismiss() + } + + func action() async { + actionCallCount += 1 + await _action() + } + + var dismissCallCount: Int = 0 + var actionCallCount: Int = 0 + + // swiftlint:disable identifier_name + var _dismiss: () async -> Void = {} + var _action: () async -> Void = {} + // swiftlint:enable identifier_name +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageLinkOpener.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageLinkOpener.swift new file mode 100644 index 0000000000..c4ce4fa852 --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageLinkOpener.swift @@ -0,0 +1,30 @@ +// +// CapturingNewTabPageLinkOpener.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import NewTabPage + +final class CapturingNewTabPageLinkOpener: NewTabPageLinkOpening { + func openLink(_ target: NewTabPageDataModel.OpenAction.Target) async { + openLinkCalls.append(target) + await _openLink(target) + } + + var openLinkCalls: [NewTabPageDataModel.OpenAction.Target] = [] + // swiftlint:disable:next identifier_name + var _openLink: (NewTabPageDataModel.OpenAction.Target) async -> Void = { _ in } +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageNextStepsCardsProvider.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageNextStepsCardsProvider.swift index 4cc61f4c50..2df71817cc 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageNextStepsCardsProvider.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/CapturingNewTabPageNextStepsCardsProvider.swift @@ -27,26 +27,26 @@ final class CapturingNewTabPageNextStepsCardsProvider: NewTabPageNextStepsCardsP $isViewExpanded.dropFirst().removeDuplicates().eraseToAnyPublisher() } - @Published var cards: [NewTabPageNextStepsCardsClient.CardID] = [] - var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + @Published var cards: [NewTabPageDataModel.CardID] = [] + var cardsPublisher: AnyPublisher<[NewTabPageDataModel.CardID], Never> { $cards.dropFirst().removeDuplicates().eraseToAnyPublisher() } - func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + func handleAction(for card: NewTabPageDataModel.CardID) { handleActionCalls.append(card) } - func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + func dismiss(_ card: NewTabPageDataModel.CardID) { dismissCalls.append(card) } - func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + func willDisplayCards(_ cards: [NewTabPageDataModel.CardID]) { willDisplayCardsCalls.append(cards) willDisplayCardsImpl?(cards) } - var handleActionCalls: [NewTabPageNextStepsCardsClient.CardID] = [] - var dismissCalls: [NewTabPageNextStepsCardsClient.CardID] = [] - var willDisplayCardsCalls: [[NewTabPageNextStepsCardsClient.CardID]] = [] - var willDisplayCardsImpl: (([NewTabPageNextStepsCardsClient.CardID]) -> Void)? + var handleActionCalls: [NewTabPageDataModel.CardID] = [] + var dismissCalls: [NewTabPageDataModel.CardID] = [] + var willDisplayCardsCalls: [[NewTabPageDataModel.CardID]] = [] + var willDisplayCardsImpl: (([NewTabPageDataModel.CardID]) -> Void)? } diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/RemoteMessageModelMocks.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/RemoteMessageModelMocks.swift new file mode 100644 index 0000000000..66bd9346f3 --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/Mocks/RemoteMessageModelMocks.swift @@ -0,0 +1,75 @@ +// +// RemoteMessageModelMocks.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import RemoteMessaging + +extension RemoteMessageModel { + static func mockSmall(id: String) -> RemoteMessageModel { + .init( + id: id, + content: .small(titleText: "title", descriptionText: "description"), + matchingRules: [], + exclusionRules: [], + isMetricsEnabled: true + ) + } + + static func mockMedium(id: String) -> RemoteMessageModel { + .init( + id: "sample_message", + content: .medium(titleText: "title", descriptionText: "description", placeholder: .criticalUpdate), + matchingRules: [], + exclusionRules: [], + isMetricsEnabled: true + ) + } + + static func mockBigSingleAction(id: String, action: RemoteAction) -> RemoteMessageModel { + .init( + id: "sample_message", + content: .bigSingleAction( + titleText: "title", + descriptionText: "description", + placeholder: .ddgAnnounce, + primaryActionText: "primary_action", + primaryAction: action + ), + matchingRules: [], + exclusionRules: [], + isMetricsEnabled: true + ) + } + + static func mockBigTwoAction(id: String, primaryAction: RemoteAction, secondaryAction: RemoteAction) -> RemoteMessageModel { + .init( + id: "sample_message", + content: .bigTwoAction( + titleText: "title", + descriptionText: "description", + placeholder: .ddgAnnounce, + primaryActionText: "primary_action", + primaryAction: primaryAction, + secondaryActionText: "secondary_action", + secondaryAction: secondaryAction + ), + matchingRules: [], + exclusionRules: [], + isMetricsEnabled: true + ) + } +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageActionsManagerTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageActionsManagerTests.swift index dddc979744..1c69484b92 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageActionsManagerTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageActionsManagerTests.swift @@ -30,7 +30,7 @@ final class MockNewTabPageScriptClient: NewTabPageScriptClient { } final class NewTabPageActionsManagerTests: XCTestCase { - var actionsManager: NewTabPageActionsManager! + private var actionsManager: NewTabPageActionsManager! func testThatUserScriptsReturnsAllRegisteredUserScripts() { let actionsManager = NewTabPageActionsManager(scriptClients: []) diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageConfigurationClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageConfigurationClientTests.swift index 8f5edf319b..4b61d1f2f1 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageConfigurationClientTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageConfigurationClientTests.swift @@ -22,10 +22,11 @@ import XCTest @testable import NewTabPage final class NewTabPageConfigurationClientTests: XCTestCase { - var client: NewTabPageConfigurationClient! - var sectionsVisibilityProvider: MockNewTabPageSectionsVisibilityProvider! - var contextMenuPresenter: CapturingNewTabPageContextMenuPresenter! - var userScript: NewTabPageUserScript! + private var client: NewTabPageConfigurationClient! + private var sectionsVisibilityProvider: MockNewTabPageSectionsVisibilityProvider! + private var contextMenuPresenter: CapturingNewTabPageContextMenuPresenter! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! override func setUpWithError() throws { try super.setUpWithError() @@ -33,10 +34,13 @@ final class NewTabPageConfigurationClientTests: XCTestCase { contextMenuPresenter = CapturingNewTabPageContextMenuPresenter() client = NewTabPageConfigurationClient( sectionsVisibilityProvider: sectionsVisibilityProvider, - contextMenuPresenter: contextMenuPresenter + customBackgroundProvider: CapturingNewTabPageCustomBackgroundProvider(), + contextMenuPresenter: contextMenuPresenter, + linkOpener: CapturingNewTabPageLinkOpener() ) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) } @@ -47,11 +51,11 @@ final class NewTabPageConfigurationClientTests: XCTestCase { sectionsVisibilityProvider.isFavoritesVisible = true sectionsVisibilityProvider.isPrivacyStatsVisible = false - let parameters = NewTabPageUserScript.ContextMenuParams(visibilityMenuItems: [ + let parameters = NewTabPageDataModel.ContextMenuParams(visibilityMenuItems: [ .init(id: .favorites, title: "Favorites"), .init(id: .privacyStats, title: "Privacy Stats") ]) - try await sendMessageExpectingNilResponse(named: .contextMenu, parameters: parameters) + try await messageHelper.handleMessageExpectingNilResponse(named: .contextMenu, parameters: parameters) XCTAssertEqual(contextMenuPresenter.showContextMenuCalls.count, 1) let menu = try XCTUnwrap(contextMenuPresenter.showContextMenuCalls.first) @@ -63,8 +67,8 @@ final class NewTabPageConfigurationClientTests: XCTestCase { } func testWhenContextMenuParamsIsEmptyThenContextMenuDoesNotShow() async throws { - let parameters = NewTabPageUserScript.ContextMenuParams(visibilityMenuItems: []) - try await sendMessageExpectingNilResponse(named: .contextMenu, parameters: parameters) + let parameters = NewTabPageDataModel.ContextMenuParams(visibilityMenuItems: []) + try await messageHelper.handleMessageExpectingNilResponse(named: .contextMenu, parameters: parameters) XCTAssertEqual(contextMenuPresenter.showContextMenuCalls.count, 0) } @@ -72,9 +76,10 @@ final class NewTabPageConfigurationClientTests: XCTestCase { // MARK: - initialSetup func testThatInitialSetupReturnsConfiguration() async throws { - let configuration: NewTabPageUserScript.NewTabPageConfiguration = try await sendMessage(named: .initialSetup) + let configuration: NewTabPageDataModel.NewTabPageConfiguration = try await messageHelper.handleMessage(named: .initialSetup) XCTAssertEqual(configuration.widgets, [ .init(id: .rmf), + .init(id: .freemiumPIRBanner), .init(id: .nextSteps), .init(id: .favorites), .init(id: .privacyStats) @@ -89,11 +94,11 @@ final class NewTabPageConfigurationClientTests: XCTestCase { // MARK: - widgetsSetConfig func testWhenWidgetsSetConfigIsReceivedThenWidgetConfigsAreUpdated() async throws { - let configs: [NewTabPageUserScript.NewTabPageConfiguration.WidgetConfig] = [ + let configs: [NewTabPageDataModel.NewTabPageConfiguration.WidgetConfig] = [ .init(id: .favorites, isVisible: false), .init(id: .privacyStats, isVisible: true) ] - try await sendMessageExpectingNilResponse(named: .widgetsSetConfig, parameters: configs) + try await messageHelper.handleMessageExpectingNilResponse(named: .widgetsSetConfig, parameters: configs) XCTAssertEqual(sectionsVisibilityProvider.isFavoritesVisible, false) XCTAssertEqual(sectionsVisibilityProvider.isPrivacyStatsVisible, true) } @@ -101,25 +106,11 @@ final class NewTabPageConfigurationClientTests: XCTestCase { func testWhenWidgetsSetConfigIsReceivedWithPartialConfigThenOnlyIncludedWidgetsConfigsAreUpdated() async throws { let initialIsFavoritesVisible = sectionsVisibilityProvider.isFavoritesVisible - let configs: [NewTabPageUserScript.NewTabPageConfiguration.WidgetConfig] = [ + let configs: [NewTabPageDataModel.NewTabPageConfiguration.WidgetConfig] = [ .init(id: .privacyStats, isVisible: false) ] - try await sendMessageExpectingNilResponse(named: .widgetsSetConfig, parameters: configs) + try await messageHelper.handleMessageExpectingNilResponse(named: .widgetsSetConfig, parameters: configs) XCTAssertEqual(sectionsVisibilityProvider.isFavoritesVisible, initialIsFavoritesVisible) XCTAssertEqual(sectionsVisibilityProvider.isPrivacyStatsVisible, false) } - - // MARK: - Helper functions - - func sendMessage(named methodName: NewTabPageConfigurationClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - return try XCTUnwrap(response as? Response, file: file, line: line) - } - - func sendMessageExpectingNilResponse(named methodName: NewTabPageConfigurationClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - XCTAssertNil(response, file: file, line: line) - } } diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageCustomBackgroundClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageCustomBackgroundClientTests.swift new file mode 100644 index 0000000000..960a2c156a --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageCustomBackgroundClientTests.swift @@ -0,0 +1,70 @@ +// +// NewTabPageCustomBackgroundClientTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Combine +import XCTest +@testable import NewTabPage + +final class NewTabPageCustomBackgroundClientTests: XCTestCase { + private var client: NewTabPageCustomBackgroundClient! + private var model: CapturingNewTabPageCustomBackgroundProvider! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! + + override func setUpWithError() throws { + try super.setUpWithError() + model = CapturingNewTabPageCustomBackgroundProvider() + client = NewTabPageCustomBackgroundClient(model: model) + + userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) + client.registerMessageHandlers(for: userScript) + } + + // MARK: - deleteImage + + func testThatDeleteImageCallsModel() async throws { + let deleteData = NewTabPageDataModel.DeleteImageData(id: "abcd") + try await messageHelper.handleMessageExpectingNilResponse(named: .deleteImage, parameters: deleteData) + XCTAssertEqual(model.deleteImageCalls, ["abcd"]) + } + + // MARK: - setBackground + + func testThatSetBackgroundCallsModel() async throws { + let backgroundData = NewTabPageDataModel.BackgroundData(background: .gradient("gradient01")) + try await messageHelper.handleMessageExpectingNilResponse(named: .setBackground, parameters: backgroundData) + XCTAssertEqual(model.background, .gradient("gradient01")) + } + + // MARK: - setTheme + + func testThatSetThemeCallsModel() async throws { + let themeData = NewTabPageDataModel.ThemeData(theme: .dark) + try await messageHelper.handleMessageExpectingNilResponse(named: .setTheme, parameters: themeData) + XCTAssertEqual(model.theme, .dark) + } + + // MARK: - upload + + func testThatUploadCallsModel() async throws { + try await messageHelper.handleMessageExpectingNilResponse(named: .upload) + XCTAssertEqual(model.presentUploadDialogCallsCount, 1) + } +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesClientTests.swift index d3cdba7267..b638bc2ba8 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesClientTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesClientTests.swift @@ -25,11 +25,12 @@ import XCTest final class NewTabPageFavoritesClientTests: XCTestCase { typealias NewTabPageFavoritesClientUnderTest = NewTabPageFavoritesClient - var client: NewTabPageFavoritesClientUnderTest! - var contextMenuPresenter: CapturingNewTabPageContextMenuPresenter! - var actionsHandler: CapturingNewTabPageFavoritesActionsHandler! - var favoritesModel: NewTabPageFavoritesModel! - var userScript: NewTabPageUserScript! + private var client: NewTabPageFavoritesClientUnderTest! + private var contextMenuPresenter: CapturingNewTabPageContextMenuPresenter! + private var actionsHandler: CapturingNewTabPageFavoritesActionsHandler! + private var favoritesModel: NewTabPageFavoritesModel! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! @MainActor override func setUpWithError() throws { @@ -46,13 +47,14 @@ final class NewTabPageFavoritesClientTests: XCTestCase { client = NewTabPageFavoritesClient(favoritesModel: favoritesModel, preferredFaviconSize: 100) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) } // MARK: - add func testThatAddCallsAddAction() async throws { - try await handleMessageExpectingNilResponse(named: .add) + try await messageHelper.handleMessageExpectingNilResponse(named: .add) XCTAssertEqual(actionsHandler.addNewFavoriteCallCount, 1) } @@ -60,14 +62,14 @@ final class NewTabPageFavoritesClientTests: XCTestCase { func testWhenFavoritesViewIsExpandedThenGetConfigReturnsExpandedState() async throws { favoritesModel.isViewExpanded = true - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .expanded) } func testWhenFavoritesViewIsCollapsedThenGetConfigReturnsCollapsedState() async throws { favoritesModel.isViewExpanded = false - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .collapsed) } @@ -77,14 +79,14 @@ final class NewTabPageFavoritesClientTests: XCTestCase { func testWhenSetConfigContainsExpandedStateThenFavoritesModelSettingIsSetToExpanded() async throws { favoritesModel.isViewExpanded = false let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .expanded) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(favoritesModel.isViewExpanded, true) } func testWhenSetConfigContainsCollapsedStateThenFavoritesModelSettingIsSetToCollapsed() async throws { favoritesModel.isViewExpanded = true let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .collapsed) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(favoritesModel.isViewExpanded, false) } @@ -98,7 +100,7 @@ final class NewTabPageFavoritesClientTests: XCTestCase { MockNewTabPageFavorite(id: "2", title: "D", url: "https://d.com"), MockNewTabPageFavorite(id: "3", title: "E", url: "https://e.com") ] - let data: NewTabPageFavoritesClientUnderTest.FavoritesData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.FavoritesData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data.favorites, [ .init(id: "1", title: "A", url: "https://a.com", favicon: .init(maxAvailableSize: 100, src: "duck://favicon/https%3A//a.com")), .init(id: "10", title: "B", url: "https://b.com", favicon: .init(maxAvailableSize: 100, src: "duck://favicon/https%3A//b.com")), @@ -110,35 +112,35 @@ final class NewTabPageFavoritesClientTests: XCTestCase { func testWhenFavoritesAreEmptyThenGetDataReturnsNoFavorites() async throws { favoritesModel.favorites = [] - let data: NewTabPageFavoritesClientUnderTest.FavoritesData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.FavoritesData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data.favorites, []) } // MARK: - move func testThatMoveActionIsForwardedToTheModel() async throws { - let action = NewTabPageFavoritesClientUnderTest.FavoritesMoveAction(id: "abcd", fromIndex: 10, targetIndex: 4) - try await handleMessageExpectingNilResponse(named: .move, parameters: action) + let action = NewTabPageDataModel.FavoritesMoveAction(id: "abcd", fromIndex: 10, targetIndex: 4) + try await messageHelper.handleMessageExpectingNilResponse(named: .move, parameters: action) XCTAssertEqual(actionsHandler.moveCalls, [.init("abcd", 4)]) } func testThatWhenFavoriteIsMovedToHigherIndexThenModelIncrementsIndex() async throws { - let action = NewTabPageFavoritesClientUnderTest.FavoritesMoveAction(id: "abcd", fromIndex: 1, targetIndex: 4) - try await handleMessageExpectingNilResponse(named: .move, parameters: action) + let action = NewTabPageDataModel.FavoritesMoveAction(id: "abcd", fromIndex: 1, targetIndex: 4) + try await messageHelper.handleMessageExpectingNilResponse(named: .move, parameters: action) XCTAssertEqual(actionsHandler.moveCalls, [.init("abcd", 5)]) } // MARK: - open func testThatOpenActionIsForwardedToTheModel() async throws { - let action = NewTabPageFavoritesClientUnderTest.FavoritesOpenAction(id: "abcd", url: "https://example.com") - try await handleMessageExpectingNilResponse(named: .open, parameters: action) + let action = NewTabPageDataModel.FavoritesOpenAction(id: "abcd", url: "https://example.com") + try await messageHelper.handleMessageExpectingNilResponse(named: .open, parameters: action) XCTAssertEqual(actionsHandler.openCalls, [.init(URL(string: "https://example.com")!, .current)]) } func testWhenURLIsInvalidThenOpenActionIsNotForwardedToTheModel() async throws { - let action = NewTabPageFavoritesClientUnderTest.FavoritesOpenAction(id: "abcd", url: "abcd") - try await handleMessageExpectingNilResponse(named: .open, parameters: action) + let action = NewTabPageDataModel.FavoritesOpenAction(id: "abcd", url: "abcd") + try await messageHelper.handleMessageExpectingNilResponse(named: .open, parameters: action) XCTAssertEqual(actionsHandler.openCalls, []) } @@ -146,29 +148,15 @@ final class NewTabPageFavoritesClientTests: XCTestCase { func testThatOpenContextMenuActionForExistingFavoriteIsForwardedToTheModel() async throws { favoritesModel.favorites = [.init(id: "abcd", title: "A", url: "https://example.com")] - let action = NewTabPageFavoritesClientUnderTest.FavoritesContextMenuAction(id: "abcd") - try await handleMessageExpectingNilResponse(named: .openContextMenu, parameters: action) + let action = NewTabPageDataModel.FavoritesContextMenuAction(id: "abcd") + try await messageHelper.handleMessageExpectingNilResponse(named: .openContextMenu, parameters: action) XCTAssertEqual(contextMenuPresenter.showContextMenuCalls.count, 1) } func testThatOpenContextMenuActionForNotExistingFavoriteIsNotForwardedToTheModel() async throws { favoritesModel.favorites = [] - let action = NewTabPageFavoritesClientUnderTest.FavoritesContextMenuAction(id: "abcd") - try await handleMessageExpectingNilResponse(named: .openContextMenu, parameters: action) + let action = NewTabPageDataModel.FavoritesContextMenuAction(id: "abcd") + try await messageHelper.handleMessageExpectingNilResponse(named: .openContextMenu, parameters: action) XCTAssertEqual(contextMenuPresenter.showContextMenuCalls.count, 0) } - - // MARK: - Helper functions - - func handleMessage(named methodName: NewTabPageFavoritesClientUnderTest.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - return try XCTUnwrap(response as? Response, file: file, line: line) - } - - func handleMessageExpectingNilResponse(named methodName: NewTabPageFavoritesClientUnderTest.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - XCTAssertNil(response, file: file, line: line) - } } diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesModelTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesModelTests.swift index 7785c1928d..7b30d97f9d 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesModelTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFavoritesModelTests.swift @@ -23,9 +23,9 @@ import XCTest final class NewTabPageFavoritesModelTests: XCTestCase { - var model: NewTabPageFavoritesModel! - var settingsPersistor: UserDefaultsNewTabPageFavoritesSettingsPersistor! - var favoritesSubject: PassthroughSubject<[MockNewTabPageFavorite], Never>! + private var model: NewTabPageFavoritesModel! + private var settingsPersistor: UserDefaultsNewTabPageFavoritesSettingsPersistor! + private var favoritesSubject: PassthroughSubject<[MockNewTabPageFavorite], Never>! override func setUp() async throws { try await super.setUp() diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFreemiumDBPClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFreemiumDBPClientTests.swift new file mode 100644 index 0000000000..610ee9932f --- /dev/null +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageFreemiumDBPClientTests.swift @@ -0,0 +1,66 @@ +// +// NewTabPageFreemiumDBPClientTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import RemoteMessaging +import XCTest +@testable import NewTabPage + +final class NewTabPageFreemiumDBPClientTests: XCTestCase { + private var client: NewTabPageFreemiumDBPClient! + private var provider: CapturingNewTabPageFreemiumDBPBannerProvider! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! + + override func setUpWithError() throws { + try super.setUpWithError() + provider = CapturingNewTabPageFreemiumDBPBannerProvider() + client = NewTabPageFreemiumDBPClient(provider: provider) + userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) + client.registerMessageHandlers(for: userScript) + } + + // MARK: - getData + + func testWhenMessageIsNilThenGetDataReturnsNilMessage() async throws { + let messageData: NewTabPageDataModel.FreemiumPIRBannerMessageData = try await messageHelper.handleMessage(named: .getData) + XCTAssertNil(messageData.content) + } + + func testThatGetDataReturnsMessageIfPresent() async throws { + provider.bannerMessage = .init(titleText: "sample_title", descriptionText: "sample_description", actionText: "sample_action") + let messageData: NewTabPageDataModel.FreemiumPIRBannerMessageData = try await messageHelper.handleMessage(named: .getData) + let message = try XCTUnwrap(messageData.content) + XCTAssertEqual(message, .init(titleText: "sample_title", descriptionText: "sample_description", actionText: "sample_action")) + } + + // MARK: - dismiss + + func testThatDismissIsForwardedToProvider() async throws { + try await messageHelper.handleMessageExpectingNilResponse(named: .dismiss) + XCTAssertEqual(provider.dismissCallCount, 1) + } + + // MARK: - action + + func testThatActionIsForwardedToProvider() async throws { + try await messageHelper.handleMessageExpectingNilResponse(named: .action) + XCTAssertEqual(provider.actionCallCount, 1) + } +} diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageNextStepsCardsClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageNextStepsCardsClientTests.swift index 82acec3fc9..d863006a09 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageNextStepsCardsClientTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageNextStepsCardsClientTests.swift @@ -22,9 +22,10 @@ import XCTest @testable import NewTabPage final class NewTabPageNextStepsCardsClientTests: XCTestCase { - var client: NewTabPageNextStepsCardsClient! - var model: CapturingNewTabPageNextStepsCardsProvider! - var userScript: NewTabPageUserScript! + private var client: NewTabPageNextStepsCardsClient! + private var model: CapturingNewTabPageNextStepsCardsProvider! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! @MainActor override func setUpWithError() throws { @@ -33,24 +34,25 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { client = NewTabPageNextStepsCardsClient(model: model) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) } // MARK: - action func testThatActionCallsHandleAction() async throws { - try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .defaultApp)) - try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .duckplayer)) - try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .bringStuff)) + try await messageHelper.handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageDataModel.Card(id: .defaultApp)) + try await messageHelper.handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageDataModel.Card(id: .duckplayer)) + try await messageHelper.handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageDataModel.Card(id: .bringStuff)) XCTAssertEqual(model.handleActionCalls, [.defaultApp, .duckplayer, .bringStuff]) } // MARK: - dismiss func testThatDismissCallsDismissHandler() async throws { - try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .defaultApp)) - try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .duckplayer)) - try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .bringStuff)) + try await messageHelper.handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageDataModel.Card(id: .defaultApp)) + try await messageHelper.handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageDataModel.Card(id: .duckplayer)) + try await messageHelper.handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageDataModel.Card(id: .bringStuff)) XCTAssertEqual(model.dismissCalls, [.defaultApp, .duckplayer, .bringStuff]) } @@ -58,14 +60,14 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { func testWhenNextStepsViewIsExpandedThenGetConfigReturnsExpandedState() async throws { model.isViewExpanded = true - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .expanded) } func testWhenNextStepsViewIsCollapsedThenGetConfigReturnsCollapsedState() async throws { model.isViewExpanded = false - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .collapsed) } @@ -75,14 +77,14 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { func testWhenSetConfigContainsExpandedStateThenModelSettingIsSetToExpanded() async throws { model.isViewExpanded = false let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .expanded) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(model.isViewExpanded, true) } func testWhenSetConfigContainsCollapsedStateThenModelSettingIsSetToCollapsed() async throws { model.isViewExpanded = true let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .collapsed) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(model.isViewExpanded, false) } @@ -94,7 +96,7 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { .duckplayer, .bringStuff ] - let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.NextStepsData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data, .init(content: [ .init(id: .addAppToDockMac), .init(id: .duckplayer), @@ -104,7 +106,7 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { func testWhenCardsAreEmptyThenGetDataReturnsNilContent() async throws { model.cards = [] - let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.NextStepsData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data, .init(content: nil)) } @@ -114,8 +116,8 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { model.cards = [.addAppToDockMac, .duckplayer] try await performAndWaitForWillDisplayCards { - try await handleMessageIgnoringResponse(named: .getData) - try await handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) } XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) @@ -125,17 +127,17 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { model.cards = [.addAppToDockMac, .duckplayer] try await performAndWaitForWillDisplayCards(count: 0, timeout: 0.1) { - try await handleMessageIgnoringResponse(named: .getData) - try await handleMessageIgnoringResponse(named: .getData) - try await handleMessageIgnoringResponse(named: .getData) - try await handleMessageIgnoringResponse(named: .getData) - try await handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) } XCTAssertEqual(model.willDisplayCardsCalls, []) try await performAndWaitForWillDisplayCards { - try await handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) } XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) @@ -145,17 +147,17 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { model.cards = [.addAppToDockMac, .duckplayer] try await performAndWaitForWillDisplayCards(count: 0, timeout: 0.1) { - try await handleMessageIgnoringResponse(named: .getConfig) - try await handleMessageIgnoringResponse(named: .getConfig) - try await handleMessageIgnoringResponse(named: .getConfig) - try await handleMessageIgnoringResponse(named: .getConfig) - try await handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) } XCTAssertEqual(model.willDisplayCardsCalls, []) try await performAndWaitForWillDisplayCards { - try await handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) } XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) @@ -248,8 +250,8 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { func triggerInitialCardsEventAndResetMockState() async throws { try await performAndWaitForWillDisplayCards { - try await handleMessageIgnoringResponse(named: .getConfig) - try await handleMessageIgnoringResponse(named: .getData) + try await messageHelper.handleMessageIgnoringResponse(named: .getConfig) + try await messageHelper.handleMessageIgnoringResponse(named: .getData) } model.willDisplayCardsCalls = [] } @@ -271,21 +273,4 @@ final class NewTabPageNextStepsCardsClientTests: XCTestCase { model.willDisplayCardsImpl = originalImpl } - - func handleMessage(named methodName: NewTabPageNextStepsCardsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - return try XCTUnwrap(response as? Response, file: file, line: line) - } - - func handleMessageIgnoringResponse(named methodName: NewTabPageNextStepsCardsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - } - - func handleMessageExpectingNilResponse(named methodName: NewTabPageNextStepsCardsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - XCTAssertNil(response, file: file, line: line) - } } diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsClientTests.swift index 3202f76922..767fc19c4e 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsClientTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsClientTests.swift @@ -24,15 +24,16 @@ import XCTest @testable import NewTabPage final class NewTabPagePrivacyStatsClientTests: XCTestCase { - var client: NewTabPagePrivacyStatsClient! - var model: NewTabPagePrivacyStatsModel! + private var client: NewTabPagePrivacyStatsClient! + private var model: NewTabPagePrivacyStatsModel! - var privacyStats: CapturingPrivacyStats! - var trackerDataProvider: MockPrivacyStatsTrackerDataProvider! - var eventMapping: CapturingNewTabPagePrivacyStatsEventHandler! - var settingsPersistor: UserDefaultsNewTabPagePrivacyStatsSettingsPersistor! + private var privacyStats: CapturingPrivacyStats! + private var trackerDataProvider: MockPrivacyStatsTrackerDataProvider! + private var eventMapping: CapturingNewTabPagePrivacyStatsEventHandler! + private var settingsPersistor: UserDefaultsNewTabPagePrivacyStatsSettingsPersistor! - var userScript: NewTabPageUserScript! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! override func setUp() async throws { try await super.setUp() @@ -52,6 +53,7 @@ final class NewTabPagePrivacyStatsClientTests: XCTestCase { client = NewTabPagePrivacyStatsClient(model: model) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) } @@ -59,14 +61,14 @@ final class NewTabPagePrivacyStatsClientTests: XCTestCase { func testWhenPrivacyStatsViewIsExpandedThenGetConfigReturnsExpandedState() async throws { model.isViewExpanded = true - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .expanded) } func testWhenPrivacyStatsViewIsCollapsedThenGetConfigReturnsCollapsedState() async throws { model.isViewExpanded = false - let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + let config: NewTabPageUserScript.WidgetConfig = try await messageHelper.handleMessage(named: .getConfig) XCTAssertEqual(config.animation, .auto) XCTAssertEqual(config.expansion, .collapsed) } @@ -76,14 +78,14 @@ final class NewTabPagePrivacyStatsClientTests: XCTestCase { func testWhenSetConfigContainsExpandedStateThenModelSettingIsSetToExpanded() async throws { model.isViewExpanded = false let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .expanded) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(model.isViewExpanded, true) } func testWhenSetConfigContainsCollapsedStateThenModelSettingIsSetToCollapsed() async throws { model.isViewExpanded = true let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .collapsed) - try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + try await messageHelper.handleMessageExpectingNilResponse(named: .setConfig, parameters: config) XCTAssertEqual(model.isViewExpanded, false) } @@ -110,9 +112,10 @@ final class NewTabPagePrivacyStatsClientTests: XCTestCase { client = NewTabPagePrivacyStatsClient(model: model) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) - let data: NewTabPagePrivacyStatsClient.PrivacyStatsData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.PrivacyStatsData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data, .init(totalCount: 2510, trackerCompanies: [ .init(count: 1, displayName: "A"), .init(count: 2, displayName: "B"), @@ -123,35 +126,21 @@ final class NewTabPagePrivacyStatsClientTests: XCTestCase { } func testWhenPrivacyStatsAreEmptyThenGetDataReturnsEmptyArray() async throws { - let data: NewTabPagePrivacyStatsClient.PrivacyStatsData = try await handleMessage(named: .getData) + let data: NewTabPageDataModel.PrivacyStatsData = try await messageHelper.handleMessage(named: .getData) XCTAssertEqual(data, .init(totalCount: 0, trackerCompanies: [])) } // MARK: - showLess func testThatShowLessIsPassedToTheModelAndToTheEventMapping() async throws { - try await handleMessageExpectingNilResponse(named: .showLess) + try await messageHelper.handleMessageExpectingNilResponse(named: .showLess) XCTAssertEqual(eventMapping.events, [.showLess]) } // MARK: - showMore func testThatShowMoreIsPassedToTheModelAndToTheEventMapping() async throws { - try await handleMessageExpectingNilResponse(named: .showMore) + try await messageHelper.handleMessageExpectingNilResponse(named: .showMore) XCTAssertEqual(eventMapping.events, [.showMore]) } - - // MARK: - Helper functions - - func handleMessage(named methodName: NewTabPagePrivacyStatsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - return try XCTUnwrap(response as? Response, file: file, line: line) - } - - func handleMessageExpectingNilResponse(named methodName: NewTabPagePrivacyStatsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - XCTAssertNil(response, file: file, line: line) - } } diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsModelTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsModelTests.swift index fc4d46e4db..b7173cddce 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsModelTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPagePrivacyStatsModelTests.swift @@ -67,10 +67,10 @@ final class MockPrivacyStatsTrackerDataProvider: PrivacyStatsTrackerDataProvidin final class NewTabPagePrivacyStatsModelTests: XCTestCase { - var model: NewTabPagePrivacyStatsModel! - var privacyStats: CapturingPrivacyStats! - var trackerDataProvider: MockPrivacyStatsTrackerDataProvider! - var settingsPersistor: UserDefaultsNewTabPagePrivacyStatsSettingsPersistor! + private var model: NewTabPagePrivacyStatsModel! + private var privacyStats: CapturingPrivacyStats! + private var trackerDataProvider: MockPrivacyStatsTrackerDataProvider! + private var settingsPersistor: UserDefaultsNewTabPagePrivacyStatsSettingsPersistor! override func setUp() async throws { try await super.setUp() @@ -113,7 +113,7 @@ final class NewTabPagePrivacyStatsModelTests: XCTestCase { privacyStats.privacyStats = ["A": 1, "B": 2, "C": 3, "D": 4, "E": 1500, "F": 100, "G": 900] - let stats: NewTabPagePrivacyStatsClient.PrivacyStatsData = await model.calculatePrivacyStats() + let stats: NewTabPageDataModel.PrivacyStatsData = await model.calculatePrivacyStats() XCTAssertEqual(stats, .init(totalCount: 2510, trackerCompanies: [ .init(count: 1, displayName: "A"), @@ -143,7 +143,7 @@ final class NewTabPagePrivacyStatsModelTests: XCTestCase { privacyStats.privacyStats = ["A": 1, "B": 2, "C": 3, "D": 4, "E": 1500, "F": 100, "G": 900] - let stats: NewTabPagePrivacyStatsClient.PrivacyStatsData = await model.calculatePrivacyStats() + let stats: NewTabPageDataModel.PrivacyStatsData = await model.calculatePrivacyStats() XCTAssertEqual(stats, .init(totalCount: 2510, trackerCompanies: [ .init(count: 1, displayName: "A"), diff --git a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageRMFClientTests.swift b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageRMFClientTests.swift index 53b6c23ba2..5fb46a8f69 100644 --- a/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageRMFClientTests.swift +++ b/LocalPackages/NewTabPage/Tests/NewTabPageTests/NewTabPageRMFClientTests.swift @@ -22,20 +22,22 @@ import XCTest @testable import NewTabPage final class NewTabPageRMFClientTests: XCTestCase { - var client: NewTabPageRMFClient! - var remoteMessageProvider: CapturingNewTabPageActiveRemoteMessageProvider! - var userScript: NewTabPageUserScript! + private var client: NewTabPageRMFClient! + private var remoteMessageProvider: CapturingNewTabPageActiveRemoteMessageProvider! + private var userScript: NewTabPageUserScript! + private var messageHelper: MessageHelper! override func setUpWithError() throws { try super.setUpWithError() remoteMessageProvider = CapturingNewTabPageActiveRemoteMessageProvider() client = NewTabPageRMFClient(remoteMessageProvider: remoteMessageProvider) userScript = NewTabPageUserScript() + messageHelper = .init(userScript: userScript) client.registerMessageHandlers(for: userScript) } func testWhenMessageIsNilThenGetDataReturnsNilMessage() async throws { - let rmfData: NewTabPageUserScript.RMFData = try await sendMessage(named: .rmfGetData) + let rmfData: NewTabPageDataModel.RMFData = try await messageHelper.handleMessage(named: .rmfGetData) XCTAssertNil(rmfData.content) } @@ -43,21 +45,21 @@ final class NewTabPageRMFClientTests: XCTestCase { func testThatGetDataReturnsSmallMessageIfPresent() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let rmfData: NewTabPageUserScript.RMFData = try await sendMessage(named: .rmfGetData) + let rmfData: NewTabPageDataModel.RMFData = try await messageHelper.handleMessage(named: .rmfGetData) let message = try XCTUnwrap(rmfData.content) XCTAssertEqual(message, .small(.init(id: "sample_message", titleText: "title", descriptionText: "description"))) } func testThatGetDataReturnsMediumMessageIfPresent() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockMedium(id: "sample_message") - let rmfData: NewTabPageUserScript.RMFData = try await sendMessage(named: .rmfGetData) + let rmfData: NewTabPageDataModel.RMFData = try await messageHelper.handleMessage(named: .rmfGetData) let message = try XCTUnwrap(rmfData.content) XCTAssertEqual(message, .medium(.init(id: "sample_message", titleText: "title", descriptionText: "description", icon: .criticalUpdate))) } func testThatGetDataReturnsBigSingleActionMessageIfPresent() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigSingleAction(id: "sample_message", action: .appStore) - let rmfData: NewTabPageUserScript.RMFData = try await sendMessage(named: .rmfGetData) + let rmfData: NewTabPageDataModel.RMFData = try await messageHelper.handleMessage(named: .rmfGetData) let message = try XCTUnwrap(rmfData.content) XCTAssertEqual(message, .bigSingleAction( .init( @@ -72,7 +74,7 @@ final class NewTabPageRMFClientTests: XCTestCase { func testThatGetDataReturnsBigTwoActionMessageIfPresent() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigTwoAction(id: "sample_message", primaryAction: .appStore, secondaryAction: .dismiss) - let rmfData: NewTabPageUserScript.RMFData = try await sendMessage(named: .rmfGetData) + let rmfData: NewTabPageDataModel.RMFData = try await messageHelper.handleMessage(named: .rmfGetData) let message = try XCTUnwrap(rmfData.content) XCTAssertEqual(message, .bigTwoAction( .init( @@ -91,16 +93,16 @@ final class NewTabPageRMFClientTests: XCTestCase { func testThatDismissSendsDismissActionToProvider() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfDismiss, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfDismiss, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, [.init(action: nil, button: .close)]) } func testWhenMessageIdDoesNotMatchThenDismissHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "different_sample_message") - try await sendMessageExpectingNilResponse(named: .rmfDismiss, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "different_sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfDismiss, parameters: parameters) XCTAssertTrue(remoteMessageProvider.dismissCalls.isEmpty) } @@ -109,32 +111,32 @@ final class NewTabPageRMFClientTests: XCTestCase { func testWhenSingleActionMessageThenPrimaryActionSendsActionToProvider() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigSingleAction(id: "sample_message", action: .appStore) - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, [.init(action: .appStore, button: .action)]) } func testWhenTwoActionMessageThenPrimaryActionSendsPrimaryActionToProvider() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigTwoAction(id: "sample_message", primaryAction: .appStore, secondaryAction: .dismiss) - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, [.init(action: .appStore, button: .primaryAction)]) } func testWhenMessageHasNoButtonThenPrimaryActionHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, []) } func testWhenMessageIdDoesNotMatchThenPrimaryActionHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "different_sample_message") - try await sendMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "different_sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfPrimaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, []) } @@ -143,102 +145,32 @@ final class NewTabPageRMFClientTests: XCTestCase { func testWhenTwoActionMessageThenSecondaryActionSendsSecondaryActionToProvider() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigTwoAction(id: "sample_message", primaryAction: .dismiss, secondaryAction: .appStore) - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, [.init(action: .appStore, button: .secondaryAction)]) } func testWhenSingleActionMessageThenSecondaryActionHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockBigSingleAction(id: "sample_message", action: .appStore) - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, []) } func testWhenMessageHasNoButtonThenSecondaryActionHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "sample_message") - try await sendMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, []) } func testWhenMessageIdDoesNotMatchThenSecondaryActionHasNoEffect() async throws { remoteMessageProvider.newTabPageRemoteMessage = .mockSmall(id: "sample_message") - let parameters = NewTabPageUserScript.RemoteMessageParams(id: "different_sample_message") - try await sendMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) + let parameters = NewTabPageDataModel.RemoteMessageParams(id: "different_sample_message") + try await messageHelper.handleMessageExpectingNilResponse(named: .rmfSecondaryAction, parameters: parameters) XCTAssertEqual(remoteMessageProvider.dismissCalls, []) } - - // MARK: - Helper functions - - func sendMessage(named methodName: NewTabPageRMFClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - return try XCTUnwrap(response as? Response, file: file, line: line) - } - - func sendMessageExpectingNilResponse(named methodName: NewTabPageRMFClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { - let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) - let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) - XCTAssertNil(response, file: file, line: line) - } -} - -fileprivate extension RemoteMessageModel { - static func mockSmall(id: String) -> RemoteMessageModel { - .init( - id: id, - content: .small(titleText: "title", descriptionText: "description"), - matchingRules: [], - exclusionRules: [], - isMetricsEnabled: true - ) - } - - static func mockMedium(id: String) -> RemoteMessageModel { - .init( - id: "sample_message", - content: .medium(titleText: "title", descriptionText: "description", placeholder: .criticalUpdate), - matchingRules: [], - exclusionRules: [], - isMetricsEnabled: true - ) - } - - static func mockBigSingleAction(id: String, action: RemoteAction) -> RemoteMessageModel { - .init( - id: "sample_message", - content: .bigSingleAction( - titleText: "title", - descriptionText: "description", - placeholder: .ddgAnnounce, - primaryActionText: "primary_action", - primaryAction: action - ), - matchingRules: [], - exclusionRules: [], - isMetricsEnabled: true - ) - } - - static func mockBigTwoAction(id: String, primaryAction: RemoteAction, secondaryAction: RemoteAction) -> RemoteMessageModel { - .init( - id: "sample_message", - content: .bigTwoAction( - titleText: "title", - descriptionText: "description", - placeholder: .ddgAnnounce, - primaryActionText: "primary_action", - primaryAction: primaryAction, - secondaryActionText: "secondary_action", - secondaryAction: secondaryAction - ), - matchingRules: [], - exclusionRules: [], - isMetricsEnabled: true - ) - } } diff --git a/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift b/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift index fca8c31aec..2764ed2857 100644 --- a/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift +++ b/UnitTests/Freemium/DBP/FreemiumDBPPromotionViewCoordinatorTests.swift @@ -33,7 +33,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { private var mockPixelHandler: MockFreemiumDBPExperimentPixelHandler! private var cancellables: Set = [] - @MainActor override func setUpWithError() throws { mockUserStateManager = MockFreemiumDBPUserStateManager() mockFeature = MockFreemiumDBPFeature() @@ -57,7 +56,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { mockPresenter = nil } - @MainActor func testInitialPromotionVisibility_whenFeatureIsAvailable_andNotDismissed() { // Given mockUserStateManager.didDismissHomePagePromotion = false @@ -74,7 +72,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertTrue(sut.isHomePagePromotionVisible) } - @MainActor func testInitialPromotionVisibility_whenPromotionDismissed() { // Given mockUserStateManager.didDismissHomePagePromotion = true @@ -91,14 +88,16 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertFalse(sut.isHomePagePromotionVisible) } - @MainActor - func testProceedAction_dismissesPromotion_callsShowFreemium_andFiresPixel() { + func testProceedAction_dismissesPromotion_callsShowFreemium_andFiresPixel() async throws { // Given - mockUserStateManager.didActivate = false + try await waitForViewModelUpdate { + mockUserStateManager.didActivate = false + sut.isHomePagePromotionVisible = true + } // When - let viewModel = sut.viewModel - viewModel!.proceedAction() + let viewModel = try XCTUnwrap(sut.viewModel) + await viewModel.proceedAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) @@ -106,26 +105,27 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabScanClick) } - @MainActor - func testCloseAction_dismissesPromotion_andFiresPixel() { + func testCloseAction_dismissesPromotion_andFiresPixel() async throws { // When - let viewModel = sut.viewModel - viewModel!.closeAction() + try await waitForViewModelUpdate() + let viewModel = try XCTUnwrap(sut.viewModel) + viewModel.closeAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabScanDismiss) } - @MainActor - func testProceedAction_dismissesResults_callsShowFreemium_andFiresPixel() { + func testProceedAction_dismissesResults_callsShowFreemium_andFiresPixel() async throws { // Given - mockUserStateManager.didActivate = false - mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + try await waitForViewModelUpdate { + mockUserStateManager.didActivate = false + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + } // When - let viewModel = sut.viewModel - viewModel!.proceedAction() + let viewModel = try XCTUnwrap(sut.viewModel) + await viewModel.proceedAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) @@ -133,29 +133,31 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabResultsClick) } - @MainActor - func testCloseAction_dismissesResults_andFiresPixel() { + func testCloseAction_dismissesResults_andFiresPixel() async throws { // Given - mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + try await waitForViewModelUpdate { + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + } // When - let viewModel = sut.viewModel - viewModel!.closeAction() + let viewModel = try XCTUnwrap(sut.viewModel) + viewModel.closeAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabResultsDismiss) } - @MainActor - func testProceedAction_dismissesNoResults_callsShowFreemium_andFiresPixel() { + func testProceedAction_dismissesNoResults_callsShowFreemium_andFiresPixel() async throws { // Given - mockUserStateManager.didActivate = false - mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + try await waitForViewModelUpdate { + mockUserStateManager.didActivate = false + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + } // When - let viewModel = sut.viewModel - viewModel!.proceedAction() + let viewModel = try XCTUnwrap(sut.viewModel) + await viewModel.proceedAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) @@ -163,48 +165,49 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabNoResultsClick) } - @MainActor - func testCloseAction_dismissesNoResults_andFiresPixel() { + func testCloseAction_dismissesNoResults_andFiresPixel() async throws { // Given - mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + try await waitForViewModelUpdate { + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 0, brokerCount: 0) + } // When - let viewModel = sut.viewModel - viewModel!.closeAction() + let viewModel = try XCTUnwrap(sut.viewModel) + viewModel.closeAction() // Then XCTAssertTrue(mockUserStateManager.didDismissHomePagePromotion) XCTAssertEqual(mockPixelHandler.lastFiredEvent, FreemiumDBPExperimentPixel.newTabNoResultsDismiss) } - @MainActor - func testViewModel_whenResultsExist_withMatches() { + func testViewModel_whenResultsExist_withMatches() async throws { // Given - mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + try await waitForViewModelUpdate { + mockUserStateManager.firstScanResults = FreemiumDBPMatchResults(matchesCount: 5, brokerCount: 2) + } // When - let viewModel = sut.viewModel + let viewModel = try await waitForViewModelUpdate() // Then - XCTAssertEqual(viewModel!.description, UserText.homePagePromotionFreemiumDBPPostScanEngagementResultPluralDescription(resultCount: 5, brokerCount: 2)) + XCTAssertEqual(viewModel?.description, UserText.homePagePromotionFreemiumDBPPostScanEngagementResultPluralDescription(resultCount: 5, brokerCount: 2)) } - @MainActor - func testViewModel_whenNoResultsExist() { + func testViewModel_whenNoResultsExist() async throws { // Given - mockUserStateManager.firstScanResults = nil - - // When - let viewModel = sut.viewModel + let viewModel = try await waitForViewModelUpdate { + mockUserStateManager.firstScanResults = nil + } // Then - XCTAssertEqual(viewModel!.description, UserText.homePagePromotionFreemiumDBPDescriptionMarkdown) + XCTAssertEqual(viewModel?.description, UserText.homePagePromotionFreemiumDBPDescriptionMarkdown) } - @MainActor - func testViewModel_whenFeatureNotEnabled() { + func testViewModel_whenFeatureNotEnabled() async throws { // Given - mockFeature.featureAvailable = false + try await waitForViewModelUpdate { + mockFeature.featureAvailable = false + } // When let viewModel = sut.viewModel @@ -227,7 +230,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertFalse(mockUserStateManager.didDismissHomePagePromotion) } - @MainActor func testHomePageBecomesVisible_whenFeatureBecomesAvailable_andDidDismissFalse() { // Given mockUserStateManager.didDismissHomePagePromotion = false @@ -257,7 +259,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertTrue(sut.isHomePagePromotionVisible) } - @MainActor func testHomePageBecomesInVisible_whenFeatureBecomesUnAvailable_andDidDismissFalse() { // Given mockUserStateManager.didDismissHomePagePromotion = false @@ -287,7 +288,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertFalse(sut.isHomePagePromotionVisible) } - @MainActor func testHomePageDoesNotBecomeVisible_whenFeatureBecomesAvailable_andDidDismissTrue() { // Given mockUserStateManager.didDismissHomePagePromotion = true @@ -317,7 +317,6 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { XCTAssertFalse(sut.isHomePagePromotionVisible) } - @MainActor func testHomePageDoesNotBecomeVisible_whenFeatureBecomesUnAvailable_andDidDismissTrue() { // Given mockUserStateManager.didDismissHomePagePromotion = true @@ -346,6 +345,27 @@ final class FreemiumDBPPromotionViewCoordinatorTests: XCTestCase { // Then XCTAssertFalse(sut.isHomePagePromotionVisible) } + + // MARK: - Helpers + + /** + * Sets up an expectation, then sets up Combine subscription for `sut.$viewModel` that fulfills the expectation, + * then calls the provided `block`, enables home page promotion and waits for time specified by `duration` + * before cancelling the subscription. + */ + @discardableResult + private func waitForViewModelUpdate(for duration: TimeInterval = 1, _ block: () async -> Void = {}) async throws -> PromotionViewModel? { + let expectation = self.expectation(description: "viewModelUpdate") + let cancellable = sut.$viewModel.dropFirst().prefix(1).sink { _ in expectation.fulfill() } + + await block() + sut.isHomePagePromotionVisible = true + + await fulfillment(of: [expectation], timeout: duration) + cancellable.cancel() + + return sut.viewModel + } } class MockFreemiumDBPExperimentPixelHandler: EventMapping { diff --git a/UnitTests/HomePage/CustomBackgroundTests.swift b/UnitTests/HomePage/CustomBackgroundTests.swift index e5c063bc3d..b687503b28 100644 --- a/UnitTests/HomePage/CustomBackgroundTests.swift +++ b/UnitTests/HomePage/CustomBackgroundTests.swift @@ -62,6 +62,7 @@ final class CustomBackgroundTests: XCTestCase { func testDescriptionInitializer() { XCTAssertEqual(CustomBackground("gradient|gradient03"), .gradient(.gradient03)) + XCTAssertEqual(CustomBackground("gradient|gradient02.01"), .gradient(.gradient0201)) XCTAssertEqual(CustomBackground("solidColor|color02"), .solidColor(.color02)) XCTAssertEqual(CustomBackground("solidColor|#FEFC4B"), .solidColor(.init(color: NSColor(hex: "#FEFC4B")!))) diff --git a/UnitTests/Menus/MoreOptionsMenuTests.swift b/UnitTests/Menus/MoreOptionsMenuTests.swift index adb281daca..c5487ae8b2 100644 --- a/UnitTests/Menus/MoreOptionsMenuTests.swift +++ b/UnitTests/Menus/MoreOptionsMenuTests.swift @@ -357,7 +357,11 @@ final class NetworkProtectionVisibilityMock: VPNFeatureGatekeeper { } final class MockFreemiumDBPFeature: FreemiumDBPFeature { - var featureAvailable = false + var featureAvailable = false { + didSet { + isAvailableSubject.send(featureAvailable) + } + } var isAvailableSubject = PassthroughSubject() var isAvailable: Bool { diff --git a/UnitTests/NewTabPage/NewTabPageCustomizationProviderTests.swift b/UnitTests/NewTabPage/NewTabPageCustomizationProviderTests.swift new file mode 100644 index 0000000000..2fa49beae9 --- /dev/null +++ b/UnitTests/NewTabPage/NewTabPageCustomizationProviderTests.swift @@ -0,0 +1,268 @@ +// +// NewTabPageCustomizationProviderTests.swift +// +// Copyright © 2025 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKitExtensions +import Combine +import NewTabPage +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class NewTabPageCustomizationProviderTests: XCTestCase { + var storageLocation: URL! + var appearancePreferences: AppearancePreferences! + var userColorProvider: MockUserColorProvider! + var userBackgroundImagesManager: CapturingUserBackgroundImagesManager! + var openFilePanelCalls: Int = 0 + private var settingsModel: HomePage.Models.SettingsModel! + private var provider: NewTabPageCustomizationProvider! + + @MainActor + override func setUp() async throws { + + appearancePreferences = AppearancePreferences(persistor: MockAppearancePreferencesPersistor()) + storageLocation = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + userBackgroundImagesManager = CapturingUserBackgroundImagesManager(storageLocation: storageLocation, maximumNumberOfImages: 4) + userColorProvider = MockUserColorProvider() + openFilePanelCalls = 0 + + settingsModel = HomePage.Models.SettingsModel( + appearancePreferences: appearancePreferences, + userBackgroundImagesManager: userBackgroundImagesManager, + sendPixel: { _ in }, + openFilePanel: { + self.openFilePanelCalls += 1 + return nil + }, + userColorProvider: self.userColorProvider, + showAddImageFailedAlert: {}, + navigator: MockHomePageSettingsModelNavigator() + ) + + provider = NewTabPageCustomizationProvider(homePageSettingsModel: settingsModel, appearancePreferences: appearancePreferences) + } + + override func tearDown() async throws { + try? FileManager.default.removeItem(at: storageLocation) + } + + func testThatCustomizerOpenerReturnsSettingsModelCustomizerOpener() { + XCTAssertIdentical(provider.customizerOpener, settingsModel.customizerOpener) + } + + func testThatBackgroundGetterReturnsSettingsModelBackground() throws { + settingsModel.customBackground = .gradient(.gradient01) + XCTAssertEqual(provider.background, .gradient("gradient01")) + + settingsModel.customBackground = .solidColor(.color02) + XCTAssertEqual(provider.background, .solidColor("color02")) + + let hexColor = try XCTUnwrap(SolidColorBackground("#abcdef")) + settingsModel.customBackground = .solidColor(hexColor) + XCTAssertEqual(provider.background, .hexColor("#abcdef")) + + let userImage = UserBackgroundImage(fileName: "abc.jpg", colorScheme: .light) + settingsModel.customBackground = .userImage(userImage) + XCTAssertEqual(provider.background, .userImage(.init(userImage))) + + settingsModel.customBackground = nil + XCTAssertEqual(provider.background, .default) + } + + func testThatBackgroundSetterSetsCorrectBackgroundInSettingsModel() throws { + provider.background = .gradient("gradient02.01") + XCTAssertEqual(settingsModel.customBackground, .gradient(.gradient0201)) + + provider.background = .solidColor("color02") + XCTAssertEqual(settingsModel.customBackground, .solidColor(.color02)) + + provider.background = .hexColor("#ABCDEF") + let hexColor = try XCTUnwrap(SolidColorBackground("#abcdef")) + XCTAssertEqual(settingsModel.customBackground, .solidColor(hexColor)) + + let userImage = UserBackgroundImage(fileName: "abc.jpg", colorScheme: .light) + provider.background = .userImage(.init(userImage)) + XCTAssertEqual(settingsModel.customBackground, .userImage(userImage)) + + provider.background = .default + XCTAssertEqual(settingsModel.customBackground, nil) + } + + @MainActor + func testThatCustomizerDataReturnsCorrectDataFromSettingsModelAndApperancePreferences() async throws { + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [ + .init(fileName: "1.jpg", colorScheme: .light), + .init(fileName: "2.jpg", colorScheme: .dark) + ] + } + + // this sets lastPickedCustomColor + settingsModel.customBackground = .solidColor(try XCTUnwrap(.init("#123abc"))) + settingsModel.customBackground = .solidColor(.color05) + appearancePreferences.currentThemeName = .light + + XCTAssertEqual( + provider.customizerData, + .init( + background: .solidColor("color05"), + theme: .light, + userColor: .init(hex: "#123abc"), + userImages: userBackgroundImagesManager.availableImages.map(NewTabPageDataModel.UserImage.init) + ) + ) + } + + func testThatBackgroundPublisherPublishesEvents() throws { + var events: [NewTabPageDataModel.Background] = [] + let cancellable = provider.backgroundPublisher.sink { events.append($0) } + + settingsModel.customBackground = .gradient(.gradient04) + settingsModel.customBackground = .solidColor(.color13) + settingsModel.customBackground = .solidColor(.color13) + settingsModel.customBackground = .solidColor(.color13) + settingsModel.customBackground = .solidColor(.color13) + settingsModel.customBackground = .solidColor(try XCTUnwrap(.init("#123abc"))) + settingsModel.customBackground = nil + settingsModel.customBackground = .userImage(.init(fileName: "1.jpg", colorScheme: .light)) + + cancellable.cancel() + + XCTAssertEqual( + events, + [ + .gradient("gradient04"), + .solidColor("color13"), + .hexColor("#123abc"), + .default, + .userImage(.init(.init(fileName: "1.jpg", colorScheme: .light))) + ] + ) + } + + func testThatThemeGetterReturnsAppearancePreferencesTheme() { + appearancePreferences.currentThemeName = .dark + XCTAssertEqual(provider.theme, .dark) + appearancePreferences.currentThemeName = .light + XCTAssertEqual(provider.theme, .light) + appearancePreferences.currentThemeName = .systemDefault + XCTAssertEqual(provider.theme, nil) + } + + func testThatThemeSetterSetsAppearancePreferencesTheme() { + provider.theme = .dark + XCTAssertEqual(appearancePreferences.currentThemeName, .dark) + provider.theme = .light + XCTAssertEqual(appearancePreferences.currentThemeName, .light) + provider.theme = nil + XCTAssertEqual(appearancePreferences.currentThemeName, .systemDefault) + } + + func testThatThemePublisherPublishesEvents() throws { + var events: [NewTabPageDataModel.Theme?] = [] + let cancellable = provider.themePublisher.sink { events.append($0) } + + appearancePreferences.currentThemeName = .light + appearancePreferences.currentThemeName = .dark + appearancePreferences.currentThemeName = .dark + appearancePreferences.currentThemeName = .dark + appearancePreferences.currentThemeName = .dark + appearancePreferences.currentThemeName = .systemDefault + appearancePreferences.currentThemeName = .systemDefault + appearancePreferences.currentThemeName = .light + + cancellable.cancel() + + XCTAssertEqual(events, [.light, .dark, nil, .light]) + } + + func testThatUserImagesPublisherPublishesEvents() async throws { + var events: [[NewTabPageDataModel.UserImage]] = [] + let cancellable = provider.userImagesPublisher.sink { events.append($0) } + + let image1 = UserBackgroundImage(fileName: "1.jpg", colorScheme: .light) + let image2 = UserBackgroundImage(fileName: "2.jpg", colorScheme: .dark) + + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [image1] + } + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [image1, image2] + } + try await waitForAvailableUserBackgroundImages(inverted: true) { + userBackgroundImagesManager.availableImages = [image1, image2] + } + try await waitForAvailableUserBackgroundImages(inverted: true) { + userBackgroundImagesManager.availableImages = [image1, image2] + } + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [] + } + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [image2, image1] + } + + cancellable.cancel() + + /// Slower machines may capture the initial empty array event so let's filter it out here + if events.first == [] { + events = Array(events.dropFirst()) + } + + XCTAssertEqual(events, [ + [.init(image1)], + [.init(image1), .init(image2)], + [], + [.init(image2), .init(image1)] + ]) + } + + func testThatPresentUploadDialogCallsAddImage() async { + await provider.presentUploadDialog() + XCTAssertEqual(openFilePanelCalls, 1) + } + + func testThatDeleteImageCallsImagesManager() async throws { + try await waitForAvailableUserBackgroundImages { + userBackgroundImagesManager.availableImages = [.init(fileName: "1.jpg", colorScheme: .light)] + } + await provider.deleteImage(with: "1.jpg") + XCTAssertEqual(userBackgroundImagesManager.deleteImageCallCount, 1) + } + + func testThatDeleteImageReturnsEarlyIfImageIsNotPresent() async { + await provider.deleteImage(with: "aaaaaa.jpg") + XCTAssertEqual(userBackgroundImagesManager.deleteImageCallCount, 0) + } + + // MARK: - Helpers + + /** + * Sets up an expectation, then sets up Combine subscription for `settingsModel.$availableUserBackgroundImages` that fulfills + * the expectation, then calls the provided `block` and waits for time specified by `duration` before cancelling the subscription. + */ + private func waitForAvailableUserBackgroundImages(for duration: TimeInterval = 1, inverted: Bool = false, _ block: () async -> Void = {}) async throws { + let expectation = self.expectation(description: "viewModelUpdate") + expectation.isInverted = inverted + let cancellable = settingsModel.$availableUserBackgroundImages.dropFirst().prefix(1).sink { _ in expectation.fulfill() } + + await block() + + await fulfillment(of: [expectation], timeout: duration) + cancellable.cancel() + } +} diff --git a/UnitTests/NewTabPage/NewTabPageNextStepsCardsProviderTests.swift b/UnitTests/NewTabPage/NewTabPageNextStepsCardsProviderTests.swift index 4b9f30a79a..6cfd7ca364 100644 --- a/UnitTests/NewTabPage/NewTabPageNextStepsCardsProviderTests.swift +++ b/UnitTests/NewTabPage/NewTabPageNextStepsCardsProviderTests.swift @@ -65,7 +65,7 @@ final class NewTabPageNextStepsCardsProviderTests: XCTestCase { provider.appearancePreferences.isContinueSetUpCardsViewOutdated = false provider.continueSetUpModel.featuresMatrix = [[.defaultBrowser]] - var cardsEvents = [[NewTabPageNextStepsCardsClient.CardID]]() + var cardsEvents = [[NewTabPageDataModel.CardID]]() let cancellable = provider.cardsPublisher .sink { cards in @@ -84,7 +84,7 @@ final class NewTabPageNextStepsCardsProviderTests: XCTestCase { provider.appearancePreferences.isContinueSetUpCardsViewOutdated = true provider.continueSetUpModel.featuresMatrix = [[.defaultBrowser]] - var cardsEvents = [[NewTabPageNextStepsCardsClient.CardID]]() + var cardsEvents = [[NewTabPageDataModel.CardID]]() let cancellable = provider.cardsPublisher .sink { cards in @@ -103,7 +103,7 @@ final class NewTabPageNextStepsCardsProviderTests: XCTestCase { provider.appearancePreferences.isContinueSetUpCardsViewOutdated = false provider.continueSetUpModel.featuresMatrix = [[.defaultBrowser]] - var cardsEvents = [[NewTabPageNextStepsCardsClient.CardID]]() + var cardsEvents = [[NewTabPageDataModel.CardID]]() let cancellable = provider.cardsPublisher .sink { cards in