diff --git a/.github/workflows/publish_dmg_release.yml b/.github/workflows/publish_dmg_release.yml index fefc4ce767..dcd7a4cf61 100644 --- a/.github/workflows/publish_dmg_release.yml +++ b/.github/workflows/publish_dmg_release.yml @@ -262,7 +262,8 @@ jobs: github_handle:"${{ github.actor }}" \ is_scheduled_release:"${{ github.event_name == 'schedule' }}" \ release_task_id:"${{ steps.task-id.outputs.asana_task_id }}" \ - target_section_id:"${{ vars.MACOS_APP_BOARD_DONE_SECTION_ID }}" + target_section_id:"${{ vars.MACOS_APP_BOARD_DONE_SECTION_ID }}" \ + tag:"${{ env.TAG }}" - name: Get tasks since last internal release id: get-tasks-since-last-internal-release diff --git a/Configuration/BuildNumber.xcconfig b/Configuration/BuildNumber.xcconfig index 713e7c5da5..7882e36766 100644 --- a/Configuration/BuildNumber.xcconfig +++ b/Configuration/BuildNumber.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 323 +CURRENT_PROJECT_VERSION = 324 diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 8251645953..ae1c0a4b65 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1107,6 +1107,10 @@ 372217822B33380700B8E9C2 /* TestUtils in Frameworks */ = {isa = PBXBuildFile; productRef = 372217812B33380700B8E9C2 /* TestUtils */; }; 3723A94F2CE7400D00A0C59A /* NewTabPageRMFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */; }; 3723A9502CE7400D00A0C59A /* NewTabPageRMFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */; }; + 3724ED8B2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */; }; + 3724ED8C2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */; }; + 3724ED8E2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */; }; + 3724ED8F2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */; }; 37269EFB2B332F9E005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFA2B332F9E005E8E46 /* Common */; }; 37269EFD2B332FAC005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFC2B332FAC005E8E46 /* Common */; }; 37269EFF2B332FBB005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFE2B332FBB005E8E46 /* Common */; }; @@ -1248,6 +1252,8 @@ 37BF3F22286F0A7A00BD9014 /* PinnedTabsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BF3F1F286F0A7A00BD9014 /* PinnedTabsView.swift */; }; 37C9F78C2CF1C776004D73A1 /* PrivacyStatsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */; }; 37C9F78D2CF1C776004D73A1 /* PrivacyStatsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */; }; + 37CB368B2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */; }; + 37CB368C2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */; }; 37CBCA9A2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */; }; 37CBCA9B2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */; }; 37CC53EC27E8A4D10028713D /* PreferencesDataClearingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53EB27E8A4D10028713D /* PreferencesDataClearingView.swift */; }; @@ -1683,6 +1689,8 @@ 562984712AC469E400AC20EB /* SyncPreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5629846E2AC4610100AC20EB /* SyncPreferencesTests.swift */; }; 56406D4B2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56406D4A2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift */; }; 56406D4C2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56406D4A2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift */; }; + 5641734B2CFE168700F4B716 /* PixelExperimentKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5641734A2CFE168700F4B716 /* PixelExperimentKit */; }; + 5641734D2CFE169400F4B716 /* PixelExperimentKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5641734C2CFE169400F4B716 /* PixelExperimentKit */; }; 56534DED29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */; }; 56534DEE29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56534DEC29DF252C00121467 /* CapturingDefaultBrowserProvider.swift */; }; 565E46E02B2725DD0013AC2A /* CriticalPathsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 565E46DF2B2725DD0013AC2A /* CriticalPathsTests.swift */; }; @@ -2882,6 +2890,7 @@ B6F9BDE52B45CD1900677B33 /* ModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6F9BDE32B45CD1900677B33 /* ModalView.swift */; }; B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; }; B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; }; + BB0346F52CEB80B400D23E05 /* DownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */; }; BB4339DB2C7F9606005D7ED7 /* PinnedTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */; }; BB470EBB2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */; }; BB470EBC2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */; }; @@ -3664,6 +3673,8 @@ 37219B392CBFD4F300C9D7A8 /* NewTabPageSearchBoxExperiment+Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewTabPageSearchBoxExperiment+Logger.swift"; sourceTree = ""; }; 37219B3C2CC27DB300C9D7A8 /* NewTabPageSearchBoxExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSearchBoxExperimentTests.swift; sourceTree = ""; }; 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageRMFClient.swift; sourceTree = ""; }; + 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsProviding.swift; sourceTree = ""; }; + 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsClientTests.swift; sourceTree = ""; }; 372A0FEB2B2379310033BF7F /* SyncMetricsEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetricsEventsHandler.swift; sourceTree = ""; }; 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCredentialsAdapter.swift; sourceTree = ""; }; 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageUserContentController.swift; sourceTree = ""; }; @@ -3760,6 +3771,7 @@ 37BF3F1E286F0A7A00BD9014 /* PinnedTabsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedTabsViewModel.swift; sourceTree = ""; }; 37BF3F1F286F0A7A00BD9014 /* PinnedTabsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedTabsView.swift; sourceTree = ""; }; 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyStatsTabExtension.swift; sourceTree = ""; }; + 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsClient.swift; sourceTree = ""; }; 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsAdapter.swift; sourceTree = ""; }; 37CC53EB27E8A4D10028713D /* PreferencesDataClearingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataClearingView.swift; sourceTree = ""; }; 37CC53F327E8D4620028713D /* NSPathControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPathControlView.swift; sourceTree = ""; }; @@ -4868,6 +4880,7 @@ B6F9BDE32B45CD1900677B33 /* ModalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalView.swift; sourceTree = ""; }; B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = ""; }; + BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTests.swift; sourceTree = ""; }; BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTabsTests.swift; sourceTree = ""; }; BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManagementDetailViewModel.swift; sourceTree = ""; }; BB5789712B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionSubscriptionEventHandler.swift; sourceTree = ""; }; @@ -5072,6 +5085,7 @@ 567A23C52C7F75BB0010F66C /* SpecialErrorPages in Frameworks */, 372217822B33380700B8E9C2 /* TestUtils in Frameworks */, 3706FCAA293F65D500E42796 /* UserScript in Frameworks */, + 5641734D2CFE169400F4B716 /* PixelExperimentKit in Frameworks */, 85E2BBD02B8F534A00DBEC7A /* History in Frameworks */, 4BF97AD52B43C43F00EB4240 /* NetworkProtection in Frameworks */, 560EB9372C78974C0080DBC8 /* Onboarding in Frameworks */, @@ -5306,6 +5320,7 @@ CD3301242C8870DF009AA127 /* MaliciousSiteProtection in Frameworks */, C18BF9CC2C73678500ED6B8A /* Freemium in Frameworks */, F1DF95E32BD1807C0045E591 /* Crashes in Frameworks */, + 5641734B2CFE168700F4B716 /* PixelExperimentKit in Frameworks */, 560EB9352C7897370080DBC8 /* Onboarding in Frameworks */, 85E2BBCE2B8F534000DBEC7A /* History in Frameworks */, 1EA7B8D32B7E078C000330A4 /* SubscriptionUI in Frameworks */, @@ -5855,6 +5870,7 @@ children = ( 37F8E2322CEE363D002F0141 /* NewTabPageContextMenuPresenting.swift */, 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */, + 37CB36892CFA5ADA00E5E5FA /* NextStepsCards */, 37F1E32D2CEF2DDD00130142 /* Favorites */, 37DF370B2CF3CEF5005ED34B /* PrivacyStats */, 379B5AEB2CEA26C600B9F5D7 /* NewTabPageConfigurationClient.swift */, @@ -5873,6 +5889,7 @@ 3767880E2CECD5A200F59D83 /* NewTabPageUserScriptTests.swift */, 3767880B2CECCB6C00F59D83 /* NewTabPageActionsManagerTests.swift */, 376788112CECF03000F59D83 /* NewTabPageRMFClientTests.swift */, + 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */, 37F1E32E2CEF4A2C00130142 /* NewTabPageFavoritesClientTests.swift */, 378D62562CEF801A0056BBD8 /* NewTabPageFavoritesModelTests.swift */, 37FC2A1A2CF903A70048E226 /* NewTabPagePrivacyStatsClientTests.swift */, @@ -6037,6 +6054,15 @@ path = PrivacyStats; sourceTree = ""; }; + 37CB36892CFA5ADA00E5E5FA /* NextStepsCards */ = { + isa = PBXGroup; + children = ( + 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */, + 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */, + ); + path = NextStepsCards; + sourceTree = ""; + }; 37CD54C027F2FDD100F1F7B9 /* Model */ = { isa = PBXGroup; children = ( @@ -7181,6 +7207,7 @@ BBBB653F2C77BB9400E69AC6 /* BookmarkSearchTests.swift */, BB5F46A22C8751F6005F72DF /* BookmarkSortTests.swift */, BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */, + BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */, ); path = UITests; sourceTree = ""; @@ -10011,6 +10038,7 @@ CBECDB8D2CDBD62C005B8B87 /* PageRefreshMonitor */, CBECDB8F2CDBD631005B8B87 /* BrokenSitePrompt */, 37DF37062CF38B9F005ED34B /* PrivacyStats */, + 5641734C2CFE169400F4B716 /* PixelExperimentKit */, ); productName = DuckDuckGo; productReference = 3706FD05293F65D500E42796 /* DuckDuckGo App Store.app */; @@ -10491,6 +10519,7 @@ CBECDB892CDBD616005B8B87 /* PageRefreshMonitor */, CBECDB8B2CDBD61C005B8B87 /* BrokenSitePrompt */, 37DF37042CF38B96005ED34B /* PrivacyStats */, + 5641734A2CFE168700F4B716 /* PixelExperimentKit */, ); productName = DuckDuckGo; productReference = AA585D7E248FD31100E9A3E2 /* DuckDuckGo.app */; @@ -11698,6 +11727,7 @@ 3706FB80293F65D500E42796 /* NSAlert+ActiveDownloadsTermination.swift in Sources */, B677FC552B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, D64A5FF92AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, + 3724ED8B2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */, 4BE3A6C22C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */, 3707C717294B5D0F00682A9F /* FindInPageTabExtension.swift in Sources */, 3706FB81293F65D500E42796 /* IndexPathExtension.swift in Sources */, @@ -12158,6 +12188,7 @@ 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, 4BF0E5132AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, 3706FC78293F65D500E42796 /* SecureVaultReporter.swift in Sources */, + 37CB368C2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */, 3706FC79293F65D500E42796 /* NSImageExtensions.swift in Sources */, 3706FEBD293F6EFF00E42796 /* BWCommand.swift in Sources */, C172E7302C9329D300521D9A /* FlippedView.swift in Sources */, @@ -12585,6 +12616,7 @@ CD33012A2C887B1C009AA127 /* URLTokenValidatorTests.swift in Sources */, 9F0660742BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, 9FBD847B2BB3EC3300220859 /* MockAttributionOriginProvider.swift in Sources */, + 3724ED8F2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */, 56A214B02CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */, 3706FE82293F661700E42796 /* MockStatisticsStore.swift in Sources */, 9FBD84712BB3DD8400220859 /* MockAttributionsPixelHandler.swift in Sources */, @@ -12883,6 +12915,7 @@ BB731F312CDBA6360023D2E4 /* FireWindowTests.swift in Sources */, EE42CBCC2BC8004700AD411C /* PermissionsTests.swift in Sources */, 7B4CE8E726F02135009134B1 /* TabBarTests.swift in Sources */, + BB0346F52CEB80B400D23E05 /* DownloadsTests.swift in Sources */, EEBCE6832BA463DD00B9DF00 /* NSImageExtensions.swift in Sources */, EEBCE6822BA444FA00B9DF00 /* XCUIElementExtension.swift in Sources */, EED735362BB46B6000F173D6 /* AutocompleteTests.swift in Sources */, @@ -13072,6 +13105,7 @@ B62B483E2ADE48DE000DECE5 /* ArrayBuilder.swift in Sources */, 4B92929B26670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift in Sources */, 56D145EB29E6C99B00E3488A /* DataImportStatusProviding.swift in Sources */, + 37CB368B2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */, 843965122C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */, 31D5375C291D944100407A95 /* PasswordManagementBitwardenItemView.swift in Sources */, 1D9297BF2C1B062900A38521 /* ApplicationUpdateDetector.swift in Sources */, @@ -13089,6 +13123,7 @@ B6E3E5542BBFCEE300A41922 /* NoDownloadsCellView.swift in Sources */, 3768D8382C24BFF5004120AE /* RemoteMessageView.swift in Sources */, 4BBDEE9428FC14760092FAA6 /* ConnectBitwardenViewController.swift in Sources */, + 3724ED8C2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */, 1DDF076428F815AD00EDFBE3 /* BWManager.swift in Sources */, 9833912F27AAA3CE00DAF119 /* AppTrackerDataSetProvider.swift in Sources */, B65211252B29A42C00B30633 /* BookmarkStoreMock.swift in Sources */, @@ -14105,6 +14140,7 @@ B6106BAF26A7C6180013B453 /* PermissionStoreMock.swift in Sources */, 4B98D27A28D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift in Sources */, 9F0660732BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, + 3724ED8E2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */, BDCB66D82C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */, 567A23E12C89B1EE0010F66C /* BrowserTabViewControllerOnboardingTests.swift in Sources */, 986189E62A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, @@ -15335,7 +15371,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 217.0.0; + version = 218.0.0; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { @@ -15790,6 +15826,14 @@ package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Onboarding; }; + 5641734A2CFE168700F4B716 /* PixelExperimentKit */ = { + isa = XCSwiftPackageProductDependency; + productName = PixelExperimentKit; + }; + 5641734C2CFE169400F4B716 /* PixelExperimentKit */ = { + isa = XCSwiftPackageProductDependency; + productName = PixelExperimentKit; + }; 567A23C02C7F71570010F66C /* SpecialErrorPages */ = { isa = XCSwiftPackageProductDependency; package = 9807F643278CA16F00E1547B /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ae09eed590..7bd364813b 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "befc1f1094353d8d88a99ac08885684c978b2016", - "version" : "217.0.0" + "revision" : "e5d390c8559fbe7b1ca67fd3982c91bcc0437d60", + "version" : "218.0.0" } }, { diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 9316721cce..3d79b94ed3 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -31,6 +31,7 @@ import MetricKit import Networking import Persistence import PixelKit +import PixelExperimentKit import ServiceManagement import SyncDataProviders import UserNotifications @@ -282,7 +283,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { keyValueStore: UserDefaults.appConfiguration, actionHandler: FeatureFlagOverridesPublishingHandler() ), - experimentManager: ExperimentCohortsManager(store: ExperimentsDataStore()), + experimentManager: ExperimentCohortsManager(store: ExperimentsDataStore(), fireCohortAssigned: PixelKit.fireExperimentEnrollmentPixel(subfeatureID:experiment:)), for: FeatureFlag.self ) @@ -331,6 +332,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #else privacyStats = PrivacyStats(databaseProvider: PrivacyStatsDatabase()) #endif + PixelKit.configureExperimentKit(featureFlagger: featureFlagger, eventTracker: ExperimentEventTracker(store: UserDefaults.appConfiguration)) } func applicationWillFinishLaunching(_ notification: Notification) { diff --git a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift index 44c3513c20..ce87c8cfac 100644 --- a/DuckDuckGo/FileDownload/View/DownloadsViewController.swift +++ b/DuckDuckGo/FileDownload/View/DownloadsViewController.swift @@ -95,6 +95,7 @@ final class DownloadsViewController: NSViewController { clearDownloadsButton.imagePosition = .imageOnly clearDownloadsButton.imageScaling = .scaleProportionallyDown clearDownloadsButton.toolTip = UserText.clearDownloadHistoryTooltip + clearDownloadsButton.setAccessibilityIdentifier("DownloadsViewController.clearDownloadsButton") clearDownloadsButton.cornerRadius = 4 clearDownloadsButton.backgroundInset = CGPoint(x: 2, y: 2) clearDownloadsButton.normalTintColor = .button diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index c457c86b13..47c5ba9665 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -18,6 +18,7 @@ import AppKit import BrowserServicesKit +import Combine import Common import Foundation import PixelKit @@ -26,6 +27,20 @@ import Subscription import NetworkProtection import NetworkProtectionUI +protocol ContinueSetUpModelTabOpening { + @MainActor + func openTab(_ tab: Tab) +} + +struct TabCollectionViewModelTabOpener: ContinueSetUpModelTabOpening { + let tabCollectionViewModel: TabCollectionViewModel + + @MainActor + func openTab(_ tab: Tab) { + tabCollectionViewModel.insertOrAppend(tab: tab, selected: true) + } +} + extension HomePage.Models { static let newHomePageTabOpen = Notification.Name("newHomePageAppOpen") @@ -49,7 +64,7 @@ extension HomePage.Models { private let defaultBrowserProvider: DefaultBrowserProvider private let dockCustomizer: DockCustomization private let dataImportProvider: DataImportStatusProviding - private let tabCollectionViewModel: TabCollectionViewModel + private let tabOpener: ContinueSetUpModelTabOpening private let emailManager: EmailManager private let duckPlayerPreferences: DuckPlayerPreferencesPersistor private let subscriptionManager: SubscriptionManager @@ -58,9 +73,13 @@ extension HomePage.Models { var shouldShowAllFeatures: Bool { didSet { updateVisibleMatrix() + shouldShowAllFeaturesSubject.send(shouldShowAllFeatures) } } + let shouldShowAllFeaturesPublisher: AnyPublisher + private let shouldShowAllFeaturesSubject = PassthroughSubject() + struct Settings { @UserDefaultsWrapper(key: .homePageShowMakeDefault, defaultValue: true) var shouldShowMakeDefaultSetting: Bool @@ -102,7 +121,7 @@ extension HomePage.Models { lazy var listOfFeatures = settings.isFirstSession ? firstRunFeatures : randomisedFeatures - private var featuresMatrix: [[FeatureType]] = [[]] { + @Published var featuresMatrix: [[FeatureType]] = [[]] { didSet { updateVisibleMatrix() } @@ -110,28 +129,35 @@ extension HomePage.Models { @Published var visibleFeaturesMatrix: [[FeatureType]] = [[]] - init(defaultBrowserProvider: DefaultBrowserProvider, - dockCustomizer: DockCustomization, - dataImportProvider: DataImportStatusProviding, - tabCollectionViewModel: TabCollectionViewModel, + init(defaultBrowserProvider: DefaultBrowserProvider = SystemDefaultBrowserProvider(), + dockCustomizer: DockCustomization = DockCustomizer(), + dataImportProvider: DataImportStatusProviding = BookmarksAndPasswordsImportStatusProvider(), + tabOpener: ContinueSetUpModelTabOpening, emailManager: EmailManager = EmailManager(), - duckPlayerPreferences: DuckPlayerPreferencesPersistor, + duckPlayerPreferences: DuckPlayerPreferencesPersistor = DuckPlayerPreferencesUserDefaultsPersistor(), privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager, subscriptionManager: SubscriptionManager = Application.appDelegate.subscriptionManager) { + self.defaultBrowserProvider = defaultBrowserProvider self.dockCustomizer = dockCustomizer self.dataImportProvider = dataImportProvider - self.tabCollectionViewModel = tabCollectionViewModel + self.tabOpener = tabOpener self.emailManager = emailManager self.duckPlayerPreferences = duckPlayerPreferences self.privacyConfigurationManager = privacyConfigurationManager self.subscriptionManager = subscriptionManager self.settings = .init() + shouldShowAllFeaturesPublisher = shouldShowAllFeaturesSubject.removeDuplicates().eraseToAnyPublisher() + refreshFeaturesMatrix() NotificationCenter.default.addObserver(self, selector: #selector(newTabOpenNotification(_:)), name: HomePage.Models.newHomePageTabOpen, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeKey(_:)), name: NSWindow.didBecomeKeyNotification, object: nil) + + // HTML NTP doesn't refresh on appear so we have to connect to the appear signal + // (the notification in this case) to trigger a refresh. + NotificationCenter.default.addObserver(self, selector: #selector(refreshFeaturesForHTMLNewTabPage(_:)), name: .newTabPageWebViewDidAppear, object: nil) } @MainActor func performAction(for featureType: FeatureType) { @@ -166,14 +192,14 @@ extension HomePage.Models { private func performDuckPlayerAction() { if let videoUrl = URL(string: duckPlayerURL) { let tab = Tab(content: .url(videoUrl, source: .link), shouldLoadInBackground: true) - tabCollectionViewModel.append(tab: tab) + tabOpener.openTab(tab) } } @MainActor private func performEmailProtectionAction() { let tab = Tab(content: .url(EmailUrls().emailProtectionLink, source: .ui), shouldLoadInBackground: true) - tabCollectionViewModel.append(tab: tab) + tabOpener.openTab(tab) } func performDockAction() { @@ -243,6 +269,14 @@ extension HomePage.Models { } @objc private func windowDidBecomeKey(_ notification: Notification) { + // Async dispatch allows default browser setting to propagate + // after being changed in the system dialog + DispatchQueue.main.async { + self.refreshFeaturesMatrix() + } + } + + @objc private func refreshFeaturesForHTMLNewTabPage(_ notification: Notification) { refreshFeaturesMatrix() } diff --git a/DuckDuckGo/HomePage/View/ContinueSetUpView.swift b/DuckDuckGo/HomePage/View/ContinueSetUpView.swift index 33fa94a8f1..eeacd17952 100644 --- a/DuckDuckGo/HomePage/View/ContinueSetUpView.swift +++ b/DuckDuckGo/HomePage/View/ContinueSetUpView.swift @@ -119,7 +119,7 @@ extension HomePage.Views { .onAppear { if featureType == .dock { PixelKit.fire(GeneralPixel.addToDockNewTabPageCardPresented, - frequency: .unique, + frequency: .uniqueByName, includeAppVersionParameter: false) } } @@ -292,7 +292,7 @@ extension HomePage.Views { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) } diff --git a/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift b/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift index 2ea65f9003..cf1086c16a 100644 --- a/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift +++ b/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift @@ -247,7 +247,7 @@ extension HomePage.Views.BackgroundCategoryView { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) .environmentObject(HomePage.Models.FavoritesModel( @@ -276,7 +276,7 @@ extension HomePage.Views.BackgroundCategoryView { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) .environmentObject(HomePage.Models.FavoritesModel( diff --git a/DuckDuckGo/HomePage/View/HomePageViewController.swift b/DuckDuckGo/HomePage/View/HomePageViewController.swift index 04635afce2..7651f0b5a5 100644 --- a/DuckDuckGo/HomePage/View/HomePageViewController.swift +++ b/DuckDuckGo/HomePage/View/HomePageViewController.swift @@ -171,7 +171,7 @@ final class HomePageViewController: NSViewController { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: tabCollectionViewModel, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionViewModel), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() ) } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index bb32f3e5e8..ef178b6eed 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -176,6 +176,7 @@ final class NavigationBarViewController: NSViewController { bookmarkListButton.delegate = self bookmarkListButton.setAccessibilityIdentifier("NavigationBarViewController.bookmarkListButton") downloadsButton.sendAction(on: .leftMouseDown) + downloadsButton.setAccessibilityIdentifier("NavigationBarViewController.downloadsButton") networkProtectionButton.sendAction(on: .leftMouseDown) passwordManagementButton.sendAction(on: .leftMouseDown) aiChatButton.sendAction(on: .leftMouseDown) diff --git a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift index a35f9a6d29..9c5606b3df 100644 --- a/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift +++ b/DuckDuckGo/NetworkProtection/AppTargets/BothAppTargets/NetworkProtectionTunnelController.swift @@ -654,7 +654,7 @@ final class NetworkProtectionTunnelController: TunnelController, TunnelSessionPr PixelKit.fire( NetworkProtectionPixelEvent.networkProtectionNewUser, - frequency: .unique, + frequency: .uniqueByName, includeAppVersionParameter: true) { [weak self] fired, error in guard let self, error == nil, fired else { return } self.defaults.vpnFirstEnabled = PixelKit.pixelLastFireDate(event: NetworkProtectionPixelEvent.networkProtectionNewUser) diff --git a/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift b/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift index 90fd7de527..474eda2e8b 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift @@ -130,8 +130,16 @@ extension NewTabPageActionsManager { self.init(scriptClients: [ NewTabPageConfigurationClient(appearancePreferences: appearancePreferences), NewTabPageRMFClient(remoteMessageProvider: activeRemoteMessageModel, openURLHandler: openURLHandler), + NewTabPageNextStepsCardsClient(model: HomePage.Models.ContinueSetUpModel(tabOpener: NewTabPageTabOpener())), NewTabPageFavoritesClient(favoritesModel: NewTabPageFavoritesModel()), NewTabPagePrivacyStatsClient(model: privacyStatsModel) ]) } } + +struct NewTabPageTabOpener: ContinueSetUpModelTabOpening { + @MainActor + func openTab(_ tab: Tab) { + WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel.insertOrAppend(tab: tab, selected: true) + } +} diff --git a/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift b/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift index 8eb5c7b7d8..41f88e5f84 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift @@ -131,6 +131,7 @@ final class NewTabPageConfigurationClient: NewTabPageScriptClient { return NewTabPageUserScript.NewTabPageConfiguration( widgets: [ .init(id: .rmf), + .init(id: .nextSteps), .init(id: .favorites), .init(id: .privacyStats) ], @@ -174,7 +175,7 @@ final class NewTabPageConfigurationClient: NewTabPageScriptClient { extension NewTabPageUserScript { enum WidgetId: String, Codable { - case rmf, favorites, privacyStats + case rmf, nextSteps, favorites, privacyStats } struct ContextMenuParams: Codable { diff --git a/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift b/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift index 73f368ca0a..0d3fcb3294 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift @@ -52,7 +52,12 @@ final class NewTabPageWebViewModel: NSObject { windowCancellable = webView.publisher(for: \.window) .map { $0 != nil } - .assign(to: \.isViewOnScreen, on: activeRemoteMessageModel) + .sink { [weak activeRemoteMessageModel] isOnScreen in + activeRemoteMessageModel?.isViewOnScreen = isOnScreen + if isOnScreen { + NotificationCenter.default.post(name: .newTabPageWebViewDidAppear, object: nil) + } + } } } @@ -61,3 +66,7 @@ extension NewTabPageWebViewModel: WKNavigationDelegate { navigationAction.request.url == .newtab ? .allow : .cancel } } + +extension Notification.Name { + static var newTabPageWebViewDidAppear = Notification.Name("newTabPageWebViewDidAppear") +} diff --git a/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift new file mode 100644 index 0000000000..f377d1dbde --- /dev/null +++ b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift @@ -0,0 +1,197 @@ +// +// NewTabPageNextStepsCardsClient.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 + +final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { + + let model: NewTabPageNextStepsCardsProviding + let willDisplayCardsPublisher: AnyPublisher<[CardID], Never> + weak var userScriptsSource: NewTabPageUserScriptsSource? + + private let willDisplayCardsSubject = PassthroughSubject<[CardID], Never>() + private let getDataSubject = PassthroughSubject<[CardID], Never>() + private let getConfigSubject = PassthroughSubject() + private let notifyDataUpdatedSubject = PassthroughSubject<[CardID], Never>() + private let notifyConfigUpdatedSubject = PassthroughSubject() + private var cancellables: Set = [] + + init(model: NewTabPageNextStepsCardsProviding) { + self.model = model + willDisplayCardsPublisher = willDisplayCardsSubject.eraseToAnyPublisher() + connectWillDisplayCardsPublisher() + + model.cardsPublisher + .sink { [weak self] cardIDs in + Task { @MainActor in + self?.notifyDataUpdated(cardIDs) + } + } + .store(in: &cancellables) + + model.isViewExpandedPublisher + .sink { [weak self] showAllCards in + Task { @MainActor in + self?.notifyConfigUpdated(showAllCards) + } + } + .store(in: &cancellables) + + willDisplayCardsPublisher + .sink { cards in + model.willDisplayCards(cards) + } + .store(in: &cancellables) + } + + private func connectWillDisplayCardsPublisher() { + let initialCards = Publishers.CombineLatest(getDataSubject, getConfigSubject) + .map { cards, isViewExpanded in + isViewExpanded ? cards : Array(cards.prefix(2)) + } + .share() + + let firstInitialCards = initialCards.first() + + // only notify about visible cards (i.e. if collapsed, only the first 2) + let cardsOnDataUpdated = notifyDataUpdatedSubject + .drop(untilOutputFrom: firstInitialCards) + .map { [weak self] cards in + self?.model.isViewExpanded == true ? cards: Array(cards.prefix(2)) + } + + // 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 + guard let self, isViewExpanded, model.cards.count > 2 else { + return nil + } + return Array(self.model.cards.suffix(from: 2)) + } + + Publishers.Merge3(initialCards, cardsOnDataUpdated, cardsOnConfigUpdated) + .filter { !$0.isEmpty } + .sink { [weak self] cards in + self?.willDisplayCardsSubject.send(cards) + } + .store(in: &cancellables) + } + + enum MessageName: String, CaseIterable { + case action = "nextSteps_action" + case dismiss = "nextSteps_dismiss" + case getConfig = "nextSteps_getConfig" + case getData = "nextSteps_getData" + case onConfigUpdate = "nextSteps_onConfigUpdate" + case onDataUpdate = "nextSteps_onDataUpdate" + case setConfig = "nextSteps_setConfig" + } + + 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.getConfig.rawValue: { [weak self] in try await self?.getConfig(params: $0, original: $1) }, + MessageName.getData.rawValue: { [weak self] in try await self?.getData(params: $0, original: $1) }, + MessageName.setConfig.rawValue: { [weak self] in try await self?.setConfig(params: $0, original: $1) } + ]) + } + + @MainActor + func action(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let card: NewTabPageNextStepsCardsClient.Card = DecodableHelper.decode(from: params) else { + return nil + } + model.handleAction(for: card.id) + return nil + } + + @MainActor + func dismiss(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let card: Card = DecodableHelper.decode(from: params) else { + return nil + } + model.dismiss(card.id) + return nil + } + + func getConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let expansion: NewTabPageUserScript.WidgetConfig.Expansion = model.isViewExpanded ? .expanded : .collapsed + + getConfigSubject.send(model.isViewExpanded) + return NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: expansion) + } + + @MainActor + func setConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let config: NewTabPageUserScript.WidgetConfig = DecodableHelper.decode(from: params) else { + return nil + } + model.isViewExpanded = config.expansion == .expanded + return nil + } + + @MainActor + func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let cardIDs = model.cards + let cards = cardIDs.map(Card.init(id:)) + + getDataSubject.send(cardIDs) + return 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) + + notifyDataUpdatedSubject.send(cardIDs) + pushMessage(named: MessageName.onDataUpdate.rawValue, params: params) + } + + @MainActor + private func notifyConfigUpdated(_ showAllCards: Bool) { + let expansion: NewTabPageUserScript.WidgetConfig.Expansion = showAllCards ? .expanded : .collapsed + let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: expansion) + + notifyConfigUpdatedSubject.send(showAllCards) + pushMessage(named: MessageName.onConfigUpdate.rawValue, params: config) + } +} + +extension NewTabPageNextStepsCardsClient { + + 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 { + let content: [Card]? + } +} diff --git a/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift new file mode 100644 index 0000000000..030e5e1bda --- /dev/null +++ b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift @@ -0,0 +1,118 @@ +// +// NewTabPageNextStepsCardsProviding.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 PixelKit + +protocol NewTabPageNextStepsCardsProviding: AnyObject { + var isViewExpanded: Bool { get set } + var isViewExpandedPublisher: AnyPublisher { get } + + var cards: [NewTabPageNextStepsCardsClient.CardID] { get } + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { get } + + @MainActor + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) + + @MainActor + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) +} + +extension HomePage.Models.ContinueSetUpModel: NewTabPageNextStepsCardsProviding { + var isViewExpanded: Bool { + get { + shouldShowAllFeatures + } + set { + shouldShowAllFeatures = newValue + } + } + + var isViewExpandedPublisher: AnyPublisher { + shouldShowAllFeaturesPublisher.eraseToAnyPublisher() + } + + var cards: [NewTabPageNextStepsCardsClient.CardID] { + featuresMatrix.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + } + + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + $featuresMatrix.dropFirst().removeDuplicates() + .map { matrix in + matrix.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + } + .eraseToAnyPublisher() + } + + @MainActor + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + performAction(for: .init(card)) + } + + @MainActor + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + removeItem(for: .init(card)) + } + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + guard cards.contains(.addAppToDockMac) else { + return + } + PixelKit.fire(GeneralPixel.addToDockNewTabPageCardPresented, + frequency: .uniqueByName, + includeAppVersionParameter: false) + } +} + +extension HomePage.Models.FeatureType { + init(_ card: NewTabPageNextStepsCardsClient.CardID) { + switch card { + case .bringStuff: + self = .importBookmarksAndPasswords + case .defaultApp: + self = .defaultBrowser + case .emailProtection: + self = .emailProtection + case .duckplayer: + self = .duckplayer + case .addAppToDockMac: + self = .dock + } + } +} + +extension NewTabPageNextStepsCardsClient.CardID { + init(_ feature: HomePage.Models.FeatureType) { + switch feature { + case .duckplayer: + self = .duckplayer + case .emailProtection: + self = .emailProtection + case .defaultBrowser: + self = .defaultApp + case .dock: + self = .addAppToDockMac + case .importBookmarksAndPasswords: + self = .bringStuff + } + } +} diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingPixelReporter.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingPixelReporter.swift index 24f6d0570e..b800061a1f 100644 --- a/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingPixelReporter.swift +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/OnboardingPixelReporter.swift @@ -57,27 +57,27 @@ final class OnboardingPixelReporter: OnboardingSearchSuggestionsPixelReporting, } func trackSiteSuggetionOptionTapped() { - fire(ContextualOnboardingPixel.siteSuggetionOptionTapped, .unique) + fire(ContextualOnboardingPixel.siteSuggetionOptionTapped, .uniqueByName) } func trackSearchSuggetionOptionTapped() { - fire(ContextualOnboardingPixel.searchSuggetionOptionTapped, .unique) + fire(ContextualOnboardingPixel.searchSuggetionOptionTapped, .uniqueByName) } } extension OnboardingPixelReporter: OnboardingAddressBarReporting { func trackPrivacyDashboardOpened() { if onboardingStateProvider.state != .onboardingCompleted { - fire(ContextualOnboardingPixel.onboardingPrivacyDashboardOpened, .unique) + fire(ContextualOnboardingPixel.onboardingPrivacyDashboardOpened, .uniqueByName) } } func trackAddressBarTypedIn() { if onboardingStateProvider.state == .showTryASearch { - fire(ContextualOnboardingPixel.onboardingSearchCustom, .unique) + fire(ContextualOnboardingPixel.onboardingSearchCustom, .uniqueByName) } if onboardingStateProvider.state == .showTryASite { - fire(ContextualOnboardingPixel.onboardingVisitSiteCustom, .unique) + fire(ContextualOnboardingPixel.onboardingVisitSiteCustom, .uniqueByName) } } @@ -85,7 +85,7 @@ extension OnboardingPixelReporter: OnboardingAddressBarReporting { let key = "onboarding.website-visited" let siteVisited = userDefaults.bool(forKey: key) if siteVisited { - fire(ContextualOnboardingPixel.secondSiteVisited, .unique) + fire(ContextualOnboardingPixel.secondSiteVisited, .uniqueByName) } else { userDefaults.set(true, forKey: key) } @@ -95,21 +95,21 @@ extension OnboardingPixelReporter: OnboardingAddressBarReporting { extension OnboardingPixelReporter: OnboardingFireReporting { func trackFireButtonPressed() { if onboardingStateProvider.state != .onboardingCompleted { - fire(ContextualOnboardingPixel.onboardingFireButtonPressed, .unique) + fire(ContextualOnboardingPixel.onboardingFireButtonPressed, .uniqueByName) } } } extension OnboardingPixelReporter: OnboardingDialogsReporting { func trackLastDialogShown() { - fire(ContextualOnboardingPixel.onboardingFinished, .unique) + fire(ContextualOnboardingPixel.onboardingFinished, .uniqueByName) } func trackFireButtonSkipped() { - fire(ContextualOnboardingPixel.onboardingFireButtonPromptSkipPressed, .unique) + fire(ContextualOnboardingPixel.onboardingFireButtonPromptSkipPressed, .uniqueByName) } func trackFireButtonTryIt() { - fire(ContextualOnboardingPixel.onboardingFireButtonTryItPressed, .unique) + fire(ContextualOnboardingPixel.onboardingFireButtonTryItPressed, .uniqueByName) } } diff --git a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift index 43fc227272..8f87ad4e1c 100644 --- a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift +++ b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift @@ -16,10 +16,12 @@ // limitations under the License. // -import Foundation import AppKit import Bookmarks +import BrowserServicesKit import Common +import FeatureFlags +import Foundation import PixelKit import os.log @@ -165,7 +167,7 @@ enum ThemeName: String, Equatable, CaseIterable { } } -extension FavoritesDisplayMode: LosslessStringConvertible { +extension FavoritesDisplayMode: @retroactive LosslessStringConvertible { static let `default` = FavoritesDisplayMode.displayNative(.desktop) public init?(_ description: String) { @@ -232,6 +234,11 @@ final class AppearancePreferences: ObservableObject { } } + var isContinueSetUpCardsVisibilityControlAvailable: Bool { + // HTML NTP doesn't allow for hiding Next Steps Cards section + !featureFlagger().isFeatureOn(.htmlNewTabPage) + } + var isContinueSetUpVisible: Bool { get { return persistor.isContinueSetUpVisible && !persistor.continueSetUpCardsClosed && !isContinueSetUpCardsViewOutdated @@ -337,12 +344,14 @@ final class AppearancePreferences: ObservableObject { init( persistor: AppearancePreferencesPersistor = AppearancePreferencesUserDefaultsPersistor(), homePageNavigator: HomePageNavigator = DefaultHomePageNavigator(), + featureFlagger: @autoclosure @escaping () -> FeatureFlagger = NSApp.delegateTyped.featureFlagger, dateTimeProvider: @escaping () -> Date = Date.init ) { self.persistor = persistor self.homePageNavigator = homePageNavigator self.dateTimeProvider = dateTimeProvider self.isContinueSetUpCardsViewOutdated = persistor.continueSetUpCardsNumberOfDaysDemonstrated >= Constants.dismissNextStepsCardsAfterDays + self.featureFlagger = featureFlagger self.continueSetUpCardsClosed = persistor.continueSetUpCardsClosed currentThemeName = .init(rawValue: persistor.currentThemeName) ?? .systemDefault showFullURL = persistor.showFullURL @@ -359,6 +368,7 @@ final class AppearancePreferences: ObservableObject { private var persistor: AppearancePreferencesPersistor private var homePageNavigator: HomePageNavigator + private let featureFlagger: () -> FeatureFlagger private let dateTimeProvider: () -> Date private func requestSync() { diff --git a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift index 606e3955ab..d8e77d151b 100644 --- a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift @@ -108,7 +108,7 @@ extension Preferences { if addressBarModel.shouldShowAddressBar { ToggleMenuItem(UserText.newTabSearchBarSectionTitle, isOn: $model.isSearchBarVisible) } - if model.isContinueSetUpAvailable && !model.isContinueSetUpCardsViewOutdated && !model.continueSetUpCardsClosed { + if model.isContinueSetUpCardsVisibilityControlAvailable && model.isContinueSetUpAvailable && !model.isContinueSetUpCardsViewOutdated && !model.continueSetUpCardsClosed { ToggleMenuItem(UserText.newTabSetUpSectionTitle, isOn: $model.isContinueSetUpVisible) } ToggleMenuItem(UserText.newTabFavoriteSectionTitle, isOn: $model.isFavoriteVisible).accessibilityIdentifier("Preferences.AppearanceView.showFavoritesToggle") diff --git a/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift b/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift index ed34723192..a2662cd94e 100644 --- a/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesGeneralView.swift @@ -191,7 +191,7 @@ extension Preferences { .disabled(downloadsModel.alwaysRequestDownloadLocation) ToggleMenuItem(UserText.downloadsAlwaysAsk, - isOn: $downloadsModel.alwaysRequestDownloadLocation) + isOn: $downloadsModel.alwaysRequestDownloadLocation).accessibilityIdentifier("PreferencesGeneralView.alwaysAskWhereToSaveFiles") } } diff --git a/DuckDuckGo/Preferences/View/PreferencesRootView.swift b/DuckDuckGo/Preferences/View/PreferencesRootView.swift index b9f186fc68..c1552f3cfc 100644 --- a/DuckDuckGo/Preferences/View/PreferencesRootView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesRootView.swift @@ -163,7 +163,7 @@ enum Preferences { case .activateAddEmailClick: PixelKit.fire(PrivacyProPixel.privacyProRestorePurchaseEmailStart, frequency: .legacyDailyAndCount) case .postSubscriptionAddEmailClick: - PixelKit.fire(PrivacyProPixel.privacyProWelcomeAddDevice, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProWelcomeAddDevice, frequency: .uniqueByName) case .restorePurchaseStoreClick: PixelKit.fire(PrivacyProPixel.privacyProRestorePurchaseStoreStart, frequency: .legacyDailyAndCount) case .addDeviceEnterEmail: diff --git a/DuckDuckGo/Statistics/ATB/StatisticsLoader.swift b/DuckDuckGo/Statistics/ATB/StatisticsLoader.swift index b63adf3d0d..a4e6f1a4b2 100644 --- a/DuckDuckGo/Statistics/ATB/StatisticsLoader.swift +++ b/DuckDuckGo/Statistics/ATB/StatisticsLoader.swift @@ -21,6 +21,7 @@ import Foundation import BrowserServicesKit import Networking import PixelKit +import PixelExperimentKit import os.log final class StatisticsLoader { @@ -34,15 +35,21 @@ final class StatisticsLoader { private let attributionPixelHandler: InstallationAttributionsPixelHandler private let parser = AtbParser() private var isAppRetentionRequestInProgress = false + private let fireSearchExperimentPixels: () -> Void + private let fireAppRetentionExperimentPixels: () -> Void init( statisticsStore: StatisticsStore = LocalStatisticsStore(), emailManager: EmailManager = EmailManager(), - attributionPixelHandler: InstallationAttributionsPixelHandler = AppInstallationAttributionPixelHandler() + attributionPixelHandler: InstallationAttributionsPixelHandler = AppInstallationAttributionPixelHandler(), + fireAppRetentionExperimentPixels: @escaping () -> Void = PixelKit.fireAppRetentionExperimentPixels, + fireSearchExperimentPixels: @escaping () -> Void = PixelKit.fireSearchExperimentPixels ) { self.statisticsStore = statisticsStore self.emailManager = emailManager self.attributionPixelHandler = attributionPixelHandler + self.fireSearchExperimentPixels = fireSearchExperimentPixels + self.fireAppRetentionExperimentPixels = fireAppRetentionExperimentPixels } func refreshRetentionAtb(isSearch: Bool, completion: @escaping Completion = {}) { @@ -57,13 +64,16 @@ final class StatisticsLoader { } PixelExperiment.fireSerpPixel() PixelExperiment.fireOnboardingSearchPerformed5to7Pixel() + self.fireSearchExperimentPixels() if NSApp.runType == .normal { self.fireDailyOsVersionCounterPixel() } self.fireDockPixel() } else if !self.statisticsStore.isAppRetentionFiredToday { self.refreshAppRetentionAtb(completion: completion) + self.fireAppRetentionExperimentPixels() } else { + self.fireAppRetentionExperimentPixels() completion() } } diff --git a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift index 29354841b7..8ade538671 100644 --- a/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift +++ b/DuckDuckGo/Tab/UserScripts/Subscription/SubscriptionPagesUseSubscriptionFeature.swift @@ -278,7 +278,7 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { PixelKit.fire(PrivacyProPixel.privacyProPurchaseSuccess, frequency: .legacyDailyAndCount) sendFreemiumSubscriptionPixelIfFreemiumActivated() saveSubscriptionUpgradeTimestampIfFreemiumActivated() - PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActivated, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActivated, frequency: .uniqueByName) subscriptionSuccessPixelHandler.fireSuccessfulSubscriptionAttributionPixel() sendSubscriptionUpgradeFromFreemiumNotificationIfFreemiumActivated() await pushPurchaseUpdate(originalMessage: message, purchaseUpdate: purchaseUpdate) @@ -354,14 +354,14 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { switch featureSelection.productFeature { case .networkProtection: - PixelKit.fire(PrivacyProPixel.privacyProWelcomeVPN, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProWelcomeVPN, frequency: .uniqueByName) notificationCenter.post(name: .ToggleNetworkProtectionInMainWindow, object: self, userInfo: nil) case .dataBrokerProtection: - PixelKit.fire(PrivacyProPixel.privacyProWelcomePersonalInformationRemoval, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProWelcomePersonalInformationRemoval, frequency: .uniqueByName) notificationCenter.post(name: .openPersonalInformationRemoval, object: self, userInfo: nil) await uiHandler.showTab(with: .dataBrokerProtection) case .identityTheftRestoration, .identityTheftRestorationGlobal: - PixelKit.fire(PrivacyProPixel.privacyProWelcomeIdentityRestoration, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProWelcomeIdentityRestoration, frequency: .uniqueByName) let url = subscriptionManager.url(for: .identityTheftRestoration) await uiHandler.showTab(with: .identityTheftRestoration(url)) case .unknown: @@ -402,12 +402,12 @@ final class SubscriptionPagesUseSubscriptionFeature: Subfeature { } func subscriptionsAddEmailSuccess(params: Any, original: WKScriptMessage) async -> Encodable? { - PixelKit.fire(PrivacyProPixel.privacyProAddEmailSuccess, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProAddEmailSuccess, frequency: .uniqueByName) return nil } func subscriptionsWelcomeFaqClicked(params: Any, original: WKScriptMessage) async -> Encodable? { - PixelKit.fire(PrivacyProPixel.privacyProWelcomeFAQClick, frequency: .unique) + PixelKit.fire(PrivacyProPixel.privacyProWelcomeFAQClick, frequency: .uniqueByName) return nil } diff --git a/Gemfile.lock b/Gemfile.lock index b1166baedf..7b9d584b21 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/duckduckgo/fastlane-plugin-ddg_apple_automation - revision: f8f3d4df64ee94518a2db518468df1880f2d2d95 - tag: 0.12.0 + revision: a0a94315a8c970f3a7cd20572a0b55e465c1b45a + tag: 0.12.1 specs: - fastlane-plugin-ddg_apple_automation (0.12.0) + fastlane-plugin-ddg_apple_automation (0.12.1) asana climate_control httpparty diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index 32d3c02db7..35ceb38061 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "217.0.0"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "218.0.0"), .package(path: "../SwiftUIExtensions"), .package(path: "../AppKitExtensions"), .package(path: "../XPCHelper"), diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift index 490337ffae..fd5c8ef0b7 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/FreemiumDBPExperimentPixel.swift @@ -25,9 +25,9 @@ public class FreemiumDBPExperimentPixelHandler: EventMapping { + $isViewExpanded.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + @Published var cards: [NewTabPageNextStepsCardsClient.CardID] = [] + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + $cards.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + handleActionCalls.append(card) + } + + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + dismissCalls.append(card) + } + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + willDisplayCardsCalls.append(cards) + willDisplayCardsImpl?(cards) + } + + var handleActionCalls: [NewTabPageNextStepsCardsClient.CardID] = [] + var dismissCalls: [NewTabPageNextStepsCardsClient.CardID] = [] + var willDisplayCardsCalls: [[NewTabPageNextStepsCardsClient.CardID]] = [] + var willDisplayCardsImpl: (([NewTabPageNextStepsCardsClient.CardID]) -> Void)? +} + +final class NewTabPageNextStepsCardsClientTests: XCTestCase { + var client: NewTabPageNextStepsCardsClient! + var model: CapturingNewTabPageNextStepsCardsProvider! + var userScript: NewTabPageUserScript! + + @MainActor + override func setUpWithError() throws { + try super.setUpWithError() + model = CapturingNewTabPageNextStepsCardsProvider() + client = NewTabPageNextStepsCardsClient(model: model) + + userScript = NewTabPageUserScript() + 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)) + 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)) + XCTAssertEqual(model.dismissCalls, [.defaultApp, .duckplayer, .bringStuff]) + } + + // MARK: - getConfig + + func testWhenNextStepsViewIsExpandedThenGetConfigReturnsExpandedState() async throws { + model.isViewExpanded = true + let config: NewTabPageUserScript.WidgetConfig = try await 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) + XCTAssertEqual(config.animation, .auto) + XCTAssertEqual(config.expansion, .collapsed) + } + + // MARK: - setConfig + + func testWhenSetConfigContainsExpandedStateThenModelSettingIsSetToExpanded() async throws { + model.isViewExpanded = false + let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .expanded) + try await 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) + XCTAssertEqual(model.isViewExpanded, false) + } + + // MARK: - getData + + func testThatGetDataReturnsCardsFromTheModel() async throws { + model.cards = [ + .addAppToDockMac, + .duckplayer, + .bringStuff + ] + let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + XCTAssertEqual(data, .init(content: [ + .init(id: .addAppToDockMac), + .init(id: .duckplayer), + .init(id: .bringStuff) + ])) + } + + func testWhenCardsAreEmptyThenGetDataReturnsNilContent() async throws { + model.cards = [] + let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + XCTAssertEqual(data, .init(content: nil)) + } + + // MARK: - willDisplayCardsPublisher + + func testThatWillDisplayCardsPublisherIsSentAfterGetDataAndGetConfigAreCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsNotSentBeforeGetConfigIsCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, []) + + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsNotSentBeforeGetDataIsCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, []) + + _ = try await client.getData(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterUpdatingCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = true + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer, .bringStuff]]) + } + + func testWhenCardsAreUpdatedThenWillDisplayCardsEventOnlyContainsCurrentlyVisibleCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.expectedFulfillmentCount = 3 + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + model.cards = [.duckplayer, .addAppToDockMac, .bringStuff] + model.cards = [.addAppToDockMac, .emailProtection, .duckplayer] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [ + [.addAppToDockMac, .duckplayer], + [.duckplayer, .addAppToDockMac], + [.addAppToDockMac, .emailProtection] + ]) + } + + func testThatWillDisplayCardsEventIsNotPublishedWhenCardsIsEmpty() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.expectedFulfillmentCount = 2 + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + model.cards = [] + model.cards = [.addAppToDockMac, .emailProtection, .duckplayer] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [ + [.addAppToDockMac, .duckplayer], + [.addAppToDockMac, .emailProtection] + ]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterExpandingViewToRevealMoreCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer, .emailProtection, .bringStuff, .defaultApp] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = true + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [[.emailProtection, .bringStuff, .defaultApp]]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterExpandingViewAndNotRevealingMoreCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.isInverted = true + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = true + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, []) + } + + func testThatWillDisplayCardsPublisherIsNotSentAfterCollapsingView() async throws { + model.cards = [.addAppToDockMac, .duckplayer, .emailProtection] + model.isViewExpanded = true + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.isInverted = true + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = false + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, []) + } + + // MARK: - Helper functions + + func triggerInitialCardsEventAndResetMockState() async throws { + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + model.willDisplayCardsCalls = [] + } + + 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 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/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift b/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift index 9df50c7f24..b2f9238e7c 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift @@ -49,13 +49,13 @@ final class OnboardingPixelReporterTests: XCTestCase { func test_WhenTrackSiteSuggestionOptionTapped_ThenSiteSuggetionOptionTappedEventSent() throws { reporter.trackSiteSuggetionOptionTapped() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.siteSuggetionOptionTapped.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackSearchSuggetionOptionTapped_ThenSearchSuggetionOptionTappedEventSent() throws { reporter.trackSearchSuggetionOptionTapped() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.searchSuggetionOptionTapped.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackAddressBarTypedIn_ThenDependingOnTheState_CorrectPixelsAreSent() throws { @@ -66,10 +66,10 @@ final class OnboardingPixelReporterTests: XCTestCase { reporter.trackAddressBarTypedIn() if state == .showTryASearch { XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingSearchCustom.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } else if state == .showTryASite { XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingVisitSiteCustom.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } else { XCTAssertNil(eventSent) XCTAssertNil(frequency) @@ -80,26 +80,26 @@ final class OnboardingPixelReporterTests: XCTestCase { func test_WhenTrackFireButtonSkipped_ThenOnboardingFireButtonPromptSkipPressedSent() { reporter.trackFireButtonSkipped() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingFireButtonPromptSkipPressed.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackFireButtonTryIt_ThenOnboardingFireButtonTryItPressedSent() { reporter.trackFireButtonTryIt() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingFireButtonTryItPressed.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackLastDialogShown_ThenOnboardingFinishedSent() { reporter.trackLastDialogShown() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingFinished.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackFireButtonPressed_AndOnboardingNotCompleted_ThenOnboardingFireButtonPressedSent() { onboardingState.state = .showFireButton reporter.trackFireButtonPressed() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingFireButtonPressed.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackFireButtonPressed_AndOnboardingCompleted_ThenNoPixelSent() { @@ -113,7 +113,7 @@ final class OnboardingPixelReporterTests: XCTestCase { onboardingState.state = .showBlockedTrackers reporter.trackPrivacyDashboardOpened() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.onboardingPrivacyDashboardOpened.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) } func test_WhenTrackPrivacyDashboardOpened_AndOnboardingCompleted_ThenNoPixelSent() { @@ -130,7 +130,7 @@ final class OnboardingPixelReporterTests: XCTestCase { reporter.trackSiteVisited() XCTAssertEqual(eventSent?.name, ContextualOnboardingPixel.secondSiteVisited.name) - XCTAssertEqual(frequency, .unique) + XCTAssertEqual(frequency, .uniqueByName) eventSent = nil frequency = nil } diff --git a/UnitTests/Statistics/ATB/StatisticsLoaderTests.swift b/UnitTests/Statistics/ATB/StatisticsLoaderTests.swift index f8ec2cff39..91415c2a36 100644 --- a/UnitTests/Statistics/ATB/StatisticsLoaderTests.swift +++ b/UnitTests/Statistics/ATB/StatisticsLoaderTests.swift @@ -19,6 +19,7 @@ import XCTest import OHHTTPStubs import OHHTTPStubsSwift +import PixelExperimentKit @testable import PixelKit @testable import DuckDuckGo_Privacy_Browser @@ -27,6 +28,8 @@ class StatisticsLoaderTests: XCTestCase { private var mockAttributionsPixelHandler: MockAttributionsPixelHandler! private var mockStatisticsStore: StatisticsStore! private var testee: StatisticsLoader! + private var fireAppRetentionExperimentPixelsCalled = false + private var fireSearchExperimentPixelsCalled = false let pixelKit = PixelKit(dryRun: true, appVersion: "1.0.0", defaultHeaders: [:], @@ -38,7 +41,10 @@ class StatisticsLoaderTests: XCTestCase { mockAttributionsPixelHandler = MockAttributionsPixelHandler() mockStatisticsStore = MockStatisticsStore() - testee = StatisticsLoader(statisticsStore: mockStatisticsStore, attributionPixelHandler: mockAttributionsPixelHandler) + testee = StatisticsLoader(statisticsStore: mockStatisticsStore, + attributionPixelHandler: mockAttributionsPixelHandler, + fireAppRetentionExperimentPixels: { self.fireAppRetentionExperimentPixelsCalled = true }, + fireSearchExperimentPixels: { self.fireSearchExperimentPixelsCalled = true }) } override func tearDown() { @@ -47,6 +53,8 @@ class StatisticsLoaderTests: XCTestCase { mockStatisticsStore = nil mockAttributionsPixelHandler = nil testee = nil + fireAppRetentionExperimentPixelsCalled = false + fireSearchExperimentPixelsCalled = false super.tearDown() } @@ -251,6 +259,7 @@ class StatisticsLoaderTests: XCTestCase { testee.refreshRetentionAtb(isSearch: true) { XCTAssertEqual(self.mockStatisticsStore.atb, "v20-1") XCTAssertEqual(self.mockStatisticsStore.searchRetentionAtb, "v77-5") + XCTAssertTrue(self.fireSearchExperimentPixelsCalled) expect.fulfill() } @@ -270,6 +279,8 @@ class StatisticsLoaderTests: XCTestCase { XCTAssertEqual(self.mockStatisticsStore.appRetentionAtb, "v77-5") XCTAssertEqual(self.mockStatisticsStore.searchRetentionAtb, "searchRetentionAtb") XCTAssertTrue(self.mockStatisticsStore.isAppRetentionFiredToday) + XCTAssertTrue(self.fireAppRetentionExperimentPixelsCalled) + XCTAssertFalse(self.fireSearchExperimentPixelsCalled) expect.fulfill() } @@ -289,6 +300,8 @@ class StatisticsLoaderTests: XCTestCase { XCTAssertEqual(self.mockStatisticsStore.appRetentionAtb, "appRetentionAtb") XCTAssertEqual(self.mockStatisticsStore.searchRetentionAtb, "searchRetentionAtb") XCTAssertTrue(self.mockStatisticsStore.isAppRetentionFiredToday) + XCTAssertTrue(self.fireAppRetentionExperimentPixelsCalled) + XCTAssertFalse(self.fireSearchExperimentPixelsCalled) expect.fulfill() } @@ -304,6 +317,8 @@ class StatisticsLoaderTests: XCTestCase { XCTAssertEqual(self.mockStatisticsStore.appRetentionAtb, "v77-5") XCTAssertNil(self.mockStatisticsStore.searchRetentionAtb) XCTAssertTrue(self.mockStatisticsStore.isAppRetentionFiredToday) + XCTAssertTrue(self.fireAppRetentionExperimentPixelsCalled) + XCTAssertFalse(self.fireSearchExperimentPixelsCalled) expect.fulfill() } diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile index bbb081f896..13db66a800 100644 --- a/fastlane/Pluginfile +++ b/fastlane/Pluginfile @@ -2,4 +2,4 @@ # # Ensure this file is checked in to source control! -gem 'fastlane-plugin-ddg_apple_automation', git: 'https://github.com/duckduckgo/fastlane-plugin-ddg_apple_automation', tag: "0.12.0" +gem 'fastlane-plugin-ddg_apple_automation', git: 'https://github.com/duckduckgo/fastlane-plugin-ddg_apple_automation', tag: "0.12.1"