diff --git a/Core/Pixel.swift b/Core/Pixel.swift index 9a31ed5f56..b569bea727 100644 --- a/Core/Pixel.swift +++ b/Core/Pixel.swift @@ -143,6 +143,10 @@ public struct PixelParameters { // Autofill public static let countBucket = "count_bucket" + + // Privacy Dashboard + public static let daysSinceInstall = "daysSinceInstall" + public static let fromOnboarding = "from_onboarding" } public struct PixelValues { diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 2748793c24..300b2b63a1 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -44,7 +44,8 @@ extension Pixel { case forgetAllDataCleared case privacyDashboardOpened - + case privacyDashboardFirstTimeOpenedUnique + case dashboardProtectionAllowlistAdd case dashboardProtectionAllowlistRemove @@ -142,18 +143,27 @@ extension Pixel { case onboardingIntroShownUnique case onboardingIntroComparisonChartShownUnique case onboardingIntroChooseBrowserCTAPressed - - case daxDialogsSerp - case daxDialogsWithoutTrackers + case onboardingContextualSearchOptionTappedUnique + case onboardingContextualSearchCustomUnique + case onboardingContextualSiteOptionTappedUnique + case onboardingContextualSiteCustomUnique + case onboardingContextualSecondSiteVisitUnique + case onboardingContextualTrySearchUnique + case onboardingContextualTryVisitSiteUnique + + case daxDialogsSerpUnique + case daxDialogsWithoutTrackersUnique case daxDialogsWithoutTrackersFollowUp - case daxDialogsWithTrackers - case daxDialogsSiteIsMajor - case daxDialogsSiteOwnedByMajor - case daxDialogsHidden - case daxDialogsFireEducationShown - case daxDialogsFireEducationConfirmed - case daxDialogsFireEducationCancelled - + case daxDialogsWithTrackersUnique + case daxDialogsSiteIsMajorUnique + case daxDialogsSiteOwnedByMajorUnique + case daxDialogsHiddenUnique + case daxDialogsFireEducationShownUnique + case daxDialogsFireEducationConfirmedUnique + case daxDialogsFireEducationCancelledUnique + case daxDialogsEndOfJourneyTabUnique + case daxDialogsEndOfJourneyNewTabUnique + case widgetsOnboardingCTAPressed case widgetsOnboardingDeclineOptionPressed case widgetsOnboardingMovedToBackground @@ -759,7 +769,8 @@ extension Pixel.Event { case .forgetAllDataCleared: return "mf_dc" case .privacyDashboardOpened: return "mp" - + case .privacyDashboardFirstTimeOpenedUnique: return "m_privacy_dashboard_first_time_used_unique" + case .dashboardProtectionAllowlistAdd: return "mp_wla" case .dashboardProtectionAllowlistRemove: return "mp_wlr" @@ -865,18 +876,27 @@ extension Pixel.Event { case .onboardingIntroShownUnique: return "m_preonboarding_intro_shown_unique" case .onboardingIntroComparisonChartShownUnique: return "m_preonboarding_comparison_chart_shown_unique" case .onboardingIntroChooseBrowserCTAPressed: return "m_preonboarding_choose_browser_pressed" - - case .daxDialogsSerp: return "m_dx_s" - case .daxDialogsWithoutTrackers: return "m_dx_wo" + case .onboardingContextualSearchOptionTappedUnique: return "m_onboarding_search_option_tapped_unique" + case .onboardingContextualSiteOptionTappedUnique: return "m_onboarding_visit_site_option_tapped_unique" + case .onboardingContextualSecondSiteVisitUnique: return "m_second_sitevisit_unique" + case .onboardingContextualSearchCustomUnique: return "m_onboarding_search_custom_unique" + case .onboardingContextualSiteCustomUnique: return "m_onboarding_visit_site_custom_unique" + case .onboardingContextualTrySearchUnique: return "m_dx_try_a_search_unique" + case .onboardingContextualTryVisitSiteUnique: return "m_dx_try_visit_site_unique" + + case .daxDialogsSerpUnique: return "m_dx_s_unique" + case .daxDialogsWithoutTrackersUnique: return "m_dx_wo_unique" case .daxDialogsWithoutTrackersFollowUp: return "m_dx_wof" - case .daxDialogsWithTrackers: return "m_dx_wt" - case .daxDialogsSiteIsMajor: return "m_dx_sm" - case .daxDialogsSiteOwnedByMajor: return "m_dx_so" - case .daxDialogsHidden: return "m_dx_h" - case .daxDialogsFireEducationShown: return "m_dx_fe_s" - case .daxDialogsFireEducationConfirmed: return "m_dx_fe_co" - case .daxDialogsFireEducationCancelled: return "m_dx_fe_ca" - + case .daxDialogsWithTrackersUnique: return "m_dx_wt_unique" + case .daxDialogsSiteIsMajorUnique: return "m_dx_sm_unique" + case .daxDialogsSiteOwnedByMajorUnique: return "m_dx_so_unique" + case .daxDialogsHiddenUnique: return "m_dx_h_unique" + case .daxDialogsFireEducationShownUnique: return "m_dx_fe_s_unique" + case .daxDialogsFireEducationConfirmedUnique: return "m_dx_fe_co_unique" + case .daxDialogsFireEducationCancelledUnique: return "m_dx_fe_ca_unique" + case .daxDialogsEndOfJourneyTabUnique: return "m_dx_end_tab_unique" + case .daxDialogsEndOfJourneyNewTabUnique: return "m_dx_end_new_tab_unique" + case .widgetsOnboardingCTAPressed: return "m_o_w_a" case .widgetsOnboardingDeclineOptionPressed: return "m_o_w_d" case .widgetsOnboardingMovedToBackground: return "m_o_w_b" diff --git a/Core/TimerInterface.swift b/Core/TimerInterface.swift index 0fa36283f5..909ee7dd11 100644 --- a/Core/TimerInterface.swift +++ b/Core/TimerInterface.swift @@ -28,15 +28,25 @@ public protocol TimerInterface: AnyObject { extension Timer: TimerInterface {} public protocol TimerCreating: AnyObject { - func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface + func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface +} + +public extension TimerCreating { + + func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface { + makeTimer(withTimeInterval: interval, repeats: repeats, on: .main, block: block) + } + } public final class TimerFactory: TimerCreating { public init() {} - public func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface { - Timer.scheduledTimer(withTimeInterval: interval, repeats: repeats, block: block) + public func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface { + let timer = Timer(timeInterval: interval, repeats: repeats, block: block) + runLoop.add(timer, forMode: .common) + return timer } } diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 3756b727b4..99f37ee416 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -49,7 +49,12 @@ public struct UserDefaultsWrapper { case daxBrowsingMajorTrackingSiteShown = "com.duckduckgo.ios.daxOnboardingBrowsingMajorTrackingSiteShown" case daxBrowsingOwnedByMajorTrackingSiteShown = "com.duckduckgo.ios.daxOnboardingBrowsingOwnedByMajorTrackingSiteShown" case daxFireButtonEducationShownOrExpired = "com.duckduckgo.ios.daxfireButtonEducationShownOrExpired" + case daxFireMessageExperimentShown = "com.duckduckgo.ios.fireMessageShown" case fireButtonPulseDateShown = "com.duckduckgo.ios.fireButtonPulseDateShown" + case privacyButtonPulseShown = "com.duckduckgo.ios.privacyButtonPulseShown" + case daxBrowsingFinalDialogShown = "com.duckduckgo.ios.daxOnboardingFinalDialogSeen" + case daxLastVisitedOnboardingWebsite = "com.duckduckgo.ios.daxOnboardingLastVisitedWebsite" + case daxLastShownContextualOnboardingDialogType = "com.duckduckgo.ios.daxLastShownContextualOnboardingDialogType" case notFoundCache = "com.duckduckgo.ios.favicons.notFoundCache" case faviconSizeNeedsMigration = "com.duckduckgo.ios.favicons.sizeNeedsMigration" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 35e15de26c..ab2ede28a8 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -236,6 +236,15 @@ 4BE67B072B96B9B0007335F7 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 4BE67B062B96B9B0007335F7 /* Common */; }; 4BF3E4AF2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BF3E4AE2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift */; }; 560E990F2BEE2CB800507CE0 /* SyncErrorMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 560E990E2BEE2CB800507CE0 /* SyncErrorMessage.swift */; }; + 564DE4512C3EBF2E00D23241 /* OnboardingSuggestionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4502C3EBF2E00D23241 /* OnboardingSuggestionsViewModel.swift */; }; + 564DE4532C3ED1B700D23241 /* NewTabDaxDialogFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4522C3ED1B700D23241 /* NewTabDaxDialogFactory.swift */; }; + 564DE4552C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4542C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift */; }; + 564DE4572C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4562C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift */; }; + 564DE45A2C450BE600D23241 /* DaxDialogsNewTabTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4592C450BE600D23241 /* DaxDialogsNewTabTests.swift */; }; + 564DE45C2C45160500D23241 /* OnboardingSuggestionsViewModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE45B2C45160500D23241 /* OnboardingSuggestionsViewModelsTests.swift */; }; + 564DE45E2C45218500D23241 /* OnboardingNavigationDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE45D2C45218500D23241 /* OnboardingNavigationDelegateTests.swift */; }; + 564DE4602C4544CA00D23241 /* HomePageDependencies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE45F2C4544CA00D23241 /* HomePageDependencies.swift */; }; + 564DE4622C4546BE00D23241 /* HomeViewController+DaxDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 564DE4612C4546BE00D23241 /* HomeViewController+DaxDialogs.swift */; }; 566B73702BECD46800FF1959 /* MainViewController+SyncAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B736F2BECD46800FF1959 /* MainViewController+SyncAlerts.swift */; }; 566B73732BECE4F200FF1959 /* SyncErrorHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B73712BECE4F200FF1959 /* SyncErrorHandling.swift */; }; 566B73762BECE53D00FF1959 /* SyncPausedStateManaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 566B73742BECE53D00FF1959 /* SyncPausedStateManaging.swift */; }; @@ -248,6 +257,13 @@ 569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437352BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift */; }; 56A061442BEE086700F24B36 /* CapturingAdapterErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437262BDD467400C0881B /* CapturingAdapterErrorHandler.swift */; }; 56A061452BEE086E00F24B36 /* CapturingSyncPausedStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 569437372BE530D300C0881B /* CapturingSyncPausedStateManager.swift */; }; + 56D060222C356B5C003BAEB5 /* ContextualDaxDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060212C356B5C003BAEB5 /* ContextualDaxDialog.swift */; }; + 56D060242C35918D003BAEB5 /* ContextualOnboardingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060232C35918D003BAEB5 /* ContextualOnboardingList.swift */; }; + 56D060262C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */; }; + 56D060282C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D060272C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift */; }; + 56D0602B2C381B23003BAEB5 /* OnboardingSuggestedSitesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D0602A2C381B23003BAEB5 /* OnboardingSuggestedSitesProvider.swift */; }; + 56D0602D2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D0602C2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift */; }; + 56D0602F2C384F70003BAEB5 /* OnboardingSuggestedSitesProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D0602E2C384F70003BAEB5 /* OnboardingSuggestedSitesProviderTests.swift */; }; 56D8556A2BEA9169009F9698 /* CurrentDateProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D855692BEA9169009F9698 /* CurrentDateProviding.swift */; }; 56D8556C2BEA91C4009F9698 /* SyncAlertsPresenting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56D8556B2BEA91C4009F9698 /* SyncAlertsPresenting.swift */; }; 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */; }; @@ -641,6 +657,22 @@ 9F23B8032C2BCD0000950875 /* DaxDialogStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */; }; 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */; }; 9F23B8092C2BE9B700950875 /* MockURLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */; }; + 9F4CC5152C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5142C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift */; }; + 9F4CC5172C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5162C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift */; }; + 9F4CC51B2C48C0C7006A96EB /* MockTabDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC51A2C48C0C7006A96EB /* MockTabDelegate.swift */; }; + 9F4CC51D2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC51C2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift */; }; + 9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC51E2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift */; }; + 9F4CC5242C4A4F0D006A96EB /* SwiftUITestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5232C4A4F0D006A96EB /* SwiftUITestUtilities.swift */; }; + 9F4CC5272C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4CC5262C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift */; }; + 9F5E5AAC2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */; }; + 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */; }; + 9F5E5AB22C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */; }; + 9F69331B2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331A2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift */; }; + 9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */; }; + 9F6933192C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6933182C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift */; }; + 9F69331F2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */; }; + 9F6933212C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */; }; + 9F8007262C5261AF003EDAF4 /* MockPrivacyDataReporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */; }; 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 9F8FE9482BAE50E50071E372 /* Lottie */; }; 9F9EE4CE2C377D4900D4118E /* OnboardingFirePixelMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */; }; 9F9EE4D42C37BB1300D4118E /* OnboardingView+Landing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */; }; @@ -867,7 +899,6 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */ = {isa = PBXBuildFile; fileRef = D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */; }; D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */; }; D670E5BB2BB6A75300941A42 /* SubscriptionNavigationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */; }; - D670E5BD2BB6AA0000941A42 /* View+AppearModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */; }; D67969112BC84CE700BA8B34 /* SubscriptionContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67969102BC84CE700BA8B34 /* SubscriptionContainerViewModel.swift */; }; D68A21442B7EC08500BB372E /* SubscriptionExternalLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */; }; D68A21462B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */; }; @@ -1403,6 +1434,15 @@ 4BE27566272F878F006B20B0 /* URLRequestExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = URLRequestExtension.swift; path = ../DuckDuckGo/URLRequestExtension.swift; sourceTree = ""; }; 4BF3E4AE2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNRedditSessionWorkaround.swift; sourceTree = ""; }; 560E990E2BEE2CB800507CE0 /* SyncErrorMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorMessage.swift; sourceTree = ""; }; + 564DE4502C3EBF2E00D23241 /* OnboardingSuggestionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsViewModel.swift; sourceTree = ""; }; + 564DE4522C3ED1B700D23241 /* NewTabDaxDialogFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabDaxDialogFactory.swift; sourceTree = ""; }; + 564DE4542C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingNewTabDialogFactoryTests.swift; sourceTree = ""; }; + 564DE4562C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewControllerDaxDialogTests.swift; sourceTree = ""; }; + 564DE4592C450BE600D23241 /* DaxDialogsNewTabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxDialogsNewTabTests.swift; sourceTree = ""; }; + 564DE45B2C45160500D23241 /* OnboardingSuggestionsViewModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestionsViewModelsTests.swift; sourceTree = ""; }; + 564DE45D2C45218500D23241 /* OnboardingNavigationDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingNavigationDelegateTests.swift; sourceTree = ""; }; + 564DE45F2C4544CA00D23241 /* HomePageDependencies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePageDependencies.swift; sourceTree = ""; }; + 564DE4612C4546BE00D23241 /* HomeViewController+DaxDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeViewController+DaxDialogs.swift"; sourceTree = ""; }; 566B736F2BECD46800FF1959 /* MainViewController+SyncAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainViewController+SyncAlerts.swift"; sourceTree = ""; }; 566B73712BECE4F200FF1959 /* SyncErrorHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandling.swift; sourceTree = ""; }; 566B73742BECE53D00FF1959 /* SyncPausedStateManaging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncPausedStateManaging.swift; sourceTree = ""; }; @@ -1415,6 +1455,13 @@ 569437322BE4E3DD00C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncErrorHandlerSyncErrorsAlertsTests.swift; sourceTree = ""; }; 569437352BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsViewControllerErrorTests.swift; sourceTree = ""; }; 569437372BE530D300C0881B /* CapturingSyncPausedStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapturingSyncPausedStateManager.swift; sourceTree = ""; }; + 56D060212C356B5C003BAEB5 /* ContextualDaxDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialog.swift; sourceTree = ""; }; + 56D060232C35918D003BAEB5 /* ContextualOnboardingList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingList.swift; sourceTree = ""; }; + 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingDialogs.swift; sourceTree = ""; }; + 56D060272C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSearchesProvider.swift; sourceTree = ""; }; + 56D0602A2C381B23003BAEB5 /* OnboardingSuggestedSitesProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSitesProvider.swift; sourceTree = ""; }; + 56D0602C2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSearchesProviderTests.swift; sourceTree = ""; }; + 56D0602E2C384F70003BAEB5 /* OnboardingSuggestedSitesProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingSuggestedSitesProviderTests.swift; sourceTree = ""; }; 56D855692BEA9169009F9698 /* CurrentDateProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentDateProviding.swift; sourceTree = ""; }; 56D8556B2BEA91C4009F9698 /* SyncAlertsPresenting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAlertsPresenting.swift; sourceTree = ""; }; 6AC6DAB228804F97002723C0 /* BarsAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BarsAnimator.swift; sourceTree = ""; }; @@ -2337,6 +2384,22 @@ 9F23B8022C2BCD0000950875 /* DaxDialogStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaxDialogStyles.swift; sourceTree = ""; }; 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModelTests.swift; sourceTree = ""; }; 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockURLOpener.swift; sourceTree = ""; }; + 9F4CC5142C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenterMock.swift; sourceTree = ""; }; + 9F4CC5162C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabViewControllerDaxDialogTests.swift; sourceTree = ""; }; + 9F4CC51A2C48C0C7006A96EB /* MockTabDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTabDelegate.swift; sourceTree = ""; }; + 9F4CC51C2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataDatabaseTestUtilities.swift; sourceTree = ""; }; + 9F4CC51E2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialogsFactoryTests.swift; sourceTree = ""; }; + 9F4CC5232C4A4F0D006A96EB /* SwiftUITestUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUITestUtilities.swift; sourceTree = ""; }; + 9F4CC5262C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyIconContextualOnboardingAnimator.swift; sourceTree = ""; }; + 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualDaxDialogsFactory.swift; sourceTree = ""; }; + 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenter.swift; sourceTree = ""; }; + 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextualOnboardingPresenterTests.swift; sourceTree = ""; }; + 9F69331A2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingDaxFavouritesTests.swift; sourceTree = ""; }; + 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTutorialSettings.swift; sourceTree = ""; }; + 9F6933182C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPixelReporterMock.swift; sourceTree = ""; }; + 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnFirstAppearViewModifier.swift; sourceTree = ""; }; + 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingHostingControllerMock.swift; sourceTree = ""; }; + 9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPrivacyDataReporter.swift; sourceTree = ""; }; 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFirePixelMock.swift; sourceTree = ""; }; 9F9EE4D32C37BB1300D4118E /* OnboardingView+Landing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OnboardingView+Landing.swift"; sourceTree = ""; }; 9FA5E44A2BF1AF3400BDEF02 /* SubscriptionContainerViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewFactory.swift; sourceTree = ""; }; @@ -2573,7 +2636,6 @@ D668D92A2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesFeature.swift; sourceTree = ""; }; D66F683C2BB333C100AE93E2 /* SubscriptionContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerView.swift; sourceTree = ""; }; D670E5BA2BB6A75200941A42 /* SubscriptionNavigationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionNavigationCoordinator.swift; sourceTree = ""; }; - D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppearModifiers.swift"; sourceTree = ""; }; D67969102BC84CE700BA8B34 /* SubscriptionContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionContainerViewModel.swift; sourceTree = ""; }; D68A21432B7EC08500BB372E /* SubscriptionExternalLinkView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkView.swift; sourceTree = ""; }; D68A21452B7EC16200BB372E /* SubscriptionExternalLinkViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionExternalLinkViewModel.swift; sourceTree = ""; }; @@ -3032,6 +3094,7 @@ 6FD3F80E2C3EF4F000DA5797 /* DeviceOrientationEnvironmentValue.swift */, 9FEA222D2C324ECD006B03BF /* ViewVisibility.swift */, 9FEA22282C2E38FA006B03BF /* AnimatableTypingText.swift */, + 9F69331E2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift */, ); name = SwiftUI; sourceTree = ""; @@ -3507,6 +3570,27 @@ name = Mock; sourceTree = ""; }; + 56D060202C356B0B003BAEB5 /* ContextualDaxDialogs */ = { + isa = PBXGroup; + children = ( + 564DE4502C3EBF2E00D23241 /* OnboardingSuggestionsViewModel.swift */, + 56D060212C356B5C003BAEB5 /* ContextualDaxDialog.swift */, + 56D060232C35918D003BAEB5 /* ContextualOnboardingList.swift */, + 56D060252C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift */, + 564DE4522C3ED1B700D23241 /* NewTabDaxDialogFactory.swift */, + ); + path = ContextualDaxDialogs; + sourceTree = ""; + }; + 56D060292C381AD1003BAEB5 /* OnboardingHelpers */ = { + isa = PBXGroup; + children = ( + 56D060272C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift */, + 56D0602A2C381B23003BAEB5 /* OnboardingSuggestedSitesProvider.swift */, + ); + path = OnboardingHelpers; + sourceTree = ""; + }; 6F03CAF82C32C3AA004179A8 /* Messages */ = { isa = PBXGroup; children = ( @@ -3521,6 +3605,7 @@ 6F03CB002C32ED42004179A8 /* NewTabPageMessagesModelTests.swift */, 6F40D15C2C34436200BF22F0 /* HomePageDisplayDailyPixelBucketTests.swift */, 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */, + 564DE4562C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift */, ); name = NewTabPage; sourceTree = ""; @@ -4169,6 +4254,7 @@ 85C29705247BDCE60063A335 /* Dax */ = { isa = PBXGroup; children = ( + 564DE4592C450BE600D23241 /* DaxDialogsNewTabTests.swift */, 858650D22469BFAD00C36F8A /* DaxDialogTests.swift */, 85C29706247BDCFF0063A335 /* DaxDialogsBrowsingSpecTests.swift */, ); @@ -4410,17 +4496,46 @@ 9F23B8042C2BE20500950875 /* Onboarding */ = { isa = PBXGroup; children = ( - 9F9EE4CB2C377D2400D4118E /* Mocks */, 9F23B8052C2BE22700950875 /* OnboardingIntroViewModelTests.swift */, + 56D0602C2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift */, + 56D0602E2C384F70003BAEB5 /* OnboardingSuggestedSitesProviderTests.swift */, + 564DE4542C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift */, + 564DE45B2C45160500D23241 /* OnboardingSuggestionsViewModelsTests.swift */, + 564DE45D2C45218500D23241 /* OnboardingNavigationDelegateTests.swift */, + 9F9EE4CB2C377D2400D4118E /* Mocks */, 9FE05CEF2C3642F900D9046B /* OnboardingPixelReporterTests.swift */, + 9F5E5AB12C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift */, + 9F4CC5162C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift */, + 9F4CC51E2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift */, + 9F69331A2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift */, ); name = Onboarding; sourceTree = ""; }; + 9F4CC5252C4E22F9006A96EB /* ContextualOnboarding */ = { + isa = PBXGroup; + children = ( + 9F4CC5262C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift */, + ); + name = ContextualOnboarding; + sourceTree = ""; + }; + 9F5E5AAA2C3D0FAA00165F54 /* ContextualOnboarding */ = { + isa = PBXGroup; + children = ( + 9F5E5AAB2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift */, + 9F5E5AAF2C3E4C6000165F54 /* ContextualOnboardingPresenter.swift */, + ); + path = ContextualOnboarding; + sourceTree = ""; + }; 9F9EE4CB2C377D2400D4118E /* Mocks */ = { isa = PBXGroup; children = ( 9F9EE4CC2C377D3F00D4118E /* OnboardingFirePixelMock.swift */, + 9F4CC5142C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift */, + 9F6933182C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift */, + 9F6933202C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift */, ); name = Mocks; sourceTree = ""; @@ -4471,11 +4586,13 @@ isa = PBXGroup; children = ( 9FE05CEC2C36423C00D9046B /* Pixels */, + 56D060202C356B0B003BAEB5 /* ContextualDaxDialogs */, 9FE08BD42C2A60BD001D5EBC /* MetricBuilder */, 9FE08BD12C2A5B77001D5EBC /* Styles */, 9FB027172C26BC0F009EA190 /* BrowsersComparison */, 9FB027102C2526A8009EA190 /* DaxDialogs */, 9F23B7FF2C2BABE000950875 /* OnboardingIntro */, + 9F5E5AAA2C3D0FAA00165F54 /* ContextualOnboarding */, 9F23B8002C2BC94400950875 /* OnboardingBackground.swift */, ); path = OnboardingExperiment; @@ -4858,7 +4975,6 @@ children = ( F1FDC9342BF51E41006B1435 /* VPNSettings+Environment.swift */, D664C7982B289AA000CBFA76 /* WKUserContentController+Handler.swift */, - D670E5BC2BB6AA0000941A42 /* View+AppearModifiers.swift */, ); path = Extensions; sourceTree = ""; @@ -5153,6 +5269,7 @@ isa = PBXGroup; children = ( C1B7B53328944EFA0098FD6A /* CoreDataTestUtilities.swift */, + 9F4CC51C2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift */, 9846AA6622BD3BBF007DE48E /* InitHelpers.swift */, C14882E527F20DAA00D59F0C /* HtmlTestDataLoader.swift */, F1134ECF1F40EBE200B73467 /* JsonTestDataLoader.swift */, @@ -5160,6 +5277,7 @@ 85449F0023FEAF3000512AAF /* UserDefaultsExtension.swift */, 31B1FA86286EFC5C00CA3C1C /* XCTestCaseExtension.swift */, EE7917902A83DE93008DFF28 /* CombineTestUtilities.swift */, + 9F4CC5232C4A4F0D006A96EB /* SwiftUITestUtilities.swift */, ); name = TestUtils; sourceTree = ""; @@ -5254,9 +5372,11 @@ 85058365219AE9EA00ED4EDB /* HomePageConfiguration.swift */, 6F03CAFD2C32DD08004179A8 /* HomePageMessagesConfiguration.swift */, F16390811E648B7A005B4550 /* HomeViewController.swift */, + 564DE4612C4546BE00D23241 /* HomeViewController+DaxDialogs.swift */, 6F40D15A2C34423800BF22F0 /* HomePageDisplayDailyPixelBucket.swift */, 85058367219C49E000ED4EDB /* HomeViewSectionRenderers.swift */, 85C861E528FF1B5F00189466 /* HomeViewSectionRenderersExtension.swift */, + 564DE45F2C4544CA00D23241 /* HomePageDependencies.swift */, 85B9CB8321AEBD72009001F1 /* Cells */, 85374D3621AC417200FF5A1E /* Renderers */, ); @@ -5453,6 +5573,7 @@ F17669A91E412A17003D3222 /* Mocks */ = { isa = PBXGroup; children = ( + 9F4CC51A2C48C0C7006A96EB /* MockTabDelegate.swift */, C14882E927F20DD000D59F0C /* MockBookmarksCoreDataStorage.swift */, 98B3128F218CCB2200E54DE1 /* MockDependencyProvider.swift */, C158AC7A297AB5DC0008723A /* MockSecureVault.swift */, @@ -5466,6 +5587,8 @@ 6F03CB032C32EFA8004179A8 /* MockPixelFiring.swift */, 9F23B8082C2BE9B700950875 /* MockURLOpener.swift */, 9FEA22332C3271DC006B03BF /* MockTimer.swift */, + 9F8007252C5261AF003EDAF4 /* MockPrivacyDataReporter.swift */, + 9F69331C2C5A191400CD6A5D /* MockTutorialSettings.swift */, ); name = Mocks; sourceTree = ""; @@ -5601,6 +5724,7 @@ F1BE54481E69DD5F00FCF649 /* Onboarding */ = { isa = PBXGroup; children = ( + 56D060292C381AD1003BAEB5 /* OnboardingHelpers */, 9FF7E9802C22A19800902BE5 /* OnboardingExperiment */, 984147AA24F0259000362052 /* Onboarding.storyboard */, 851B128722200575004781BC /* Onboarding.swift */, @@ -5630,6 +5754,7 @@ 1EEF123E2850A68A003DDE57 /* PrivacyInfoContainerView.swift */, 1E7A71152934E4C700B7EA19 /* OmniBarNotifications */, 1EE411F42857C5130003FE64 /* PrivacyIconAndTrackers */, + 9F4CC5252C4E22F9006A96EB /* ContextualOnboarding */, ); name = OmniBar; sourceTree = ""; @@ -6835,6 +6960,7 @@ C12726EE2A5FF88C00215B02 /* EmailSignupPromptView.swift in Sources */, 83134D7D20E2D725006CE65D /* FeedbackSender.swift in Sources */, B652DF12287C336E00C12A9C /* ContentBlockingUpdating.swift in Sources */, + 56D0602B2C381B23003BAEB5 /* OnboardingSuggestedSitesProvider.swift in Sources */, 314C92BA27C3E7CB0042EC96 /* QuickLookContainerViewController.swift in Sources */, 855D914D2063EF6A00C4B448 /* TabSwitcherTransition.swift in Sources */, CB258D1229A4F24900DEBA24 /* ConfigurationManager.swift in Sources */, @@ -6858,6 +6984,7 @@ F1BE54581E69DE1000FCF649 /* TutorialSettings.swift in Sources */, 1EE52ABB28FB1D6300B750C1 /* UIImageExtension.swift in Sources */, 858650D12469BCDE00C36F8A /* DaxDialogs.swift in Sources */, + 9F5E5AB02C3E4C6000165F54 /* ContextualOnboardingPresenter.swift in Sources */, 310D091B2799F54900DC0060 /* DownloadManager.swift in Sources */, 98D98A7425ED88D100D8E3DF /* BrowsingMenuEntryViewCell.swift in Sources */, F1564F032B7B915F00D454A6 /* AppDelegate+SKAD4.swift in Sources */, @@ -6880,6 +7007,7 @@ 988AC355257E47C100793C64 /* RequeryLogic.swift in Sources */, 6FB2A67A2C2C5BAE004D20C8 /* FavoriteEmptyStateItem.swift in Sources */, 6FBF0F8B2BD7C0A900136CF0 /* AllProtectedCell.swift in Sources */, + 9F4CC5242C4A4F0D006A96EB /* SwiftUITestUtilities.swift in Sources */, 1E4F4A5A297193DE00625985 /* MainViewController+CookiesManaged.swift in Sources */, C12324C32C4697C900FBB26B /* AutofillBreakageReportTableViewCell.swift in Sources */, 8586A10D24CBA7070049720E /* FindInPageActivity.swift in Sources */, @@ -6925,6 +7053,7 @@ F114C55B1E66EB020018F95F /* NibLoading.swift in Sources */, D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */, D668D9252B693778008E2FF2 /* SubscriptionITPView.swift in Sources */, + 9F5E5AAC2C3D0FCD00165F54 /* ContextualDaxDialogsFactory.swift in Sources */, C10CB5F32A1A5BDF0048E503 /* AutofillViews.swift in Sources */, 6FE127382C20492500EB5724 /* NewTabPage.swift in Sources */, 982E5630222C3D5B008D861B /* FeedbackPickerViewController.swift in Sources */, @@ -6971,6 +7100,7 @@ D68DF81E2B5830380023DBEA /* SubscriptionRestoreViewModel.swift in Sources */, F4F6DFB426E6B63700ED7E12 /* BookmarkFolderCell.swift in Sources */, D6F93E3E2B50A8A0004C268D /* SubscriptionSettingsView.swift in Sources */, + 56D060222C356B5C003BAEB5 /* ContextualDaxDialog.swift in Sources */, 1D200C9B2BA31A6A00108701 /* AboutView.swift in Sources */, 851B12CC22369931004781BC /* AtbAndVariantCleanup.swift in Sources */, D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, @@ -6996,6 +7126,7 @@ F4C9FBF528340DDA002281CC /* AutofillInterfaceEmailTruncator.swift in Sources */, 1E016AB42949FEB500F21625 /* OmniBarNotificationViewModel.swift in Sources */, 6AC6DAB328804F97002723C0 /* BarsAnimator.swift in Sources */, + 56D060242C35918D003BAEB5 /* ContextualOnboardingList.swift in Sources */, EE0153ED2A6FF9E6002A8B26 /* NetworkProtectionRootView.swift in Sources */, EEF0F8CC2ABC832300630031 /* NetworkProtectionDebugFeatures.swift in Sources */, B60DFF072872B64B0061E7C2 /* JSAlertController.swift in Sources */, @@ -7010,6 +7141,7 @@ D63FF8952C1B67E9006DE24D /* YoutubePlayerUserScript.swift in Sources */, 4BF3E4AF2C06A85200ED5D57 /* VPNRedditSessionWorkaround.swift in Sources */, 6F40D15B2C34423800BF22F0 /* HomePageDisplayDailyPixelBucket.swift in Sources */, + 564DE4512C3EBF2E00D23241 /* OnboardingSuggestionsViewModel.swift in Sources */, 311BD1AF2836BB4200AEF6C1 /* AutofillItemsLockedView.swift in Sources */, 85DE681A2B6A8BB000DED4FE /* MainViewCoordinator.swift in Sources */, F1617C151E57336D00DEDCAF /* TabManager.swift in Sources */, @@ -7035,6 +7167,7 @@ BDD3B3552B8EF8DB005857A8 /* NetworkProtectionUNNotificationPresenter.swift in Sources */, BD862E0B2B30F9300073E2EE /* VPNFeedbackFormView.swift in Sources */, 850365F323DE087800D0F787 /* UIImageViewExtension.swift in Sources */, + 56D060262C359D2E003BAEB5 /* ContextualOnboardingDialogs.swift in Sources */, 373608922ABB430D00629E7F /* FavoritesDisplayMode+UserDefaults.swift in Sources */, C160544129D6044D00B715A1 /* AutofillInterfaceUsernameTruncator.swift in Sources */, 31C70B5528045E3500FB6AD1 /* SecureVaultReporter.swift in Sources */, @@ -7052,6 +7185,7 @@ D63657192A7BAE7C001AF19D /* EmailManagerRequestDelegate.swift in Sources */, 1E4FAA6427D8DFB900ADC5B3 /* OngoingDownloadRowViewModel.swift in Sources */, 8C4724502217A14B004C9B2D /* TabViewControllerLongPressBookmarkExtension.swift in Sources */, + 564DE4532C3ED1B700D23241 /* NewTabDaxDialogFactory.swift in Sources */, 1EDE39D22705D4A200C99C72 /* FileSizeDebugViewController.swift in Sources */, 4B412ACC2BBB3D0900A39F5E /* LazyView.swift in Sources */, 85047C772A0D5D3D00D2FF3F /* SyncSettingsViewController+SyncDelegate.swift in Sources */, @@ -7078,6 +7212,7 @@ 85DB12ED2A1FED0C000A4A72 /* AppDelegate+AppDeepLinks.swift in Sources */, 98DA6ECA2181E41F00E65433 /* ThemeManager.swift in Sources */, F1D43AFC2B99C56000BAB743 /* RootDebugViewController+VanillaBrowser.swift in Sources */, + 564DE4602C4544CA00D23241 /* HomePageDependencies.swift in Sources */, C159DF072A430B60007834BB /* EmailSignupViewController.swift in Sources */, 37A6A8FE2AFD0208008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, F1CA3C3B1F045B65005FADB3 /* Authenticator.swift in Sources */, @@ -7179,7 +7314,6 @@ 984D035C24AE15CD0066CFB8 /* TabSwitcherSettings.swift in Sources */, D6E83C562B21ECC1006C8AFB /* SettingsLegacyViewProvider.swift in Sources */, 98B31292218CCB8C00E54DE1 /* AppDependencyProvider.swift in Sources */, - D670E5BD2BB6AA0000941A42 /* View+AppearModifiers.swift in Sources */, D6ACEA322BBD55BF008FADDF /* TabURLInterceptor.swift in Sources */, C13F3F6A2B7F883A0083BE40 /* AuthConfirmationPromptViewController.swift in Sources */, 851624C72B96389D002D5CD7 /* HistoryDebugViewController.swift in Sources */, @@ -7205,6 +7339,7 @@ 9FB027122C2526DD009EA190 /* OnboardingView+IntroDialogContent.swift in Sources */, 37FCAAAB29911BF1000E420A /* WaitlistExtensions.swift in Sources */, EE4BE0092A740BED00CD6AA8 /* ClearTextField.swift in Sources */, + 56D060282C380D83003BAEB5 /* OnboardingSuggestedSearchesProvider.swift in Sources */, F44D279C27F331BB0037F371 /* AutofillLoginPromptView.swift in Sources */, F1FDC9302BF4E0B3006B1435 /* SubscriptionEnvironment+Default.swift in Sources */, CBD4F13E279EBFAB00B20FD7 /* HomeMessageView.swift in Sources */, @@ -7244,6 +7379,7 @@ CB84C7BD29A3EF530088A5B8 /* AppConfigurationURLProvider.swift in Sources */, AA3D854723D9E88E00788410 /* AppIconSettingsCell.swift in Sources */, 316931D927BD22A80095F5ED /* DownloadActionMessageViewHelper.swift in Sources */, + 9F69331F2C5B1D0C00CD6A5D /* OnFirstAppearViewModifier.swift in Sources */, BDF8D0022C1B87F4003E3B27 /* NetworkProtectionDNSSettingsViewModel.swift in Sources */, 9838059F2228208E00385F1A /* PositiveFeedbackViewController.swift in Sources */, 8590CB67268A2E520089F6BF /* RootDebugViewController.swift in Sources */, @@ -7287,11 +7423,13 @@ C1B7B52528941F2A0098FD6A /* RemoteMessagingClient.swift in Sources */, 3132FA2827A0788400DD7A12 /* PassKitPreviewHelper.swift in Sources */, 6FA343922C3D3C3B00470677 /* FavoriteIconView.swift in Sources */, + 9F4CC5272C4E230C006A96EB /* PrivacyIconContextualOnboardingAnimator.swift in Sources */, 8505836C219F424500ED4EDB /* TextFieldWithInsets.swift in Sources */, CBD4F13F279EBFAF00B20FD7 /* HomeMessageViewModel.swift in Sources */, 1E4DCF4A27B6A38000961E25 /* DownloadListRepresentable.swift in Sources */, 1DEAADFB2BA71E9A00E25A97 /* SettingsPrivacyProtectionDescriptionView.swift in Sources */, 6F3537A02C4AAFD2009F8717 /* NewTabPageSettingsSectionItemView.swift in Sources */, + 564DE4622C4546BE00D23241 /* HomeViewController+DaxDialogs.swift in Sources */, 2DC3FC65C6D9DA634426672D /* AutofillNoAuthAvailableView.swift in Sources */, 6F03CAFC2C32C6F6004179A8 /* NewTabPageMessagesModel.swift in Sources */, ); @@ -7312,6 +7450,7 @@ 98DA35C4268CC81E00159906 /* DomainMatchingReportTests.swift in Sources */, 8590CB632684F10F0089F6BF /* ContentBlockerProtectionStoreTests.swift in Sources */, 83EDCC411F86B89C005CDFCD /* StatisticsLoaderTests.swift in Sources */, + 564DE4572C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift in Sources */, C14882E327F20D9A00D59F0C /* BookmarksExporterTests.swift in Sources */, 85C29708247BDD060063A335 /* DaxDialogsBrowsingSpecTests.swift in Sources */, 9FE05CF12C36468A00D9046B /* OnboardingPixelReporterTests.swift in Sources */, @@ -7321,6 +7460,7 @@ 31C138AC27A403CB00FFD4B2 /* DownloadManagerTests.swift in Sources */, BDE219EA2C457B46005D5884 /* PrivacyProDataReporterTests.swift in Sources */, EEFE9C732A603CE9005B0A26 /* NetworkProtectionStatusViewModelTests.swift in Sources */, + 9F4CC5152C47AD08006A96EB /* ContextualOnboardingPresenterMock.swift in Sources */, F13B4BF91F18CA0600814661 /* TabsModelTests.swift in Sources */, F1BDDBFD2C340D9C00459306 /* SubscriptionContainerViewModelTests.swift in Sources */, 987243142C5232B5007ECC76 /* BookmarksDatabaseSetupTests.swift in Sources */, @@ -7329,6 +7469,7 @@ 986B45D0299E30A50089D2D7 /* BookmarkEntityTests.swift in Sources */, B6AD9E3828D4512E0019CDE9 /* EmbeddedTrackerDataTests.swift in Sources */, 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */, + 9F4CC51B2C48C0C7006A96EB /* MockTabDelegate.swift in Sources */, 1E722729292EB24D003B5F53 /* AppSettingsMock.swift in Sources */, 8536A1C8209AF2410050739E /* MockVariantManager.swift in Sources */, C1B7B53428944EFA0098FD6A /* CoreDataTestUtilities.swift in Sources */, @@ -7375,11 +7516,14 @@ 8341D807212D5E8D000514C2 /* HashExtensionTest.swift in Sources */, C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */, 85D2187924BF6B8B004373D2 /* FaviconSourcesProviderTests.swift in Sources */, + 9F69331B2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift in Sources */, 983BD6B52B34760600AAC78E /* MockPrivacyConfiguration.swift in Sources */, 1E8146AD28C8ABF000D1AF63 /* TrackerAnimationLogicTests.swift in Sources */, C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */, B6AD9E3A28D456820019CDE9 /* PrivacyConfigurationManagerMock.swift in Sources */, F189AED71F18F6DE001EBAE1 /* TabTests.swift in Sources */, + 9F5E5AB22C3E606D00165F54 /* ContextualOnboardingPresenterTests.swift in Sources */, + 9F4CC5172C48B8D4006A96EB /* TabViewControllerDaxDialogTests.swift in Sources */, F13B4BFB1F18E3D900814661 /* TabsModelPersistenceExtensionTests.swift in Sources */, CB48D3372B90DF2000631D8B /* UserBehaviorMonitorTests.swift in Sources */, 8528AE7E212EF5FF00D0BD74 /* AppRatingPromptTests.swift in Sources */, @@ -7387,13 +7531,16 @@ 9F9EE4CE2C377D4900D4118E /* OnboardingFirePixelMock.swift in Sources */, 4BC21A2F27238B7500229F0E /* RunLoopExtensionTests.swift in Sources */, 314A3EFC293905EC00D3D4C8 /* BrokenSiteReportingTests.swift in Sources */, + 56D0602F2C384F70003BAEB5 /* OnboardingSuggestedSitesProviderTests.swift in Sources */, 851B1283221FE65E004781BC /* ImproveOnboardingExperiment1Tests.swift in Sources */, F194FAFB1F14E622009B4DF8 /* UIFontExtensionTests.swift in Sources */, 9F23B8092C2BE9B700950875 /* MockURLOpener.swift in Sources */, + 9F8007262C5261AF003EDAF4 /* MockPrivacyDataReporter.swift in Sources */, F40F843728C939760081AE75 /* AutofillLoginListViewModelTests.swift in Sources */, C14882E827F20DAB00D59F0C /* TestDataLoader.swift in Sources */, C14882EA27F20DD000D59F0C /* MockBookmarksCoreDataStorage.swift in Sources */, 1E05D1DB29C47B3300BF9A1F /* DailyPixelTests.swift in Sources */, + 564DE4552C3EDEF200D23241 /* ContextualOnboardingNewTabDialogFactoryTests.swift in Sources */, 981FED7422046017008488D7 /* AutoClearTests.swift in Sources */, 98DDF9F322C4029D00DE38DB /* InitHelpers.swift in Sources */, B6AD9E3628D4510A0019CDE9 /* ContentBlockerRulesManagerMock.swift in Sources */, @@ -7407,14 +7554,22 @@ C1BF0BA929B63E2200482B73 /* AutofillLoginPromptViewModelTests.swift in Sources */, EE3B226B29DE0F110082298A /* MockInternalUserStoring.swift in Sources */, 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */, + 9F4CC51D2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift in Sources */, C185ED672BD43DA100BAE9DC /* ImportPasswordsStatusHandlerTests.swift in Sources */, F198D7981E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift in Sources */, + 564DE45E2C45218500D23241 /* OnboardingNavigationDelegateTests.swift in Sources */, C14E2F7729DE14EA002AC515 /* AutofillInterfaceUsernameTruncatorTests.swift in Sources */, + 564DE45A2C450BE600D23241 /* DaxDialogsNewTabTests.swift in Sources */, C174CE602BD6A6CE00AED2EA /* MockDDGSyncing.swift in Sources */, + 9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */, 8521FDE6238D414B00A44CC3 /* FileStoreTests.swift in Sources */, F14E491F1E391CE900DC037C /* URLExtensionTests.swift in Sources */, 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */, + 9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */, 85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */, + 9F6933212C5B9A5B00CD6A5D /* OnboardingHostingControllerMock.swift in Sources */, + 9F6933192C59BB0300CD6A5D /* OnboardingPixelReporterMock.swift in Sources */, + 56D0602D2C383FD2003BAEB5 /* OnboardingSuggestedSearchesProviderTests.swift in Sources */, 85AD49EE2B6149110085D2D1 /* CookieStorageTests.swift in Sources */, 569437242BDD405400C0881B /* SyncBookmarksAdapterTests.swift in Sources */, 858479CD2B87964500D156C1 /* HistoryManagerTests.swift in Sources */, @@ -7444,6 +7599,7 @@ 98983096255B5019003339A2 /* BookmarksCachingSearchTests.swift in Sources */, D6B67A122C332B6E002122EB /* DuckPlayerMocks.swift in Sources */, 9FEA22352C327226006B03BF /* MockTimer.swift in Sources */, + 564DE45C2C45160500D23241 /* OnboardingSuggestionsViewModelsTests.swift in Sources */, EE7917912A83DE93008DFF28 /* CombineTestUtilities.swift in Sources */, 8540BD5223D8C2220057FDD2 /* PreserveLoginsTests.swift in Sources */, 85F200072217032E006BB258 /* AddressDisplayHelperTests.swift in Sources */, diff --git a/DuckDuckGo/AnimatableTypingText.swift b/DuckDuckGo/AnimatableTypingText.swift index b391d447f0..1f7bd05126 100644 --- a/DuckDuckGo/AnimatableTypingText.swift +++ b/DuckDuckGo/AnimatableTypingText.swift @@ -22,16 +22,15 @@ import Core import Combine // MARK: - View - struct AnimatableTypingText: View { - private let text: String + private let text: NSAttributedString private var startAnimating: Binding private var onTypingFinished: (() -> Void)? @StateObject private var model: AnimatableTypingTextModel init( - _ text: String, + _ text: NSAttributedString, startAnimating: Binding = .constant(true), onTypingFinished: (() -> Void)? = nil ) { @@ -41,27 +40,33 @@ struct AnimatableTypingText: View { self.onTypingFinished = onTypingFinished } - var body: some View { - ZStack(alignment: .topLeading) { - Text(text) - .frame(maxWidth: .infinity, alignment: .leading) - .visibility(.invisible) + init( + _ text: String, + startAnimating: Binding = .constant(true), + onTypingFinished: (() -> Void)? = nil + ) { + let attributesText = NSAttributedString(string: text) + self.text = attributesText + _model = StateObject(wrappedValue: AnimatableTypingTextModel(text: attributesText, onTypingFinished: onTypingFinished)) + self.startAnimating = startAnimating + self.onTypingFinished = onTypingFinished + } - Text(AttributedString(model.typedAttributedText)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .onChange(of: startAnimating.wrappedValue, perform: { shouldAnimate in - if shouldAnimate { - model.startAnimating() - } else { - model.stopAnimating() - } - }) - .onAppear { - if startAnimating.wrappedValue { - model.startAnimating() + var body: some View { + Text(AttributedString(model.typedAttributedText)) + .frame(maxWidth: .infinity, alignment: .leading) + .onChange(of: startAnimating.wrappedValue, perform: { shouldAnimate in + if shouldAnimate { + model.startAnimating() + } else { + model.stopAnimating() + } + }) + .onAppear { + if startAnimating.wrappedValue { + model.startAnimating() + } } - } } } @@ -73,15 +78,15 @@ final class AnimatableTypingTextModel: ObservableObject { @Published private(set) var typedAttributedText: NSAttributedString = .init(string: "") private var typingIndex = 0 - private var textTypedSoFar: String = "" - private let text: String + private let text: NSAttributedString private let onTypingFinished: (() -> Void)? private let timerFactory: TimerCreating - init(text: String, onTypingFinished: (() -> Void)?, timerFactory: TimerCreating = TimerFactory()) { + init(text: NSAttributedString, onTypingFinished: (() -> Void)?, timerFactory: TimerCreating = TimerFactory()) { self.text = text self.onTypingFinished = onTypingFinished self.timerFactory = timerFactory + typedAttributedText = createAttributedString(original: text, visibleLength: 0) } func startAnimating() { @@ -104,7 +109,7 @@ final class AnimatableTypingTextModel: ObservableObject { } private func handleTimerEvent() { - if textTypedSoFar == text { + if typingIndex >= text.length { onTypingFinished?() stopAnimating() return @@ -114,31 +119,42 @@ final class AnimatableTypingTextModel: ObservableObject { } private func stopTyping() { - typedAttributedText = NSAttributedString(string: text) + typedAttributedText = text DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.onTypingFinished?() } } private func showCharacter() { + typingIndex = min(typingIndex + 1, text.length) + typedAttributedText = createAttributedString(original: text, visibleLength: typingIndex) + } - func attributedTypedString(forTypedChars typedChars: [String.Element]) -> NSAttributedString { - let chars = Array(text) - let untypedChars = chars[typedChars.count ..< chars.count] - let combined = NSMutableAttributedString(string: String(typedChars)) - combined.append(NSAttributedString(string: String(untypedChars), attributes: [ - NSAttributedString.Key.foregroundColor: UIColor.clear - ])) + private func createAttributedString(original: NSAttributedString, visibleLength: Int) -> NSAttributedString { + let totalRange = NSRange(location: 0, length: original.length) + let visibleRange = NSRange(location: 0, length: min(visibleLength, original.length)) - return combined - } + // Make the entire text transparent + let transparentText = original.applyingColor(.clear, to: totalRange) - let chars = Array(text) - typingIndex = min(typingIndex + 1, chars.count) - let typedChars = Array(chars[0 ..< typingIndex]) - textTypedSoFar = String(typedChars) - let attributedString = attributedTypedString(forTypedChars: typedChars) - typedAttributedText = attributedString + // Change the color to standard for the visible range + let visibleText = transparentText.applyingColor(.label, to: visibleRange) + + return visibleText } +} + +// Extension to apply color to NSAttributedString +extension NSAttributedString { + func applyingColor(_ color: UIColor, to range: NSRange) -> NSAttributedString { + let mutableAttributedString = NSMutableAttributedString(attributedString: self) + mutableAttributedString.enumerateAttributes(in: range, options: []) { attributes, range, _ in + var newAttributes = attributes + newAttributes[.foregroundColor] = color + mutableAttributedString.setAttributes(newAttributes, range: range) + } + + return mutableAttributedString + } } diff --git a/DuckDuckGo/AppDelegate.swift b/DuckDuckGo/AppDelegate.swift index c8925c5b62..366ac5d038 100644 --- a/DuckDuckGo/AppDelegate.swift +++ b/DuckDuckGo/AppDelegate.swift @@ -233,13 +233,14 @@ import WebKit let variantManager = DefaultVariantManager() let historyMessageManager = HistoryMessageManager() + let daxDialogs = DaxDialogs.shared // assign it here, because "did become active" is already too late and "viewWillAppear" // has already been called on the HomeViewController so won't show the home row CTA AtbAndVariantCleanup.cleanup() variantManager.assignVariantIfNeeded { _ in // MARK: perform first time launch logic here - DaxDialogs.shared.primeForUse() + daxDialogs.primeForUse() // New users don't see the message historyMessageManager.dismiss() @@ -336,7 +337,11 @@ import WebKit previewsSource: previewsSource, tabsModel: tabsModel, syncPausedStateManager: syncErrorHandler, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + variantManager: variantManager, + contextualOnboardingPresenter: ContextualOnboardingPresenter(variantManager: variantManager), + contextualOnboardingLogic: daxDialogs, + contextualOnboardingPixelReporter: OnboardingPixelReporter()) main.loadViewIfNeeded() syncErrorHandler.alertPresenter = main @@ -699,6 +704,11 @@ import WebKit func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { os_log("App launched with url %s", log: .lifecycleLog, type: .debug, url.absoluteString) + // If showing the onboarding intro ignore deeplinks + guard mainViewController?.needsToShowOnboardingIntro() == false else { + return false + } + if handleEmailSignUpDeepLink(url) { return true } diff --git a/DuckDuckGo/Assets.xcassets/Success-128.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Success-128.imageset/Contents.json new file mode 100644 index 0000000000..82ee51dcd8 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Success-128.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Success-128.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Success-128.imageset/Success-128.svg b/DuckDuckGo/Assets.xcassets/Success-128.imageset/Success-128.svg new file mode 100644 index 0000000000..3407da0d72 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Success-128.imageset/Success-128.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Contents.json new file mode 100644 index 0000000000..279c563708 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Wand-16.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Wand-16.svg b/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Wand-16.svg new file mode 100644 index 0000000000..e812a04346 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Wand-16.imageset/Wand-16.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/DuckDuckGo/Base.lproj/Tab.storyboard b/DuckDuckGo/Base.lproj/Tab.storyboard index 4f3852b007..63df68b5cf 100644 --- a/DuckDuckGo/Base.lproj/Tab.storyboard +++ b/DuckDuckGo/Base.lproj/Tab.storyboard @@ -28,11 +28,15 @@ - - - - - + + + + + + + + + @@ -119,11 +123,15 @@ + + + + @@ -133,6 +141,7 @@ + @@ -153,7 +162,7 @@ - + diff --git a/DuckDuckGo/DaxDialogs.swift b/DuckDuckGo/DaxDialogs.swift index d89dc6b1a4..c6acc16d2b 100644 --- a/DuckDuckGo/DaxDialogs.swift +++ b/DuckDuckGo/DaxDialogs.swift @@ -30,6 +30,27 @@ protocol EntityProviding { } +protocol NewTabDialogSpecProvider { + func nextHomeScreenMessage() -> DaxDialogs.HomeScreenSpec? + func nextHomeScreenMessageNew() -> DaxDialogs.HomeScreenSpec? + func dismiss() +} + +protocol ContextualOnboardingLogic { + var isShowingFireDialog: Bool { get } + var shouldShowPrivacyButtonPulse: Bool { get } + var isShowingSearchSuggestions: Bool { get } + var isShowingSitesSuggestions: Bool { get } + + func setSearchMessageSeen() + func setFireEducationMessageSeen() + func setFinalOnboardingDialogSeen() + func setPrivacyButtonPulseSeen() + + func canEnableAddFavoriteFlow() -> Bool // Temporary during Contextual Onboarding Experiment + func enableAddFavoriteFlow() +} + extension ContentBlockerRulesManager: EntityProviding { func entity(forHost host: String) -> Entity? { @@ -38,7 +59,7 @@ extension ContentBlockerRulesManager: EntityProviding { } -final class DaxDialogs { +final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { struct MajorTrackers { @@ -51,7 +72,8 @@ final class DaxDialogs { struct HomeScreenSpec: Equatable { static let initial = HomeScreenSpec(message: UserText.daxDialogHomeInitial, accessibilityLabel: nil) - static let subsequent = HomeScreenSpec(message: UserText.daxDialogHomeSubsequent, accessibilityLabel: nil) + static let subsequent = HomeScreenSpec(message: "", accessibilityLabel: nil) + static let final = HomeScreenSpec(message: UserText.daxDialogHomeSubsequent, accessibilityLabel: nil) static let addFavorite = HomeScreenSpec(message: UserText.daxDialogHomeAddFavorite, accessibilityLabel: UserText.daxDialogHomeAddFavoriteAccessible) @@ -65,69 +87,97 @@ final class DaxDialogs { settings.browsingWithTrackersShown = flag case .afterSearch: settings.browsingAfterSearchShown = flag + case .visitWebsite: + break case .withoutTrackers: settings.browsingWithoutTrackersShown = flag case .siteIsMajorTracker, .siteOwnedByMajorTracker: settings.browsingMajorTrackingSiteShown = flag settings.browsingWithoutTrackersShown = flag + case .fire: + settings.fireMessageExperimentShown = flag + case .final: + settings.browsingFinalDialogShown = flag } } struct BrowsingSpec: Equatable { // swiftlint:disable nesting - enum SpecType { + enum SpecType: String { case afterSearch + case visitWebsite case withoutTrackers case siteIsMajorTracker case siteOwnedByMajorTracker case withOneTracker case withMultipleTrackers + case fire + case final } // swiftlint:enable nesting static let afterSearch = BrowsingSpec(message: UserText.daxDialogBrowsingAfterSearch, cta: UserText.daxDialogBrowsingAfterSearchCTA, highlightAddressBar: false, - pixelName: .daxDialogsSerp, type: .afterSearch) - + pixelName: .daxDialogsSerpUnique, + type: .afterSearch) + + // Message and CTA empty on purpose as for this case we use only pixelName and type + static let visitWebsite = BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .onboardingContextualTryVisitSiteUnique, type: .visitWebsite) + static let withoutTrackers = BrowsingSpec(message: UserText.daxDialogBrowsingWithoutTrackers, cta: UserText.daxDialogBrowsingWithoutTrackersCTA, highlightAddressBar: false, - pixelName: .daxDialogsWithoutTrackers, type: .withoutTrackers) - + pixelName: .daxDialogsWithoutTrackersUnique, type: .withoutTrackers) + static let siteIsMajorTracker = BrowsingSpec(message: UserText.daxDialogBrowsingSiteIsMajorTracker, cta: UserText.daxDialogBrowsingSiteIsMajorTrackerCTA, highlightAddressBar: false, - pixelName: .daxDialogsSiteIsMajor, type: .siteIsMajorTracker) - + pixelName: .daxDialogsSiteIsMajorUnique, type: .siteIsMajorTracker) + static let siteOwnedByMajorTracker = BrowsingSpec(message: UserText.daxDialogBrowsingSiteOwnedByMajorTracker, cta: UserText.daxDialogBrowsingSiteOwnedByMajorTrackerCTA, highlightAddressBar: false, - pixelName: .daxDialogsSiteOwnedByMajor, type: .siteOwnedByMajorTracker) - + pixelName: .daxDialogsSiteOwnedByMajorUnique, type: .siteOwnedByMajorTracker) + static let withOneTracker = BrowsingSpec(message: UserText.daxDialogBrowsingWithOneTracker, cta: UserText.daxDialogBrowsingWithOneTrackerCTA, highlightAddressBar: true, - pixelName: .daxDialogsWithTrackers, type: .withOneTracker) - + pixelName: .daxDialogsWithTrackersUnique, type: .withOneTracker) + static let withMultipleTrackers = BrowsingSpec(message: UserText.daxDialogBrowsingWithMultipleTrackers, cta: UserText.daxDialogBrowsingWithMultipleTrackersCTA, highlightAddressBar: true, - pixelName: .daxDialogsWithTrackers, type: .withMultipleTrackers) - + pixelName: .daxDialogsWithTrackersUnique, type: .withMultipleTrackers) + + // Message and CTA empty on purpose as for this case we use only pixelName and type + static let fire = BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .daxDialogsFireEducationShownUnique, type: .fire) + + static let final = BrowsingSpec(message: UserText.daxDialogHomeSubsequent, cta: "", highlightAddressBar: false, pixelName: .daxDialogsEndOfJourneyTabUnique, type: .final) + let message: String let cta: String - let highlightAddressBar: Bool + fileprivate(set) var highlightAddressBar: Bool let pixelName: Pixel.Event let type: SpecType func format(args: CVarArg...) -> BrowsingSpec { - return BrowsingSpec(message: String(format: message, arguments: args), - cta: cta, - highlightAddressBar: highlightAddressBar, - pixelName: pixelName, - type: type) + format(message: message, args: args) + } + + func format(message: String, args: CVarArg...) -> BrowsingSpec { + withUpdatedMessage(String(format: message, arguments: args)) + } + + func withUpdatedMessage(_ message: String) -> BrowsingSpec { + BrowsingSpec( + message: message, + cta: cta, + highlightAddressBar: highlightAddressBar, + pixelName: pixelName, + type: type + ) } } @@ -136,10 +186,10 @@ final class DaxDialogs { confirmAction: UserText.daxDialogFireButtonEducationConfirmAction, cancelAction: UserText.daxDialogFireButtonEducationCancelAction, isConfirmActionDestructive: true, - displayedPixelName: .daxDialogsFireEducationShown, - confirmActionPixelName: .daxDialogsFireEducationConfirmed, - cancelActionPixelName: .daxDialogsFireEducationCancelled) - + displayedPixelName: .daxDialogsFireEducationShownUnique, + confirmActionPixelName: .daxDialogsFireEducationConfirmedUnique, + cancelActionPixelName: .daxDialogsFireEducationCancelledUnique) + let message: String let confirmAction: String let cancelAction: String @@ -165,6 +215,8 @@ final class DaxDialogs { // So we can avoid showing two dialogs for the same page private var lastURLDaxDialogReturnedFor: URL? + private var currentHomeSpec: HomeScreenSpec? + /// Use singleton accessor, this is only accessible for tests init(settings: DaxDialogsSettings = DefaultDaxDialogsSettings(), entityProviding: EntityProviding, @@ -173,40 +225,90 @@ final class DaxDialogs { self.entityProviding = entityProviding self.variantManager = variantManager } - + + private var isNewOnboarding: Bool { + variantManager.isSupported(feature: .newOnboardingIntro) + } + private var firstBrowsingMessageSeen: Bool { return settings.browsingAfterSearchShown || settings.browsingWithTrackersShown || settings.browsingWithoutTrackersShown || settings.browsingMajorTrackingSiteShown } - + + private var firstSearchSeenButNoSiteVisited: Bool { + return settings.browsingAfterSearchShown + && !settings.browsingWithTrackersShown + && !settings.browsingWithoutTrackersShown + && !settings.browsingMajorTrackingSiteShown + } + private var nonDDGBrowsingMessageSeen: Bool { settings.browsingWithTrackersShown || settings.browsingWithoutTrackersShown || settings.browsingMajorTrackingSiteShown } - - private var fireButtonBrowsingMessageSeenOrExpired: Bool { - return settings.fireButtonEducationShownOrExpired + + private var finalDaxDialogSeen: Bool { + settings.browsingFinalDialogShown } - + + private var visitedSiteAndFireButtonSeen: Bool { + settings.fireMessageExperimentShown && + firstBrowsingMessageSeen + } + + private var shouldDisplayFinalContextualBrowsingDialog: Bool { + !finalDaxDialogSeen && + visitedSiteAndFireButtonSeen + } + + var isShowingSearchSuggestions: Bool { + guard isNewOnboarding else { return false } + return currentHomeSpec == .initial + } + + var isShowingSitesSuggestions: Bool { + guard isNewOnboarding else { return false } + return lastShownDaxDialogType.flatMap(BrowsingSpec.SpecType.init(rawValue:)) == .visitWebsite || currentHomeSpec == .subsequent + } + var isEnabled: Bool { // skip dax dialogs in integration tests guard ProcessInfo.processInfo.environment["DAXDIALOGS"] != "false" else { return false } return !settings.isDismissed } + var isShowingFireDialog: Bool { + guard isNewOnboarding, let lastShownDaxDialogType else { return false } + return BrowsingSpec.SpecType(rawValue: lastShownDaxDialogType) == .fire + } + var isAddFavoriteFlow: Bool { return nextHomeScreenMessageOverride == .addFavorite } var shouldShowFireButtonPulse: Bool { - return nonDDGBrowsingMessageSeen && !fireButtonBrowsingMessageSeenOrExpired && isEnabled + if isNewOnboarding { + // Show fire the user hasn't seen the fire education dialog or the fire button has not animated before. + nonDDGBrowsingMessageSeen && (!settings.fireMessageExperimentShown && settings.fireButtonPulseDateShown == nil) && isEnabled + } else { + nonDDGBrowsingMessageSeen && !settings.fireButtonEducationShownOrExpired && isEnabled + } + } + + var shouldShowPrivacyButtonPulse: Bool { + guard isNewOnboarding else { return false } + return settings.browsingWithTrackersShown && !settings.privacyButtonPulseShown && isEnabled } func isStillOnboarding() -> Bool { - if peekNextHomeScreenMessage() != nil { + if isNewOnboarding { + if peekNextHomeScreenMessageExperiment() != nil { + return true + } + } else if peekNextHomeScreenMessage() != nil { return true } return false @@ -220,7 +322,13 @@ final class DaxDialogs { settings.isDismissed = false } + func canEnableAddFavoriteFlow() -> Bool { + !isNewOnboarding + } + func enableAddFavoriteFlow() { + guard canEnableAddFavoriteFlow() else { return } + nextHomeScreenMessageOverride = .addFavorite // Progress to next home screen message, but don't re-show the second dax dialog if it's already been shown settings.homeScreenMessagesSeen = max(settings.homeScreenMessagesSeen, 1) @@ -237,7 +345,63 @@ final class DaxDialogs { private var fireButtonPulseTimer: Timer? private static let timeToFireButtonExpire: TimeInterval = 1 * 60 * 60 + private var lastVisitedOnboardingWebsiteURLPath: String? { + guard isNewOnboarding else { return nil } + return settings.lastVisitedOnboardingWebsiteURLPath + } + + private func saveLastVisitedOnboardingWebsite(url: URL?) { + guard isNewOnboarding, let url = url else { return } + settings.lastVisitedOnboardingWebsiteURLPath = url.absoluteString + } + + private func removeLastVisitedOnboardingWebsite() { + guard isNewOnboarding else { return } + settings.lastVisitedOnboardingWebsiteURLPath = nil + } + + private var lastShownDaxDialogType: String? { + guard isNewOnboarding else { return nil } + return settings.lastShownContextualOnboardingDialogType + } + + private func saveLastShownDaxDialog(specType: BrowsingSpec.SpecType) { + guard isNewOnboarding else { return } + settings.lastShownContextualOnboardingDialogType = specType.rawValue + } + + private func removeLastShownDaxDialog() { + settings.lastShownContextualOnboardingDialogType = nil + } + + private func lastShownDaxDialog(privacyInfo: PrivacyInfo) -> BrowsingSpec? { + guard let dialogType = lastShownDaxDialogType else { return nil } + switch dialogType { + case BrowsingSpec.SpecType.afterSearch.rawValue: + return BrowsingSpec.afterSearch + case BrowsingSpec.SpecType.visitWebsite.rawValue: + return .visitWebsite + case BrowsingSpec.SpecType.withoutTrackers.rawValue: + return BrowsingSpec.withoutTrackers + case BrowsingSpec.SpecType.siteIsMajorTracker.rawValue: + guard let host = privacyInfo.domain else { return nil } + return majorTrackerMessage(host) + case BrowsingSpec.SpecType.siteOwnedByMajorTracker.rawValue: + guard let host = privacyInfo.domain, let owner = isOwnedByFacebookOrGoogle(host) else { return nil } + return majorTrackerOwnerMessage(host, owner) + case BrowsingSpec.SpecType.withOneTracker.rawValue, BrowsingSpec.SpecType.withMultipleTrackers.rawValue: + guard let entityNames = blockedEntityNames(privacyInfo.trackerInfo) else { return nil } + return trackersBlockedMessage(entityNames) + case BrowsingSpec.SpecType.fire.rawValue: + return .fire + case BrowsingSpec.SpecType.final.rawValue: + return nil + default: return nil + } + } + func fireButtonPulseStarted() { + ViewHighlighter.dismissPrivacyIconPulseAnimation() if settings.fireButtonPulseDateShown == nil { settings.fireButtonPulseDateShown = Date() } @@ -262,11 +426,40 @@ final class DaxDialogs { settings.fireButtonEducationShownOrExpired = true return ActionSheetSpec.fireButtonEducation } - + + func setSearchMessageSeen() { + guard isNewOnboarding else { return } + saveLastShownDaxDialog(specType: .visitWebsite) + } + + func setFireEducationMessageSeen() { + guard isNewOnboarding else { return } + // Set also privacy button pulse seen as we don't have to show anymore if we saw the fire educational message. + settings.privacyButtonPulseShown = true + settings.fireMessageExperimentShown = true + saveLastShownDaxDialog(specType: .fire) + } + + func setPrivacyButtonPulseSeen() { + guard isNewOnboarding else { return } + settings.privacyButtonPulseShown = true + } + + func setFinalOnboardingDialogSeen() { + guard isNewOnboarding else { return } + settings.browsingFinalDialogShown = true + } + func nextBrowsingMessageIfShouldShow(for privacyInfo: PrivacyInfo) -> BrowsingSpec? { - guard privacyInfo.url != lastURLDaxDialogReturnedFor else { return nil } - - let message = nextBrowsingMessage(privacyInfo: privacyInfo) + + var message: BrowsingSpec? + if isNewOnboarding { + message = nextBrowsingMessageExperiment(privacyInfo: privacyInfo) + } else { + guard privacyInfo.url != lastURLDaxDialogReturnedFor else { return nil } + message = nextBrowsingMessage(privacyInfo: privacyInfo) + } + if message != nil { lastURLDaxDialogReturnedFor = privacyInfo.url } @@ -281,7 +474,7 @@ final class DaxDialogs { if privacyInfo.url.isDuckDuckGoSearch { return searchMessage() } - + // won't be shown if owned by major tracker message has already been shown if isFacebookOrGoogle(privacyInfo.url) { return majorTrackerMessage(host) @@ -299,7 +492,56 @@ final class DaxDialogs { // only shown if first time on a non-ddg page and none of the non-ddg messages shown return noTrackersMessage() } - + + private func nextBrowsingMessageExperiment(privacyInfo: PrivacyInfo) -> BrowsingSpec? { + + if let lastVisitedOnboardingWebsiteURLPath, + compareUrls(url1: URL(string: lastVisitedOnboardingWebsiteURLPath), url2: privacyInfo.url) { + return lastShownDaxDialog(privacyInfo: privacyInfo) + } + + func hasTrackers(host: String) -> Bool { + isFacebookOrGoogle(privacyInfo.url) || isOwnedByFacebookOrGoogle(host) != nil || blockedEntityNames(privacyInfo.trackerInfo) != nil + } + + // Reset current home spec when navigating + currentHomeSpec = nil + + guard isEnabled, nextHomeScreenMessageOverride == nil else { return nil } + + guard let host = privacyInfo.domain else { return nil } + + var spec: BrowsingSpec? + + if privacyInfo.url.isDuckDuckGoSearch && !settings.browsingAfterSearchShown { + spec = searchMessage() + } else if isFacebookOrGoogle(privacyInfo.url) && !settings.browsingMajorTrackingSiteShown { + // won't be shown if owned by major tracker message has already been shown + spec = majorTrackerMessage(host) + } else if let owner = isOwnedByFacebookOrGoogle(host), !settings.browsingMajorTrackingSiteShown { + // won't be shown if major tracker message has already been shown + spec = majorTrackerOwnerMessage(host, owner) + } else if let entityNames = blockedEntityNames(privacyInfo.trackerInfo), !settings.browsingWithTrackersShown { + spec = trackersBlockedMessage(entityNames) + } else if !settings.browsingWithoutTrackersShown && !privacyInfo.url.isDuckDuckGoSearch && !hasTrackers(host: host) { + // if non duck duck go search and no trackers found and no tracker message already shown, show no trackers message + spec = noTrackersMessage() + } else if shouldDisplayFinalContextualBrowsingDialog { + // If the user visited a website and saw the fire dialog + spec = finalMessage() + } + + if let spec { + saveLastShownDaxDialog(specType: spec.type) + saveLastVisitedOnboardingWebsite(url: privacyInfo.url) + } else { + removeLastVisitedOnboardingWebsite() + removeLastShownDaxDialog() + } + + return spec + } + func nextHomeScreenMessage() -> HomeScreenSpec? { guard let homeScreenSpec = peekNextHomeScreenMessage() else { return nil } @@ -310,6 +552,15 @@ final class DaxDialogs { return homeScreenSpec } + func nextHomeScreenMessageNew() -> HomeScreenSpec? { + guard let homeScreenSpec = peekNextHomeScreenMessageExperiment() else { + currentHomeSpec = nil + return nil + } + currentHomeSpec = homeScreenSpec + return homeScreenSpec + } + private func peekNextHomeScreenMessage() -> HomeScreenSpec? { if nextHomeScreenMessageOverride != nil { return nextHomeScreenMessageOverride @@ -323,12 +574,34 @@ final class DaxDialogs { } if firstBrowsingMessageSeen { - return .subsequent + return .final } return nil } - + + private func peekNextHomeScreenMessageExperiment() -> HomeScreenSpec? { + if nextHomeScreenMessageOverride != nil { + return nextHomeScreenMessageOverride + } + guard isEnabled else { return nil } + + // Check final first as if we skip anonymous searches we don't want to show this. + if settings.fireMessageExperimentShown && !finalDaxDialogSeen { + return .final + } + + if !settings.browsingAfterSearchShown { + return .initial + } + + if firstSearchSeenButNoSiteVisited { + return .subsequent + } + + return nil + } + private func noTrackersMessage() -> DaxDialogs.BrowsingSpec? { if !settings.browsingWithoutTrackersShown && !settings.browsingMajorTrackingSiteShown && !settings.browsingWithTrackersShown { settings.browsingWithoutTrackersShown = true @@ -338,7 +611,8 @@ final class DaxDialogs { } func majorTrackerOwnerMessage(_ host: String, _ majorTrackerEntity: Entity) -> DaxDialogs.BrowsingSpec? { - guard !settings.browsingMajorTrackingSiteShown else { return nil } + if !isNewOnboarding && settings.browsingMajorTrackingSiteShown { return nil } + guard let entityName = majorTrackerEntity.displayName, let entityPrevalence = majorTrackerEntity.prevalence else { return nil } settings.browsingMajorTrackingSiteShown = true @@ -349,7 +623,8 @@ final class DaxDialogs { } private func majorTrackerMessage(_ host: String) -> DaxDialogs.BrowsingSpec? { - guard !settings.browsingMajorTrackingSiteShown else { return nil } + if !isNewOnboarding && settings.browsingMajorTrackingSiteShown { return nil } + guard let entityName = entityProviding.entity(forHost: host)?.displayName else { return nil } settings.browsingMajorTrackingSiteShown = true settings.browsingWithoutTrackersShown = true @@ -361,25 +636,44 @@ final class DaxDialogs { settings.browsingAfterSearchShown = true return BrowsingSpec.afterSearch } - + + private func finalMessage() -> BrowsingSpec? { + guard !finalDaxDialogSeen else { return nil } + return BrowsingSpec.final + } + private func trackersBlockedMessage(_ entitiesBlocked: [String]) -> BrowsingSpec? { - guard !settings.browsingWithTrackersShown else { return nil } + if !isNewOnboarding && settings.browsingWithTrackersShown { return nil } + var spec: BrowsingSpec? switch entitiesBlocked.count { case 0: - return nil - + spec = nil + case 1: settings.browsingWithTrackersShown = true - return BrowsingSpec.withOneTracker.format(args: entitiesBlocked[0]) - + let args = entitiesBlocked[0] + spec = if isNewOnboarding { + BrowsingSpec.withOneTracker.format(message: UserText.DaxOnboardingExperiment.ContextualOnboarding.daxDialogBrowsingWithOneTracker, args: args) + } else { + BrowsingSpec.withOneTracker.format(args: args) + } + default: settings.browsingWithTrackersShown = true - return BrowsingSpec.withMultipleTrackers.format(args: entitiesBlocked.count - 2, - entitiesBlocked[0], - entitiesBlocked[1]) + let args: [CVarArg] = [entitiesBlocked.count - 2, entitiesBlocked[0], entitiesBlocked[1]] + spec = if isNewOnboarding { + BrowsingSpec.withMultipleTrackers.format(message: UserText.DaxOnboardingExperiment.ContextualOnboarding.daxDialogBrowsingWithMultipleTrackers, args: args) + } else { + BrowsingSpec.withMultipleTrackers.format(args: args) + } } + // New Contextual onboarding doesn't highlight the address bar. This checks prevents to cancel the lottie animation. + if isNewOnboarding { + spec?.highlightAddressBar = false + } + return spec } private func blockedEntityNames(_ trackerInfo: TrackerInfo) -> [String]? { @@ -400,4 +694,45 @@ final class DaxDialogs { guard let entity = entityProviding.entity(forHost: host) else { return nil } return entity.domains?.contains(where: { MajorTrackers.domains.contains($0) }) ?? false ? entity : nil } + + private func compareUrls(url1: URL?, url2: URL?) -> Bool { + guard let url1, let url2 else { return false } + + if url1 == url2 { + return true + } + + return url1.isSameDuckDuckGoSearchURL(other: url2) + } +} + +extension URL { + + func isSameDuckDuckGoSearchURL(other: URL?) -> Bool { + guard let other else { return false } + + guard isDuckDuckGoSearch && other.isDuckDuckGoSearch else { return false } + + // Extract 'q' parameter from both URLs + let queryValue1 = URLComponents(url: self, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "q" })?.value + let queryValue2 = URLComponents(url: other, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "q" })?.value + + let normalizedQuery1 = queryValue1? + .replacingOccurrences(of: "+", with: " ") + .replacingOccurrences(of: "%20", with: " ") + let normalizedQuery2 = queryValue2? + .replacingOccurrences(of: "+", with: " ") + .replacingOccurrences(of: "%20", with: " ") + + return normalizedQuery1 == normalizedQuery2 + } +} + +private extension ViewHighlighter { + + static func dismissPrivacyIconPulseAnimation() { + guard ViewHighlighter.highlightedViews.contains(where: { $0.view is PrivacyIconView }) else { return } + ViewHighlighter.hideAll() + } + } diff --git a/DuckDuckGo/DaxDialogsSettings.swift b/DuckDuckGo/DaxDialogsSettings.swift index a68a5f7c18..960b63013a 100644 --- a/DuckDuckGo/DaxDialogsSettings.swift +++ b/DuckDuckGo/DaxDialogsSettings.swift @@ -34,9 +34,19 @@ protocol DaxDialogsSettings { var browsingMajorTrackingSiteShown: Bool { get set } var fireButtonEducationShownOrExpired: Bool { get set } - + + var fireMessageExperimentShown: Bool { get set } + var fireButtonPulseDateShown: Date? { get set } + var privacyButtonPulseShown: Bool { get set } + + var browsingFinalDialogShown: Bool { get set } + + var lastVisitedOnboardingWebsiteURLPath: String? { get set } + + var lastShownContextualOnboardingDialogType: String? { get set } + } class DefaultDaxDialogsSettings: DaxDialogsSettings { @@ -61,10 +71,25 @@ class DefaultDaxDialogsSettings: DaxDialogsSettings { @UserDefaultsWrapper(key: .daxFireButtonEducationShownOrExpired, defaultValue: false) var fireButtonEducationShownOrExpired: Bool - + + @UserDefaultsWrapper(key: .daxFireMessageExperimentShown, defaultValue: false) + var fireMessageExperimentShown: Bool + @UserDefaultsWrapper(key: .fireButtonPulseDateShown, defaultValue: nil) var fireButtonPulseDateShown: Date? - + + @UserDefaultsWrapper(key: .privacyButtonPulseShown, defaultValue: false) + var privacyButtonPulseShown: Bool + + @UserDefaultsWrapper(key: .daxBrowsingFinalDialogShown, defaultValue: false) + var browsingFinalDialogShown: Bool + + @UserDefaultsWrapper(key: .daxLastVisitedOnboardingWebsite, defaultValue: nil) + var lastVisitedOnboardingWebsiteURLPath: String? + + @UserDefaultsWrapper(key: .daxLastShownContextualOnboardingDialogType, defaultValue: nil) + var lastShownContextualOnboardingDialogType: String? + } class InMemoryDaxDialogsSettings: DaxDialogsSettings { @@ -82,7 +107,17 @@ class InMemoryDaxDialogsSettings: DaxDialogsSettings { var browsingMajorTrackingSiteShown: Bool = false var fireButtonEducationShownOrExpired: Bool = false - + + var fireMessageExperimentShown: Bool = false + var fireButtonPulseDateShown: Date? - + + var privacyButtonPulseShown: Bool = false + + var browsingFinalDialogShown = false + + var lastVisitedOnboardingWebsiteURLPath: String? + + var lastShownContextualOnboardingDialogType: String? + } diff --git a/DuckDuckGo/FullscreenDaxDialogViewController.swift b/DuckDuckGo/FullscreenDaxDialogViewController.swift index 1557ae3c70..3a49583756 100644 --- a/DuckDuckGo/FullscreenDaxDialogViewController.swift +++ b/DuckDuckGo/FullscreenDaxDialogViewController.swift @@ -137,7 +137,7 @@ extension TabViewController: FullscreenDaxDialogDelegate { preferredStyle: isPad ? .alert : .actionSheet) alertController.addAction(title: UserText.daxDialogHideButton, style: .default) { - Pixel.fire(pixel: .daxDialogsHidden, withAdditionalParameters: [ "c": DefaultDaxDialogsSettings().browsingDialogsSeenCount ]) + Pixel.fire(pixel: .daxDialogsHiddenUnique, withAdditionalParameters: [ "c": DefaultDaxDialogsSettings().browsingDialogsSeenCount ]) DaxDialogs.shared.dismiss() } alertController.addAction(title: UserText.daxDialogHideCancel, style: .cancel) { diff --git a/DuckDuckGo/HomePageDependencies.swift b/DuckDuckGo/HomePageDependencies.swift new file mode 100644 index 0000000000..a806afb709 --- /dev/null +++ b/DuckDuckGo/HomePageDependencies.swift @@ -0,0 +1,37 @@ +// +// HomePageDependencies.swift +// DuckDuckGo +// +// 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 Bookmarks +import DDGSync +import Core +import BrowserServicesKit + +struct HomePageDependencies { + let homePageConfiguration: HomePageConfiguration + let model: Tab + let favoritesViewModel: FavoritesListInteracting + let appSettings: AppSettings + let syncService: DDGSyncing + let syncDataProviders: SyncDataProviders + let privacyProDataReporter: PrivacyProDataReporting + let variantManager: VariantManager + let newTabDialogFactory: any NewTabDaxDialogProvider + let newTabDialogTypeProvider: NewTabDialogSpecProvider +} diff --git a/DuckDuckGo/HomeViewController+DaxDialogs.swift b/DuckDuckGo/HomeViewController+DaxDialogs.swift new file mode 100644 index 0000000000..c9dd772944 --- /dev/null +++ b/DuckDuckGo/HomeViewController+DaxDialogs.swift @@ -0,0 +1,92 @@ +// +// HomeViewController+DaxDialogs.swift +// DuckDuckGo +// +// 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 UIKit +import Core +import SwiftUI + +extension HomeViewController { + + func showNextDaxDialog(dialogProvider: NewTabDialogSpecProvider) { + guard let spec = dialogProvider.nextHomeScreenMessage() else { return } + guard !isShowingDax else { return } + guard let daxDialogViewController = daxDialogViewController else { return } + collectionView.isHidden = true + daxDialogContainer.isHidden = false + daxDialogContainer.alpha = 0.0 + + daxDialogViewController.loadViewIfNeeded() + daxDialogViewController.message = spec.message + daxDialogViewController.accessibleMessage = spec.accessibilityLabel + + if spec == .initial { + UniquePixel.fire(pixel: .onboardingContextualTryVisitSiteUnique, includedParameters: [.appVersion, .atb]) + } + + view.addGestureRecognizer(daxDialogViewController.tapToCompleteGestureRecognizer) + + daxDialogContainerHeight.constant = daxDialogViewController.calculateHeight() + hideLogo() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + UIView.animate(withDuration: 0.4, animations: { + self.daxDialogContainer.alpha = 1.0 + }, completion: { _ in + self.daxDialogViewController?.start() + }) + } + + configureCollectionView() + } + + func showNextDaxDialogNew(dialogProvider: NewTabDialogSpecProvider, factory: any NewTabDaxDialogProvider) { + dismissHostingController(didFinishNTPOnboarding: false) + let onDismiss = { + dialogProvider.dismiss() + self.dismissHostingController(didFinishNTPOnboarding: true) + } + guard let spec = dialogProvider.nextHomeScreenMessageNew() else { return } + let daxDialogView = AnyView(factory.createDaxDialog(for: spec, onDismiss: onDismiss)) + hostingController = UIHostingController(rootView: daxDialogView) + guard let hostingController else { return } + hostingController.view.backgroundColor = .clear + view.addSubview(hostingController.view) + hostingController.didMove(toParent: self) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + hideLogo() + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + configureCollectionView() + } + + private func dismissHostingController(didFinishNTPOnboarding: Bool) { + hostingController?.willMove(toParent: nil) + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + if didFinishNTPOnboarding { + // If there are favorites to show hide the Dax logo + delegate?.home(self, didRequestHideLogo: hasFavoritesToShow) + } + } +} diff --git a/DuckDuckGo/HomeViewController.swift b/DuckDuckGo/HomeViewController.swift index 9996947d1b..b51a65401b 100644 --- a/DuckDuckGo/HomeViewController.swift +++ b/DuckDuckGo/HomeViewController.swift @@ -25,7 +25,8 @@ import Common import DDGSync import Persistence import RemoteMessaging - +import SwiftUI +import BrowserServicesKit class HomeViewController: UIViewController, NewTabPage { @@ -38,7 +39,8 @@ class HomeViewController: UIViewController, NewTabPage { @IBOutlet weak var daxDialogContainer: UIView! @IBOutlet weak var daxDialogContainerHeight: NSLayoutConstraint! weak var daxDialogViewController: DaxDialogViewController? - + var hostingController: UIHostingController? + var logoContainer: UIView! { return delegate?.homeDidRequestLogoContainer(self) } @@ -64,7 +66,7 @@ class HomeViewController: UIViewController, NewTabPage { weak var delegate: HomeControllerDelegate? weak var chromeDelegate: BrowserChromeDelegate? - + private var viewHasAppeared = false private var defaultVerticalAlignConstant: CGFloat = 0 @@ -74,31 +76,35 @@ class HomeViewController: UIViewController, NewTabPage { private let appSettings: AppSettings private let syncService: DDGSyncing private let syncDataProviders: SyncDataProviders + private let variantManager: VariantManager + private let newTabDialogFactory: any NewTabDaxDialogProvider + private let newTabDialogTypeProvider: NewTabDialogSpecProvider private var viewModelCancellable: AnyCancellable? private var favoritesDisplayModeCancellable: AnyCancellable? let privacyProDataReporter: PrivacyProDataReporting + var hasFavoritesToShow: Bool { + !favoritesViewModel.favorites.isEmpty + } + static func loadFromStoryboard( - homePageConfiguration: HomePageConfiguration, - model: Tab, - favoritesViewModel: FavoritesListInteracting, - appSettings: AppSettings, - syncService: DDGSyncing, - syncDataProviders: SyncDataProviders, - privacyProDataReporter: PrivacyProDataReporting + homePageDependecies: HomePageDependencies ) -> HomeViewController { let storyboard = UIStoryboard(name: "Home", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "HomeViewController", creator: { coder in HomeViewController( coder: coder, - homePageConfiguration: homePageConfiguration, - tabModel: model, - favoritesViewModel: favoritesViewModel, - appSettings: appSettings, - syncService: syncService, - syncDataProviders: syncDataProviders, - privacyProDataReporter: privacyProDataReporter + homePageConfiguration: homePageDependecies.homePageConfiguration, + tabModel: homePageDependecies.model, + favoritesViewModel: homePageDependecies.favoritesViewModel, + appSettings: homePageDependecies.appSettings, + syncService: homePageDependecies.syncService, + syncDataProviders: homePageDependecies.syncDataProviders, + privacyProDataReporter: homePageDependecies.privacyProDataReporter, + variantManager: homePageDependecies.variantManager, + newTabDialogFactory: homePageDependecies.newTabDialogFactory, + newTabDialogTypeProvider: homePageDependecies.newTabDialogTypeProvider ) }) return controller @@ -112,7 +118,10 @@ class HomeViewController: UIViewController, NewTabPage { appSettings: AppSettings, syncService: DDGSyncing, syncDataProviders: SyncDataProviders, - privacyProDataReporter: PrivacyProDataReporting + privacyProDataReporter: PrivacyProDataReporting, + variantManager: VariantManager, + newTabDialogFactory: any NewTabDaxDialogProvider, + newTabDialogTypeProvider: NewTabDialogSpecProvider ) { self.homePageConfiguration = homePageConfiguration self.tabModel = tabModel @@ -121,6 +130,9 @@ class HomeViewController: UIViewController, NewTabPage { self.syncService = syncService self.syncDataProviders = syncDataProviders self.privacyProDataReporter = privacyProDataReporter + self.variantManager = variantManager + self.newTabDialogFactory = newTabDialogFactory + self.newTabDialogTypeProvider = newTabDialogTypeProvider super.init(coder: coder) } @@ -210,7 +222,10 @@ class HomeViewController: UIViewController, NewTabPage { func openedAsNewTab(allowingKeyboard: Bool) { collectionView.openedAsNewTab(allowingKeyboard: allowingKeyboard) - showNextDaxDialog() + if !variantManager.isSupported(feature: .newOnboardingIntro) { + // In the new onboarding this gets called twice (viewDidAppear in Tab) which then reset the spec to nil. + presentNextDaxDialog() + } } @IBAction func launchSettings() { @@ -226,9 +241,9 @@ class HomeViewController: UIViewController, NewTabPage { Pixel.fire(pixel: .homeScreenShown) sendDailyDisplayPixel() - - showNextDaxDialog() - + + presentNextDaxDialog() + collectionView.didAppear() viewHasAppeared = true @@ -238,42 +253,25 @@ class HomeViewController: UIViewController, NewTabPage { var isShowingDax: Bool { return !daxDialogContainer.isHidden } - - func showNextDaxDialog() { - - guard !isShowingDax else { return } - guard let spec = DaxDialogs.shared.nextHomeScreenMessage(), - let daxDialogViewController = daxDialogViewController else { return } - collectionView.isHidden = true - daxDialogContainer.isHidden = false - daxDialogContainer.alpha = 0.0 - - daxDialogViewController.loadViewIfNeeded() - daxDialogViewController.message = spec.message - daxDialogViewController.accessibleMessage = spec.accessibilityLabel - - view.addGestureRecognizer(daxDialogViewController.tapToCompleteGestureRecognizer) - - daxDialogContainerHeight.constant = daxDialogViewController.calculateHeight() - hideLogo() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - UIView.animate(withDuration: 0.4, animations: { - self.daxDialogContainer.alpha = 1.0 - }, completion: { _ in - self.daxDialogViewController?.start() - }) - } - - configureCollectionView() - } func hideLogo() { delegate?.home(self, didRequestHideLogo: true) } func onboardingCompleted() { - showNextDaxDialog() + presentNextDaxDialog() + } + + func presentNextDaxDialog() { + if variantManager.isSupported(feature: .newOnboardingIntro) { + showNextDaxDialogNew(dialogProvider: newTabDialogTypeProvider, factory: newTabDialogFactory) + } else { + showNextDaxDialog(dialogProvider: newTabDialogTypeProvider) + } + } + + func showNextDaxDialog() { + showNextDaxDialog(dialogProvider: newTabDialogTypeProvider) } func reloadFavorites() { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 225ceeb6c8..56602d956d 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -100,6 +100,11 @@ class MainViewController: UIViewController { let syncService: DDGSyncing let syncDataProviders: SyncDataProviders let syncPausedStateManager: any SyncPausedStateManaging + private let variantManager: VariantManager + private let tutorialSettings: TutorialSettings + private let contextualOnboardingLogic: ContextualOnboardingLogic + private let contextualOnboardingPixelReporter: OnboardingCustomInteractionPixelReporting + private let statisticsStore: StatisticsStore @UserDefaultsWrapper(key: .syncDidShowSyncPausedByFeatureFlagAlert, defaultValue: false) private var syncDidShowSyncPausedByFeatureFlagAlert: Bool @@ -177,7 +182,13 @@ class MainViewController: UIViewController { previewsSource: TabPreviewsSource, tabsModel: TabsModel, syncPausedStateManager: any SyncPausedStateManaging, - privacyProDataReporter: PrivacyProDataReporting + privacyProDataReporter: PrivacyProDataReporting, + variantManager: VariantManager, + contextualOnboardingPresenter: ContextualOnboardingPresenting, + contextualOnboardingLogic: ContextualOnboardingLogic, + contextualOnboardingPixelReporter: OnboardingCustomInteractionPixelReporting, + tutorialSettings: TutorialSettings = DefaultTutorialSettings(), + statisticsStore: StatisticsStore = StatisticsUserDefaults() ) { self.bookmarksDatabase = bookmarksDatabase self.bookmarksDatabaseCleaner = bookmarksDatabaseCleaner @@ -196,10 +207,17 @@ class MainViewController: UIViewController { bookmarksDatabase: bookmarksDatabase, historyManager: historyManager, syncService: syncService, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic) self.syncPausedStateManager = syncPausedStateManager self.privacyProDataReporter = privacyProDataReporter self.homeTabManager = NewTabPageManager() + self.variantManager = variantManager + self.tutorialSettings = tutorialSettings + self.contextualOnboardingLogic = contextualOnboardingLogic + self.contextualOnboardingPixelReporter = contextualOnboardingPixelReporter + self.statisticsStore = statisticsStore super.init(nibName: nil, bundle: nil) @@ -397,8 +415,14 @@ class MainViewController: UIViewController { } func startAddFavoriteFlow() { - DaxDialogs.shared.enableAddFavoriteFlow() - if DefaultTutorialSettings().hasSeenOnboarding { + // Disable add favourite flow when new onboarding experiment is running and open a new tab. + guard contextualOnboardingLogic.canEnableAddFavoriteFlow() else { + newTab() + return + } + + contextualOnboardingLogic.enableAddFavoriteFlow() + if tutorialSettings.hasSeenOnboarding { newTab() } } @@ -409,9 +433,8 @@ class MainViewController: UIViewController { // explicitly skip onboarding, e.g. for integration tests return } - - let settings = DefaultTutorialSettings() - let showOnboarding = !settings.hasSeenOnboarding || + + let showOnboarding = !tutorialSettings.hasSeenOnboarding || // explicitly show onboarding, can be set in the scheme > Run > Environment Variables ProcessInfo.processInfo.environment["ONBOARDING"] == "true" guard showOnboarding else { return } @@ -667,7 +690,7 @@ class MainViewController: UIViewController { if let presentedViewController { return presentedViewController.supportedInterfaceOrientations } - return DefaultTutorialSettings().hasSeenOnboarding ? [.allButUpsideDown] : [.portrait] + return tutorialSettings.hasSeenOnboarding ? [.allButUpsideDown] : [.portrait] } override var shouldAutorotate: Bool { @@ -754,13 +777,18 @@ class MainViewController: UIViewController { viewCoordinator.logoContainer.isHidden = true adjustNewTabPageSafeAreaInsets(for: appSettings.currentAddressBarPosition) } else { - let controller = HomeViewController.loadFromStoryboard(homePageConfiguration: homePageConfiguration, - model: tabModel, - favoritesViewModel: favoritesViewModel, - appSettings: appSettings, - syncService: syncService, - syncDataProviders: syncDataProviders, - privacyProDataReporter: privacyProDataReporter) + let newTabDaxDialogFactory = NewTabDaxDialogFactory(delegate: self, contextualOnboardingLogic: DaxDialogs.shared) + let homePageDependencies = HomePageDependencies(homePageConfiguration: homePageConfiguration, + model: tabModel, + favoritesViewModel: favoritesViewModel, + appSettings: appSettings, + syncService: syncService, + syncDataProviders: syncDataProviders, + privacyProDataReporter: privacyProDataReporter, + variantManager: variantManager, + newTabDialogFactory: newTabDaxDialogFactory, + newTabDialogTypeProvider: DaxDialogs.shared) + let controller = HomeViewController.loadFromStoryboard(homePageDependecies: homePageDependencies) controller.delegate = self controller.chromeDelegate = self @@ -780,18 +808,30 @@ class MainViewController: UIViewController { } @IBAction func onFirePressed() { - Pixel.fire(pixel: .forgetAllPressedBrowsing) - wakeLazyFireButtonAnimator() - - if let spec = DaxDialogs.shared.fireButtonEducationMessage() { - segueToActionSheetDaxDialogWithSpec(spec) - } else { + + func showClearDataAlert() { let alert = ForgetDataAlert.buildAlert(forgetTabsAndDataHandler: { [weak self] in self?.forgetAllWithAnimation {} }) self.present(controller: alert, fromView: self.viewCoordinator.toolbar) } + Pixel.fire(pixel: .forgetAllPressedBrowsing) + wakeLazyFireButtonAnimator() + + if DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + // Dismiss dax dialog and pulse animation when the user taps on the Fire Button. + currentTab?.dismissContextualDaxFireDialog() + ViewHighlighter.hideAll() + showClearDataAlert() + } else { + if let spec = DaxDialogs.shared.fireButtonEducationMessage() { + segueToActionSheetDaxDialogWithSpec(spec) + } else { + showClearDataAlert() + } + } + performCancel() } @@ -826,7 +866,9 @@ class MainViewController: UIViewController { func didReturnFromBackground() { skipSERPFlow = true - if DaxDialogs.shared.shouldShowFireButtonPulse { + + // Show Fire Pulse only if Privacy button pulse should not be shown. In control group onboarding `shouldShowPrivacyButtonPulse` is always false. + if DaxDialogs.shared.shouldShowFireButtonPulse && !DaxDialogs.shared.shouldShowPrivacyButtonPulse { showFireButtonPulse() } } @@ -1265,6 +1307,14 @@ class MainViewController: UIViewController { } } + func fireOnboardingCustomSearchPixelIfNeeded(query: String) { + if contextualOnboardingLogic.isShowingSearchSuggestions { + contextualOnboardingPixelReporter.trackCustomSearch() + } else if contextualOnboardingLogic.isShowingSitesSuggestions { + contextualOnboardingPixelReporter.trackCustomSite() + } + } + func animateBackgroundTab() { showBars() tabSwitcherButton.incrementAnimated() @@ -1686,11 +1736,19 @@ extension MainViewController: OmniBarDelegate { loadQuery(query) hideSuggestionTray() showHomeRowReminder() + fireOnboardingCustomSearchPixelIfNeeded(query: query) } - func onPrivacyIconPressed() { + func onPrivacyIconPressed(isHighlighted: Bool) { guard !isSERPPresented else { return } + // Track first tap of privacy icon button + if isHighlighted { + contextualOnboardingPixelReporter.trackPrivacyDashboardOpenedForFirstTime() + } + // Dismiss privacy icon animation when showing privacy dashboard + dismissPrivacyDashboardButtonPulse() + if !DaxDialogs.shared.shouldShowFireButtonPulse { ViewHighlighter.hideAll() } @@ -1700,6 +1758,12 @@ extension MainViewController: OmniBarDelegate { func onMenuPressed() { omniBar.cancel() + + // Dismiss privacy icon animation when showing menu + if !DaxDialogs.shared.shouldShowPrivacyButtonPulse { + dismissPrivacyDashboardButtonPulse() + } + if !DaxDialogs.shared.shouldShowFireButtonPulse { ViewHighlighter.hideAll() } @@ -2235,6 +2299,14 @@ extension MainViewController: TabDelegate { showFireButtonPulse() } + func tabDidRequestPrivacyDashboardButtonPulse(tab: TabViewController, animated: Bool) { + if animated { + showPrivacyDashboardButtonPulse() + } else { + dismissPrivacyDashboardButtonPulse() + } + } + func tabDidRequestSearchBarRect(tab: TabViewController) -> CGRect { searchBarRect } @@ -2289,6 +2361,14 @@ extension MainViewController: TabDelegate { return currentTab === tab } + func tab(_ tab: TabViewController, didRequestLoadURL url: URL) { + loadUrl(url, fromExternalLink: true) + } + + func tab(_ tab: TabViewController, didRequestLoadQuery query: String) { + loadQuery(query) + } + } extension MainViewController: TabSwitcherDelegate { @@ -2524,6 +2604,10 @@ extension MainViewController: AutoClearWorker { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: showKeyboardAfterFireButton) self.showKeyboardAfterFireButton = showKeyboardAfterFireButton } + + if self.variantManager.isSupported(feature: .newOnboardingIntro) { + DaxDialogs.shared.setFireEducationMessageSeen() + } } } @@ -2544,7 +2628,16 @@ extension MainViewController: AutoClearWorker { ViewHighlighter.showIn(window, focussedOnView: view) } } - + + private func showPrivacyDashboardButtonPulse() { + viewCoordinator.omniBar.showOrScheduleOnboardingPrivacyIconAnimation() + } + + private func dismissPrivacyDashboardButtonPulse() { + DaxDialogs.shared.setPrivacyButtonPulseSeen() + viewCoordinator.omniBar.dismissOnboardingPrivacyIconAnimation() + } + } extension MainViewController { @@ -2601,10 +2694,23 @@ extension MainViewController: OnboardingDelegate { } func markOnboardingSeen() { - var settings = DefaultTutorialSettings() - settings.hasSeenOnboarding = true + tutorialSettings.hasSeenOnboarding = true + } + + func needsToShowOnboardingIntro() -> Bool { + !tutorialSettings.hasSeenOnboarding + } + +} + +extension MainViewController: OnboardingNavigationDelegate { + func navigateTo(url: URL) { + self.loadUrl(url, fromExternalLink: true) } + func searchFor(_ query: String) { + self.loadQuery(query) + } } extension MainViewController: UIDropInteractionDelegate { diff --git a/DuckDuckGo/OmniBar.swift b/DuckDuckGo/OmniBar.swift index ef6a351698..b6f80f5dc7 100644 --- a/DuckDuckGo/OmniBar.swift +++ b/DuckDuckGo/OmniBar.swift @@ -72,7 +72,8 @@ class OmniBar: UIView { private var privacyIconAndTrackersAnimator = PrivacyIconAndTrackersAnimator() private var notificationAnimator = OmniBarNotificationAnimator() - + private let privacyIconContextualOnboardingAnimator = PrivacyIconContextualOnboardingAnimator() + // Set up a view to add a custom icon to the Omnibar private var customIconView: UIImageView = UIImageView(frame: CGRect(x: 4, y: 8, width: 26, height: 26)) @@ -307,6 +308,7 @@ class OmniBar: UIView { public func cancelAllAnimations() { privacyIconAndTrackersAnimator.cancelAnimations(in: self) notificationAnimator.cancelAnimations(in: self) + privacyIconContextualOnboardingAnimator.dismissPrivacyIconAnimation(privacyInfoContainer.privacyIcon) } public func completeAnimationForDaxDialog() { @@ -316,13 +318,28 @@ class OmniBar: UIView { func showOrScheduleCookiesManagedNotification(isCosmetic: Bool) { let type: OmniBarNotificationType = isCosmetic ? .cookiePopupHidden : .cookiePopupManaged + enqueueAnimationIfNeeded { [weak self] in + guard let self else { return } + self.notificationAnimator.showNotification(type, in: self) + } + } + + func showOrScheduleOnboardingPrivacyIconAnimation() { + enqueueAnimationIfNeeded { [weak self] in + guard let self else { return } + self.privacyIconContextualOnboardingAnimator.showPrivacyIconAnimation(in: self) + } + } + + func dismissOnboardingPrivacyIconAnimation() { + privacyIconContextualOnboardingAnimator.dismissPrivacyIconAnimation(privacyInfoContainer.privacyIcon) + } + + private func enqueueAnimationIfNeeded(_ block: @escaping () -> Void) { if privacyIconAndTrackersAnimator.state == .completed { - notificationAnimator.showNotification(type, in: self) + block() } else { - privacyIconAndTrackersAnimator.onAnimationCompletion = { [weak self] in - guard let self = self else { return } - self.notificationAnimator.showNotification(type, in: self) - } + privacyIconAndTrackersAnimator.onAnimationCompletion(block) } } @@ -450,7 +467,8 @@ class OmniBar: UIView { } @IBAction func onPrivacyIconPressed(_ sender: Any) { - omniDelegate?.onPrivacyIconPressed() + let isPrivacyIconHighlighted = privacyIconContextualOnboardingAnimator.isPrivacyIconHighlighted(privacyInfoContainer.privacyIcon) + omniDelegate?.onPrivacyIconPressed(isHighlighted: isPrivacyIconHighlighted) } @IBAction func onMenuButtonPressed(_ sender: UIButton) { diff --git a/DuckDuckGo/OmniBarDelegate.swift b/DuckDuckGo/OmniBarDelegate.swift index c861af47d0..e1153fd7a3 100644 --- a/DuckDuckGo/OmniBarDelegate.swift +++ b/DuckDuckGo/OmniBarDelegate.swift @@ -35,7 +35,7 @@ protocol OmniBarDelegate: AnyObject { func onEditingEnd() -> OmniBarEditingEndResult - func onPrivacyIconPressed() + func onPrivacyIconPressed(isHighlighted: Bool) func onMenuPressed() @@ -82,8 +82,8 @@ extension OmniBarDelegate { } - func onPrivacyIconPressed() { - + func onPrivacyIconPressed(isHighlighted: Bool) { + } func onMenuPressed() { diff --git a/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift b/DuckDuckGo/OnFirstAppearViewModifier.swift similarity index 55% rename from DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift rename to DuckDuckGo/OnFirstAppearViewModifier.swift index bd7c78138d..784ab8999b 100644 --- a/DuckDuckGo/Subscription/Extensions/View+AppearModifiers.swift +++ b/DuckDuckGo/OnFirstAppearViewModifier.swift @@ -1,5 +1,5 @@ // -// View+AppearModifiers.swift +// OnFirstAppearViewModifier.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -19,15 +19,29 @@ import SwiftUI -public struct OnFirstAppearModifier: ViewModifier { +/// A view modifier that executes a specified action only once when the view first appears. +/// +/// Use this modifier to perform an action the first time the view becomes visible on the screen. +/// The action will not be executed again if the view reappears or is recreated. +/// +/// Example: +/// ```swift +/// Text("Hello, World!") +/// .modifier(OnFirstAppearModifier { +/// print("The view has appeared for the first time.") +/// }) +/// ``` +/// +/// - Parameter onFirstAppearAction: A closure to be executed the first time the view appears. +public struct OnFirstAppearViewModifier: ViewModifier { private let onFirstAppearAction: () -> Void @State private var hasAppeared = false - + public init(_ onFirstAppearAction: @escaping () -> Void) { self.onFirstAppearAction = onFirstAppearAction } - + public func body(content: Content) -> some View { content .onAppear { @@ -39,9 +53,13 @@ public struct OnFirstAppearModifier: ViewModifier { } extension View { - + + /// Adds an action to perform that is executed only the first time before this view appears. + /// + /// - Parameter action: A closure to be executed the first time the view appears. + /// - Returns: A view that will execute `action` only once when it appears. func onFirstAppear(_ onFirstAppearAction: @escaping () -> Void ) -> some View { - return modifier(OnFirstAppearModifier(onFirstAppearAction)) + return modifier(OnFirstAppearViewModifier(onFirstAppearAction)) } - + } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualDaxDialog.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualDaxDialog.swift new file mode 100644 index 0000000000..182bbc1fed --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualDaxDialog.swift @@ -0,0 +1,276 @@ +// +// ContextualDaxDialog.swift +// DuckDuckGo +// +// 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 SwiftUI +import DuckUI +import Combine + +struct ContextualDaxDialogContent: View { + + var title: String? + let message: NSAttributedString + var list: [ContextualOnboardingListItem] = [] + var listAction: ((_ item: ContextualOnboardingListItem) -> Void)? + var imageName: String? + var cta: String? + var action: (() -> Void)? + + private let itemsToAnimate: [DisplayableTypes] + + init( + title: String? = nil, + message: NSAttributedString, + list: [ContextualOnboardingListItem] = [], + listAction: ((_: ContextualOnboardingListItem) -> Void)? = nil, + imageName: String? = nil, cta: String? = nil, + action: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.list = list + self.listAction = listAction + self.imageName = imageName + self.cta = cta + self.action = action + + var itemsToAnimate: [DisplayableTypes] = [] + if title != nil { + itemsToAnimate.append(.title) + } + itemsToAnimate.append(.message) + if !list.isEmpty { + itemsToAnimate.append(.list) + } + if imageName != nil { + itemsToAnimate.append(.image) + } + if cta != nil { + itemsToAnimate.append(.button) + } + + self.itemsToAnimate = itemsToAnimate + } + + @State private var startTypingTitle: Bool = false + @State private var startTypingMessage: Bool = false + @State private var nonTypingAnimatableItems: NonTypingAnimatableItems = [] + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Typing items + titleView + messageView + // Non Typing items + listView + .visibility(nonTypingAnimatableItems.contains(.list) ? .visible : .invisible) + imageView + .visibility(nonTypingAnimatableItems.contains(.image) ? .visible : .invisible) + actionView + .visibility(nonTypingAnimatableItems.contains(.button) ? .visible : .invisible) + } + .onAppear { + Task { @MainActor in + try await Task.sleep(interval: 0.3) + startAnimating() + } + } + } + + @ViewBuilder + private var titleView: some View { + if let title { + AnimatableTypingText(title, startAnimating: $startTypingTitle, onTypingFinished: { + startTypingMessage = true + }) + .daxTitle3() + } + } + + @ViewBuilder + private var messageView: some View { + AnimatableTypingText(message, startAnimating: $startTypingMessage, onTypingFinished: { + animateNonTypingItems() + }) + } + + @ViewBuilder + private var listView: some View { + if let listAction { + ContextualOnboardingListView(list: list, action: listAction) + } + } + + @ViewBuilder + private var imageView: some View { + if let imageName { + HStack { + Spacer() + Image(imageName) + Spacer() + } + } + } + + @ViewBuilder + private var actionView: some View { + if let cta, let action { + Button(action: action) { + Text(cta) + } + .buttonStyle(PrimaryButtonStyle(compact: true)) + } + } + + enum DisplayableTypes { + case title + case message + case list + case image + case button + } + + struct NonTypingAnimatableItems: OptionSet { + let rawValue: Int + + static let list = NonTypingAnimatableItems(rawValue: 1 << 0) + static let image = NonTypingAnimatableItems(rawValue: 1 << 1) + static let button = NonTypingAnimatableItems(rawValue: 1 << 2) + } +} + +// MARK: - Auxiliary Functions + +extension ContextualDaxDialogContent { + + private func startAnimating() { + if itemsToAnimate.contains(.title) { + startTypingTitle = true + } else if itemsToAnimate.contains(.message) { + startTypingMessage = true + } + } + + private func animateNonTypingItems() { + // Remove typing items and animate sequentially non typing items + let nonTypingItems = itemsToAnimate.filter { $0 != .title && $0 != .message } + + nonTypingItems.enumerated().forEach { index, item in + let delayForItem = Metrics.animationDelay * Double(index + 1) + withAnimation(.easeIn(duration: Metrics.animationDuration).delay(delayForItem)) { + switch item { + case .title, .message: + // Typing items. they don't need to animate sequentially. + break + case .list: + nonTypingAnimatableItems.insert(.list) + case .image: + nonTypingAnimatableItems.insert(.image) + case .button: + nonTypingAnimatableItems.insert(.button) + } + } + } + } +} + +// MARK: - Metrics + +extension ContextualDaxDialogContent { + enum Metrics { + static let animationDuration = 0.25 + static let animationDelay = 0.3 + } +} + + +// MARK: - Preview + +#Preview("Intro Dialog - text") { + let fullString = "Instantly clear your browsing activity with the Fire Button.\n\n Give it a try! ☝️" + let boldString = "Fire Button." + + let attributedString = NSMutableAttributedString(string: fullString) + let boldFontAttribute: [NSAttributedString.Key: Any] = [ + .font: UIFont.daxBodyBold() + ] + + if let boldRange = fullString.range(of: boldString) { + let nsBoldRange = NSRange(boldRange, in: fullString) + attributedString.addAttributes(boldFontAttribute, range: nsBoldRange) + } + + return ContextualDaxDialogContent(message: attributedString) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Intro Dialog - text and button") { + let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️") + return ContextualDaxDialogContent( + message: contextualText, + cta: "Got it!", + action: {}) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Intro Dialog - title, text, image and button") { + let contextualText = NSMutableAttributedString(string: "Sabrina is the best\n\nBelieve me! ☝️") + return ContextualDaxDialogContent( + title: "Who is the best?", + message: contextualText, + imageName: "Sync-Desktop-New-128", + cta: "Got it!", + action: {}) + .padding() + .preferredColorScheme(.light) +} + +#Preview("Intro Dialog - title, text, list") { + let contextualText = NSMutableAttributedString(string: "Sabrina is the best!\n\n Alessandro is ok I guess...") + let list = [ + ContextualOnboardingListItem.search(title: "Search"), + ContextualOnboardingListItem.site(title: "Website"), + ContextualOnboardingListItem.surprise(title: "Surprise"), + ] + return ContextualDaxDialogContent( + title: "Who is the best?", + message: contextualText, + list: list, + listAction: { _ in }) + .padding() + .preferredColorScheme(.light) +} + +#Preview("en_GB list") { + ContextualDaxDialogContent(title: "title", + message: "this is a message".attributedStringFromMarkdown(color: .blue), + list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_GB")).list, + listAction: { _ in }) + .padding() +} + +#Preview("en_US list") { + ContextualDaxDialogContent(title: "title", + message: "this is a message".attributedStringFromMarkdown(color: .blue), + list: OnboardingSuggestedSitesProvider(countryProvider: Locale(identifier: "en_US")).list, + listAction: { _ in }) + .padding() +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift new file mode 100644 index 0000000000..f83a220e2a --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift @@ -0,0 +1,240 @@ +// +// ContextualOnboardingDialogs.swift +// DuckDuckGo +// +// 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 SwiftUI + +struct OnboardingTrySearchDialog: View { + let title = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASearchTitle + let message = NSAttributedString(string: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASearchMessage) + let viewModel: OnboardingSearchSuggestionsViewModel + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .top) { + ContextualDaxDialogContent( + title: title, + message: message, + list: viewModel.itemsList, + listAction: viewModel.listItemPressed + ) + } + .padding() + } + } +} + +struct OnboardingTryVisitingSiteDialog: View { + let logoPosition: DaxDialogLogoPosition + let viewModel: OnboardingSiteSuggestionsViewModel + + var body: some View { + ScrollView(.vertical) { + DaxDialogView(logoPosition: logoPosition) { + OnboardingTryVisitingSiteDialogContent(viewModel: viewModel) + } + .padding() + } + } +} + +struct OnboardingTryVisitingSiteDialogContent: View { + let message = NSAttributedString(string: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteMessage) + + let viewModel: OnboardingSiteSuggestionsViewModel + + var body: some View { + ContextualDaxDialogContent( + title: viewModel.title, + message: message, + list: viewModel.itemsList, + listAction: viewModel.listItemPressed) + } +} + +struct OnboardingFireButtonDialogContent: View { + private let attributedMessage: NSAttributedString = { + let firstString = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryFireButtonMessage + let boldString = "Fire Button." + let attributedString = NSMutableAttributedString(string: firstString) + let boldFontAttribute: [NSAttributedString.Key: Any] = [ + .font: UIFont.daxBodyBold() + ] + if let boldRange = firstString.range(of: boldString) { + let nsBoldRange = NSRange(boldRange, in: firstString) + attributedString.addAttributes(boldFontAttribute, range: nsBoldRange) + } + + return attributedString + }() + + var body: some View { + ContextualDaxDialogContent( + message: attributedMessage) + } +} + +struct OnboardingFirstSearchDoneDialog: View { + let message = NSAttributedString(string: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFirstSearchDoneMessage) + let cta = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingGotItButton + + @State private var showNextScreen: Bool = false + + let shouldFollowUp: Bool + let viewModel: OnboardingSiteSuggestionsViewModel + let gotItAction: () -> Void + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .left) { + VStack { + if showNextScreen { + OnboardingTryVisitingSiteDialogContent(viewModel: viewModel) + } else { + ContextualDaxDialogContent(message: message, cta: cta) { + gotItAction() + withAnimation { + if shouldFollowUp { + showNextScreen = true + } + } + } + } + } + } + .padding() + } + } +} + +struct OnboardingFireDialog: View { + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .left) { + VStack { + OnboardingFireButtonDialogContent() + } + } + .padding() + } + } +} + +struct OnboardingTrackersDoneDialog: View { + let cta = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingGotItButton + + @State private var showNextScreen: Bool = false + + let shouldFollowUp: Bool + let message: NSAttributedString + let blockedTrackersCTAAction: () -> Void + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .left) { + VStack { + if showNextScreen { + OnboardingFireButtonDialogContent() + } else { + ContextualDaxDialogContent(message: message, cta: cta) { + blockedTrackersCTAAction() + if shouldFollowUp { + withAnimation { + showNextScreen = true + } + } + } + } + } + } + .padding() + } + } +} + +struct OnboardingFinalDialog: View { + let title = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenTitle + let message = NSAttributedString(string: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenMessage) + let cta = UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingFinalScreenButton + + let highFiveAction: () -> Void + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .left) { + ContextualDaxDialogContent( + title: title, + message: message, + cta: cta, + action: highFiveAction + ) + } + .padding() + } + } +} + +// MARK: - Preview + +#Preview("Try Search") { + OnboardingTrySearchDialog(viewModel: OnboardingSearchSuggestionsViewModel()) + .padding() +} + +#Preview("Try Site Top") { + OnboardingTryVisitingSiteDialog(logoPosition: .top, viewModel: OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteTitle)) + .padding() +} + +#Preview("Try Site Left") { + OnboardingTryVisitingSiteDialog(logoPosition: .left, viewModel: OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteTitle)) + .padding() +} + +#Preview("Try Fire Button") { + DaxDialogView(logoPosition: .left) { + OnboardingFireButtonDialogContent() + } + .padding() +} + +#Preview("First Search Dialog") { + OnboardingFirstSearchDoneDialog(shouldFollowUp: true, viewModel: OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteTitle), gotItAction: {}) + .padding() +} + +#Preview("Final Dialog") { + OnboardingFinalDialog(highFiveAction: {}) + .padding() +} + +#Preview("Trackers Dialog") { + OnboardingTrackersDoneDialog( + shouldFollowUp: true, + message: NSAttributedString(string: """ + Heads up! Instagram.com is owned by Facebook.\n\n + Facebook’s trackers lurk on about 40% of top websites 😱 but don’t worry!\n\n + I’ll block Facebook from seeing your activity on those sites. + """ + ), + blockedTrackersCTAAction: { } + ) + .padding() +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingList.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingList.swift new file mode 100644 index 0000000000..976555a7a3 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingList.swift @@ -0,0 +1,105 @@ +// +// ContextualOnboardingList.swift +// DuckDuckGo +// +// 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 SwiftUI +import DuckUI + +public enum ContextualOnboardingListItem: Equatable { + case search(title: String) + case site(title: String) + case surprise(title: String) + + var visibleTitle: String { + switch self { + case .search(let title): + return title + case .site(let title): + return title.replacingOccurrences(of: "https:", with: "") + case .surprise: + return UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOptionSurpriseMeTitle + } + } + + var title: String { + switch self { + case .search(let title): + return title + .replacingOccurrences(of: "”", with: "") + .replacingOccurrences(of: "“", with: "") + case .site(let title): + return title + case .surprise(let title): + return title + } + } + + var imageName: String { + switch self { + case .search: + return "SuggestLoupe" + case .site: + return "SuggestGlobe" + case .surprise: + return "Wand-16" + } + } +} + +struct ContextualOnboardingListView: View { + let list: [ContextualOnboardingListItem] + var action: (_ item: ContextualOnboardingListItem) -> Void + let iconSize = 16.0 + + var body: some View { + VStack { + ForEach(list.indices, id: \.self) { index in + Button(action: { + action(list[index]) + }, label: { + HStack { + Image(list[index].imageName) + .frame(width: iconSize, height: iconSize) + Text(list[index].visibleTitle) + .frame(alignment: .leading) + Spacer() + } + }) + .buttonStyle(OnboardingStyles.ListButtonStyle()) + .overlay( + RoundedRectangle(cornerRadius: 8) + .inset(by: 0.5) + .stroke(.blue, lineWidth: 1) + ) + } + } + } +} + +// MARK: - Preview + +#Preview("List") { + let list = [ + ContextualOnboardingListItem.search(title: "Search"), + ContextualOnboardingListItem.site(title: "Website"), + ContextualOnboardingListItem.surprise(title: "Surprise"), + ] + return ContextualOnboardingListView(list: list) { _ in } + .padding() +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift new file mode 100644 index 0000000000..0beeabbdd6 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/NewTabDaxDialogFactory.swift @@ -0,0 +1,125 @@ +// +// NewTabDaxDialogFactory.swift +// DuckDuckGo +// +// 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 SwiftUI + +protocol NewTabDaxDialogProvider { + associatedtype DaxDialog: View + func createDaxDialog(for homeDialog: DaxDialogs.HomeScreenSpec, onDismiss: @escaping () -> Void) -> DaxDialog +} + +final class NewTabDaxDialogFactory: NewTabDaxDialogProvider { + private var delegate: OnboardingNavigationDelegate? + private let contextualOnboardingLogic: ContextualOnboardingLogic + private let onboardingPixelReporter: OnboardingScreenImpressionReporting + + init( + delegate: OnboardingNavigationDelegate?, + contextualOnboardingLogic: ContextualOnboardingLogic, + onboardingPixelReporter: OnboardingScreenImpressionReporting = OnboardingPixelReporter() + ) { + self.delegate = delegate + self.contextualOnboardingLogic = contextualOnboardingLogic + self.onboardingPixelReporter = onboardingPixelReporter + } + + @ViewBuilder + func createDaxDialog(for homeDialog: DaxDialogs.HomeScreenSpec, onDismiss: @escaping () -> Void) -> some View { + switch homeDialog { + case .initial: + createInitialDialog() + case .addFavorite: + createAddFavoriteDialog(message: homeDialog.message) + case .subsequent: + createSubsequentDialog() + case .final: + createFinalDialog(onDismiss: onDismiss) + default: + EmptyView() + + } + } + + private func createInitialDialog() -> some View { + let viewModel = OnboardingSearchSuggestionsViewModel(delegate: delegate) + return FadeInView { + OnboardingTrySearchDialog(viewModel: viewModel) + .onboardingDaxDialogStyle() + } + .onboardingContextualBackgroundStyle() + .onFirstAppear { [weak self] in + self?.onboardingPixelReporter.trackScreenImpression(event: .onboardingContextualTrySearchUnique) + } + } + + private func createSubsequentDialog() -> some View { + let viewModel = OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteNTPTitle, delegate: delegate) + return FadeInView { + OnboardingTryVisitingSiteDialog(logoPosition: .top, viewModel: viewModel) + .onboardingDaxDialogStyle() + } + .onboardingContextualBackgroundStyle() + .onFirstAppear { [weak self] in + self?.onboardingPixelReporter.trackScreenImpression(event: .onboardingContextualTryVisitSiteUnique) + } + } + + private func createAddFavoriteDialog(message: String) -> some View { + FadeInView { + DaxDialogView(logoPosition: .top) { + ContextualDaxDialogContent(message: NSAttributedString(string: message)) + } + .padding() + } + } + + private func createFinalDialog(onDismiss: @escaping () -> Void) -> some View { + FadeInView { + OnboardingFinalDialog(highFiveAction: { + onDismiss() + }) + .onboardingDaxDialogStyle() + } + .onboardingContextualBackgroundStyle() + .onFirstAppear { [weak self] in + self?.contextualOnboardingLogic.setFinalOnboardingDialogSeen() + self?.onboardingPixelReporter.trackScreenImpression(event: .daxDialogsEndOfJourneyNewTabUnique) + } + } +} + +struct FadeInView: View { + var content: Content + @State private var opacity: Double = 0 + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .opacity(opacity) + .onAppear { + withAnimation(.easeIn(duration: 0.4)) { + opacity = 1.0 + } + } + } +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift new file mode 100644 index 0000000000..2a4e631a65 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/OnboardingSuggestionsViewModel.swift @@ -0,0 +1,80 @@ +// +// OnboardingSuggestionsViewModel.swift +// DuckDuckGo +// +// 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 + +protocol OnboardingNavigationDelegate: AnyObject { + func searchFor(_ query: String) + func navigateTo(url: URL) +} + +struct OnboardingSearchSuggestionsViewModel { + let suggestedSearchesProvider: OnboardingSuggestionsItemsProviding + weak var delegate: OnboardingNavigationDelegate? + private let pixelReporter: OnboardingSearchSuggestionsPixelReporting + + init( + suggestedSearchesProvider: OnboardingSuggestionsItemsProviding = OnboardingSuggestedSearchesProvider(), + delegate: OnboardingNavigationDelegate? = nil, + pixelReporter: OnboardingSearchSuggestionsPixelReporting = OnboardingPixelReporter() + ) { + self.suggestedSearchesProvider = suggestedSearchesProvider + self.delegate = delegate + self.pixelReporter = pixelReporter + } + + var itemsList: [ContextualOnboardingListItem] { + suggestedSearchesProvider.list + } + + func listItemPressed(_ item: ContextualOnboardingListItem) { + pixelReporter.trackSearchSuggetionOptionTapped() + delegate?.searchFor(item.title) + } +} + +struct OnboardingSiteSuggestionsViewModel { + let suggestedSitesProvider: OnboardingSuggestionsItemsProviding + weak var delegate: OnboardingNavigationDelegate? + private let pixelReporter: OnboardingSiteSuggestionsPixelReporting + + init( + title: String, + suggestedSitesProvider: OnboardingSuggestionsItemsProviding = OnboardingSuggestedSitesProvider(), + delegate: OnboardingNavigationDelegate? = nil, + pixelReporter: OnboardingSiteSuggestionsPixelReporting = OnboardingPixelReporter() + ) { + self.title = title + self.suggestedSitesProvider = suggestedSitesProvider + self.delegate = delegate + self.pixelReporter = pixelReporter + } + + let title: String + + var itemsList: [ContextualOnboardingListItem] { + suggestedSitesProvider.list + } + + func listItemPressed(_ item: ContextualOnboardingListItem) { + guard let url = URL(string: item.title) else { return } + pixelReporter.trackSiteSuggetionOptionTapped() + delegate?.navigateTo(url: url) + } +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift new file mode 100644 index 0000000000..1f71c8b4d1 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -0,0 +1,193 @@ +// +// ContextualDaxDialogsFactory.swift +// DuckDuckGo +// +// 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 SwiftUI +import Core + +// MARK: - ContextualOnboardingEventDelegate + +/// A delegate to inform about specific events happening during the contextual onboarding. +protocol ContextualOnboardingEventDelegate: AnyObject { + func didAcknowledgeContextualOnboardingSearch() + /// Inform the delegate that a dialog for blocked trackers have been shown to the user. + func didShowContextualOnboardingTrackersDialog() + /// Inform the delegate that the user did acknowledge the dialog for blocked trackers. + func didAcknowledgeContextualOnboardingTrackersDialog() + /// Inform the delegate that the user dismissed the contextual dialog. + func didTapDismissContextualOnboardingAction() +} + +// Composed delegate for Contextual Onboarding to decorate events also needed in New Tab Page. +typealias ContextualOnboardingDelegate = OnboardingNavigationDelegate & ContextualOnboardingEventDelegate + +// MARK: - Contextual Dialogs Factory + +protocol ContextualDaxDialogsFactory { + func makeView(for spec: DaxDialogs.BrowsingSpec, delegate: ContextualOnboardingDelegate, onSizeUpdate: @escaping () -> Void) -> UIHostingController +} + +final class ExperimentContextualDaxDialogsFactory: ContextualDaxDialogsFactory { + private let contextualOnboardingLogic: ContextualOnboardingLogic + private let contextualOnboardingSettings: ContextualOnboardingSettings + private let contextualOnboardingPixelReporter: OnboardingScreenImpressionReporting + + init( + contextualOnboardingLogic: ContextualOnboardingLogic, + contextualOnboardingSettings: ContextualOnboardingSettings = DefaultDaxDialogsSettings(), + contextualOnboardingPixelReporter: OnboardingScreenImpressionReporting = OnboardingPixelReporter() + ) { + self.contextualOnboardingSettings = contextualOnboardingSettings + self.contextualOnboardingLogic = contextualOnboardingLogic + self.contextualOnboardingPixelReporter = contextualOnboardingPixelReporter + } + + func makeView(for spec: DaxDialogs.BrowsingSpec, delegate: ContextualOnboardingDelegate, onSizeUpdate: @escaping () -> Void) -> UIHostingController { + let rootView: AnyView + switch spec.type { + case .afterSearch: + rootView = AnyView( + afterSearchDialog( + shouldFollowUpToWebsiteSearch: !contextualOnboardingSettings.userHasSeenTrackersDialog, + delegate: delegate, + afterSearchPixelEvent: spec.pixelName, + onSizeUpdate: onSizeUpdate + ) + ) + case .visitWebsite: + rootView = AnyView(tryVisitingSiteDialog(delegate: delegate)) + case .siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withOneTracker, .withoutTrackers: + rootView = AnyView( + withTrackersDialog( + for: spec, + shouldFollowUpToFireDialog: !contextualOnboardingSettings.userHasSeenFireDialog, + delegate: delegate, + onSizeUpdate: onSizeUpdate + ) + ) + case .fire: + rootView = AnyView(fireDialog(pixelName: spec.pixelName)) + case .final: + rootView = AnyView(endOfJourneyDialog(delegate: delegate, pixelName: spec.pixelName)) + } + + let viewWithBackground = rootView + .onboardingDaxDialogStyle() + .onboardingContextualBackgroundStyle() + let hostingController = UIHostingController(rootView: AnyView(viewWithBackground)) + if #available(iOS 16.0, *) { + hostingController.sizingOptions = [.intrinsicContentSize] + } + + return hostingController + } + + private func afterSearchDialog( + shouldFollowUpToWebsiteSearch: Bool, + delegate: ContextualOnboardingDelegate, + afterSearchPixelEvent: Pixel.Event, + onSizeUpdate: @escaping () -> Void + ) -> some View { + let viewModel = OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteTitle, delegate: delegate) + + // If should not show websites search after searching inform the delegate that the user dimissed the dialog, otherwise let the dialog handle it. + let gotItAction: () -> Void = if shouldFollowUpToWebsiteSearch { + { [weak delegate, weak self] in + onSizeUpdate() + delegate?.didAcknowledgeContextualOnboardingSearch() + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: .onboardingContextualTryVisitSiteUnique) + } + } else { + { [weak delegate] in + delegate?.didTapDismissContextualOnboardingAction() + } + } + + return OnboardingFirstSearchDoneDialog(shouldFollowUp: shouldFollowUpToWebsiteSearch, viewModel: viewModel, gotItAction: gotItAction) + .onFirstAppear { [weak self] in + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: afterSearchPixelEvent) + } + } + + private func tryVisitingSiteDialog(delegate: ContextualOnboardingDelegate) -> some View { + let viewModel = OnboardingSiteSuggestionsViewModel(title: UserText.DaxOnboardingExperiment.ContextualOnboarding.onboardingTryASiteTitle, delegate: delegate) + return OnboardingTryVisitingSiteDialog(logoPosition: .left, viewModel: viewModel) + .onFirstAppear { [weak self] in + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: .onboardingContextualTryVisitSiteUnique) + } + } + + private func withTrackersDialog(for spec: DaxDialogs.BrowsingSpec, shouldFollowUpToFireDialog: Bool, delegate: ContextualOnboardingDelegate, onSizeUpdate: @escaping () -> Void) -> some View { + let attributedMessage = spec.message.attributedStringFromMarkdown(color: ThemeManager.shared.currentTheme.daxDialogTextColor) + return OnboardingTrackersDoneDialog(shouldFollowUp: shouldFollowUpToFireDialog, message: attributedMessage, blockedTrackersCTAAction: { [weak self, weak delegate] in + // If the user has not seen the fire dialog yet proceed to the fire dialog, otherwise dismiss the dialog. + if self?.contextualOnboardingSettings.userHasSeenFireDialog == true { + delegate?.didTapDismissContextualOnboardingAction() + } else { + onSizeUpdate() + delegate?.didAcknowledgeContextualOnboardingTrackersDialog() + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: .daxDialogsFireEducationShownUnique) + } + }) + .onAppear { [weak delegate] in + delegate?.didShowContextualOnboardingTrackersDialog() + } + .onFirstAppear { [weak self] in + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: spec.pixelName) + } + } + + private func fireDialog(pixelName: Pixel.Event) -> some View { + OnboardingFireDialog() + .onFirstAppear { [weak self] in + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: pixelName) + } + } + + private func endOfJourneyDialog(delegate: ContextualOnboardingDelegate, pixelName: Pixel.Event) -> some View { + OnboardingFinalDialog(highFiveAction: { [weak delegate] in + delegate?.didTapDismissContextualOnboardingAction() + }) + .onFirstAppear { [weak self] in + self?.contextualOnboardingLogic.setFinalOnboardingDialogSeen() + self?.contextualOnboardingPixelReporter.trackScreenImpression(event: pixelName) + } + } + +} + +// MARK: - Contextual Onboarding Settings + +protocol ContextualOnboardingSettings { + var userHasSeenTrackersDialog: Bool { get } + var userHasSeenFireDialog: Bool { get } +} + +extension DefaultDaxDialogsSettings: ContextualOnboardingSettings { + + var userHasSeenTrackersDialog: Bool { + browsingWithTrackersShown || + browsingWithoutTrackersShown || + browsingMajorTrackingSiteShown + } + + var userHasSeenFireDialog: Bool { + fireMessageExperimentShown + } + +} diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift new file mode 100644 index 0000000000..1d1e8d1153 --- /dev/null +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualOnboardingPresenter.swift @@ -0,0 +1,159 @@ +// +// ContextualOnboardingPresenter.swift +// DuckDuckGo +// +// 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 BrowserServicesKit +import Core + +// Typealias for TabViewControllerType used for testing and Contextual Onboarding Delegate +typealias TabViewOnboardingDelegate = TabViewControllerType & ContextualOnboardingDelegate + +// MARK: - Contextual Onboarding Presenter + +protocol ContextualOnboardingPresenting { + func presentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewOnboardingDelegate) + func dismissContextualOnboardingIfNeeded(from vc: TabViewOnboardingDelegate) +} + +final class ContextualOnboardingPresenter: ContextualOnboardingPresenting { + private let variantManager: VariantManager + private let daxDialogsFactory: ContextualDaxDialogsFactory + private let appSettings: AppSettings + + init( + variantManager: VariantManager, + daxDialogsFactory: ContextualDaxDialogsFactory = ExperimentContextualDaxDialogsFactory(contextualOnboardingLogic: DaxDialogs.shared), + appSettings: AppSettings = AppUserDefaults() + ) { + self.variantManager = variantManager + self.daxDialogsFactory = daxDialogsFactory + self.appSettings = appSettings + } + + func presentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewOnboardingDelegate) { + if variantManager.isSupported(feature: .newOnboardingIntro) { + presentExperimentContextualOnboarding(for: spec, in: vc) + } else { + presentControlContextualOnboarding(for: spec, in: vc) + } + } + + func dismissContextualOnboardingIfNeeded(from vc: TabViewOnboardingDelegate) { + guard variantManager.isSupported(feature: .newOnboardingIntro), let daxContextualOnboarding = vc.daxContextualOnboardingController else { return } + remove(daxController: daxContextualOnboarding, fromParent: vc) + } + +} + +// MARK: - Private + +private extension ContextualOnboardingPresenter { + + func presentControlContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewOnboardingDelegate) { + vc.performSegue(withIdentifier: "DaxDialog", sender: spec) + } + + func presentExperimentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewOnboardingDelegate) { + + // Before presenting a new dialog, remove any existing ones. + vc.daxDialogsStackView.arrangedSubviews.filter({ $0 != vc.webViewContainerView }).forEach { + vc.daxDialogsStackView.removeArrangedSubview($0) + $0.removeFromSuperview() + } + + // Adjust message hand emoji based on address bar position + let platformSpecificMessage = spec.message.replacingOccurrences( + of: "☝️", + with: appSettings.currentAddressBarPosition == .bottom ? "👇" : "☝️" + ) + let platformSpecificSpec = spec.withUpdatedMessage(platformSpecificMessage) + // Ask the Dax Dialogs Factory for a view for the given spec + let controller = daxDialogsFactory.makeView(for: platformSpecificSpec, delegate: vc, onSizeUpdate: { [weak vc] in + if #unavailable(iOS 16.0) { + // For iOS 15 and below invalidate the intrinsic content size manually so the UIKit view will re-size accordingly to SwiftUI view. + vc?.daxContextualOnboardingController?.view.invalidateIntrinsicContentSize() + } + }) + controller.view.isHidden = true + controller.view.alpha = 0 + + vc.insertChild(controller, in: vc.daxDialogsStackView, at: 0) + vc.daxContextualOnboardingController = controller + + animate(daxController: controller, visible: true) + } + + func remove(daxController: UIViewController, fromParent parent: TabViewOnboardingDelegate) { + animate(daxController: daxController, visible: false) { _ in + parent.daxDialogsStackView.removeArrangedSubview(daxController.view) + parent.removeChild(daxController) + parent.daxContextualOnboardingController = nil + } + } + + func animate(daxController: UIViewController, visible isVisible: Bool, onCompletion: ((Bool) -> Void)? = nil) { + daxController.view.isHidden = !isVisible + UIView.animate( + withDuration: 0.3, + animations: { + daxController.view.alpha = isVisible ? 1 : 0 + daxController.parent?.view.layoutIfNeeded() + }, + completion: onCompletion + ) + } + +} + +// MARK: - Helpers + +private extension UIViewController { + + func insertChild(_ childController: UIViewController, in stackView: UIStackView, at index: Int) { + addChild(childController) + stackView.insertArrangedSubview(childController.view, at: index) + childController.didMove(toParent: self) + } + + func removeChild(_ childController: UIViewController) { + childController.willMove(toParent: nil) + childController.view.removeFromSuperview() + childController.removeFromParent() + } + +} + +// MARK: - TabViewControllerType + +protocol TabViewControllerType: UIViewController { + var daxDialogsStackView: UIStackView { get } + var webViewContainerView: UIView { get } + var daxContextualOnboardingController: UIViewController? { get set } +} + +extension TabViewController: TabViewControllerType { + + var daxDialogsStackView: UIStackView { + containerStackView + } + + var webViewContainerView: UIView { + webViewContainer + } +} diff --git a/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogBrowsersComparisonView.swift b/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogBrowsersComparisonView.swift index bc90273bd5..f88825aa2a 100644 --- a/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogBrowsersComparisonView.swift +++ b/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogBrowsersComparisonView.swift @@ -39,7 +39,8 @@ struct DaxDialogBrowsersComparisonView: View { }, content: { VStack(spacing: 16.0) { - AnimatableTypingText(UserText.DaxOnboardingExperiment.BrowsersComparison.title, startAnimating: $animateText) { + let attributedString = NSAttributedString(string: UserText.DaxOnboardingExperiment.Intro.title) + AnimatableTypingText(attString, startAnimating: $animateText) { withAnimation { showButton = true } diff --git a/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogView.swift b/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogView.swift index 7d8afa6cb8..1402c24f11 100644 --- a/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogView.swift +++ b/DuckDuckGo/OnboardingExperiment/DaxDialogs/DaxDialogView.swift @@ -35,17 +35,18 @@ private enum Metrics { // MARK: - DaxDialog -struct DaxDialogView: View { +enum DaxDialogLogoPosition { + case top + case left +} - enum LogoPosition { - case top - case left - } +struct DaxDialogView: View { @Environment(\.colorScheme) var colorScheme - @State private var logoPosition: LogoPosition - private let matchLogoAnimation: (id: String, namespace: Namespace.ID) + @State private var logoPosition: DaxDialogLogoPosition + + private let matchLogoAnimation: (id: String, namespace: Namespace.ID)? private let showDialogBox: Binding private let cornerRadius: CGFloat private let arrowSize: CGSize @@ -53,8 +54,8 @@ struct DaxDialogView: View { private let content: Content init( - logoPosition: LogoPosition, - matchLogoAnimation: (String, Namespace.ID) = ("", Namespace().wrappedValue), + logoPosition: DaxDialogLogoPosition, + matchLogoAnimation: (String, Namespace.ID)? = nil, showDialogBox: Binding = .constant(true), cornerRadius: CGFloat = 16.0, arrowSize: CGSize = .init(width: 16.0, height: 8.0), @@ -108,12 +109,18 @@ struct DaxDialogView: View { Metrics.stackSpacing + arrowSize.height } + @ViewBuilder private var daxLogo: some View { - Image(.daxIconExperiment) + let icon = Image(.daxIconExperiment) .resizable() - .matchedGeometryEffect(id: matchLogoAnimation.id, in: matchLogoAnimation.namespace) .aspectRatio(contentMode: .fill) .frame(width: Metrics.DaxLogo.size, height: Metrics.DaxLogo.size) + + if let matchLogoAnimation { + icon.matchedGeometryEffect(id: matchLogoAnimation.id, in: matchLogoAnimation.namespace) + } else { + icon + } } private var wrappedContent: some View { diff --git a/DuckDuckGo/OnboardingExperiment/Pixels/OnboardingPixelReporter.swift b/DuckDuckGo/OnboardingExperiment/Pixels/OnboardingPixelReporter.swift index d1310a5627..ab9f2b6144 100644 --- a/DuckDuckGo/OnboardingExperiment/Pixels/OnboardingPixelReporter.swift +++ b/DuckDuckGo/OnboardingExperiment/Pixels/OnboardingPixelReporter.swift @@ -19,6 +19,7 @@ import Foundation import Core +import BrowserServicesKit // MARK: - Pixel Fire Interface @@ -49,29 +50,63 @@ protocol OnboardingIntroPixelReporting: OnboardingIntroImpressionReporting { func trackChooseBrowserCTAAction() } +protocol OnboardingSearchSuggestionsPixelReporting { + func trackSearchSuggetionOptionTapped() +} + +protocol OnboardingSiteSuggestionsPixelReporting { + func trackSiteSuggetionOptionTapped() +} + +protocol OnboardingCustomInteractionPixelReporting { + func trackCustomSearch() + func trackCustomSite() + func trackSecondSiteVisit() + func trackPrivacyDashboardOpenedForFirstTime() +} + +protocol OnboardingScreenImpressionReporting { + func trackScreenImpression(event: Pixel.Event) +} + // MARK: - Implementation final class OnboardingPixelReporter { private let pixel: OnboardingPixelFiring.Type private let uniquePixel: OnboardingPixelFiring.Type - - init(pixel: OnboardingPixelFiring.Type = Pixel.self, uniquePixel: OnboardingPixelFiring.Type = UniquePixel.self) { + private let statisticsStore: StatisticsStore + private let calendar: Calendar + private let dateProvider: () -> Date + private let userDefaults: UserDefaults + private let siteVisitedUserDefaultsKey = "com.duckduckgo.ios.site-visited" + + init( + pixel: OnboardingPixelFiring.Type = Pixel.self, + uniquePixel: OnboardingPixelFiring.Type = UniquePixel.self, + statisticsStore: StatisticsStore = StatisticsUserDefaults(), + calendar: Calendar = .current, + dateProvider: @escaping () -> Date = Date.init, + userDefaults: UserDefaults = UserDefaults.app + ) { self.pixel = pixel self.uniquePixel = uniquePixel + self.statisticsStore = statisticsStore + self.calendar = calendar + self.dateProvider = dateProvider + self.userDefaults = userDefaults } - private func fire(event: Pixel.Event, unique: Bool, additionalParameters: [String: String] = [:]) { - let parameters: [Pixel.QueryParameters] = [.appVersion, .atb] + private func fire(event: Pixel.Event, unique: Bool, additionalParameters: [String: String] = [:], includedParameters: [Pixel.QueryParameters] = [.appVersion, .atb]) { if unique { - uniquePixel.fire(pixel: event, withAdditionalParameters: additionalParameters, includedParameters: parameters) + uniquePixel.fire(pixel: event, withAdditionalParameters: additionalParameters, includedParameters: includedParameters) } else { - pixel.fire(pixel: event, withAdditionalParameters: additionalParameters, includedParameters: parameters) + pixel.fire(pixel: event, withAdditionalParameters: additionalParameters, includedParameters: includedParameters) } } } -// MARK: - OnboardingAnalytics + Intro +// MARK: - OnboardingPixelReporter + Intro extension OnboardingPixelReporter: OnboardingIntroPixelReporting { @@ -88,3 +123,62 @@ extension OnboardingPixelReporter: OnboardingIntroPixelReporting { } } + +// MARK: - OnboardingPixelReporter + List + +extension OnboardingPixelReporter: OnboardingSearchSuggestionsPixelReporting { + + func trackSearchSuggetionOptionTapped() { + fire(event: .onboardingContextualSearchOptionTappedUnique, unique: true) + } + +} + +extension OnboardingPixelReporter: OnboardingSiteSuggestionsPixelReporting { + + func trackSiteSuggetionOptionTapped() { + fire(event: .onboardingContextualSiteOptionTappedUnique, unique: true) + } + +} + +// MARK: - OnboardingPixelReporter + Custom Interaction + +extension OnboardingPixelReporter: OnboardingCustomInteractionPixelReporting { + + func trackCustomSearch() { + fire(event: .onboardingContextualSearchCustomUnique, unique: true) + } + + func trackCustomSite() { + fire(event: .onboardingContextualSiteCustomUnique, unique: true) + } + + func trackSecondSiteVisit() { + if userDefaults.bool(forKey: siteVisitedUserDefaultsKey) { + fire(event: .onboardingContextualSecondSiteVisitUnique, unique: true) + } else { + userDefaults.set(true, forKey: siteVisitedUserDefaultsKey) + } + } + + func trackPrivacyDashboardOpenedForFirstTime() { + let daysSinceInstall = statisticsStore.installDate.flatMap { calendar.numberOfDaysBetween($0, and: dateProvider()) } + let additionalParameters = [ + PixelParameters.fromOnboarding: "true", + PixelParameters.daysSinceInstall: String(daysSinceInstall ?? 0) + ] + fire(event: .privacyDashboardFirstTimeOpenedUnique, unique: true, additionalParameters: additionalParameters, includedParameters: [.appVersion]) + } + +} + +// MARK: - OnboardingPixelReporter + Screen Impression + +extension OnboardingPixelReporter: OnboardingScreenImpressionReporting { + + func trackScreenImpression(event: Pixel.Event) { + fire(event: event, unique: true) + } + +} diff --git a/DuckDuckGo/OnboardingExperiment/Styles/DaxDialogStyles.swift b/DuckDuckGo/OnboardingExperiment/Styles/DaxDialogStyles.swift index 147955a73a..4d85103cf2 100644 --- a/DuckDuckGo/OnboardingExperiment/Styles/DaxDialogStyles.swift +++ b/DuckDuckGo/OnboardingExperiment/Styles/DaxDialogStyles.swift @@ -32,6 +32,63 @@ extension OnboardingStyles { } + struct BackgroundStyle: ViewModifier { + + func body(content: Content) -> some View { + ZStack { + OnboardingBackground() + .ignoresSafeArea(.keyboard) + + content + } + } + + } + + struct ListButtonStyle: ButtonStyle { + @Environment(\.colorScheme) private var colorScheme + + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(Font(UIFont.boldAppFont(ofSize: 15))) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + .lineLimit(nil) + .foregroundColor(foregroundColor(configuration.isPressed)) + .padding() + .frame(minWidth: 0, maxWidth: .infinity, maxHeight: 40) + .background(backgroundColor(configuration.isPressed)) + .cornerRadius(8) + .contentShape(Rectangle()) // Makes whole button area tappable, when there's no background + } + + private func foregroundColor(_ isPressed: Bool) -> Color { + switch (colorScheme, isPressed) { + case (.dark, false): + return .blue30 + case (.dark, true): + return .blue20 + case (_, false): + return .blueBase + case (_, true): + return .blue70 + } + } + + private func backgroundColor(_ isPressed: Bool) -> Color { + switch (colorScheme, isPressed) { + case (.light, true): + return .blueBase.opacity(0.2) + case (.dark, true): + return .blue30.opacity(0.2) + default: + return .clear + } + } + } + } private enum Metrics { @@ -43,4 +100,9 @@ extension View { func onboardingDaxDialogStyle() -> some View { modifier(OnboardingStyles.DaxDialogStyle()) } + + func onboardingContextualBackgroundStyle() -> some View { + modifier(OnboardingStyles.BackgroundStyle()) + } + } diff --git a/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift b/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift index 683ae77757..283c8cff3a 100644 --- a/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift +++ b/DuckDuckGo/OnboardingExperiment/Styles/OnboardingTextStyles.swift @@ -24,6 +24,7 @@ enum OnboardingStyles {} extension OnboardingStyles { struct TitleStyle: ViewModifier { + let fontSize: CGFloat func body(content: Content) -> some View { diff --git a/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSearchesProvider.swift b/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSearchesProvider.swift new file mode 100644 index 0000000000..88c2f86f35 --- /dev/null +++ b/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSearchesProvider.swift @@ -0,0 +1,87 @@ +// +// OnboardingSuggestedSearchesProvider.swift +// DuckDuckGo +// +// 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 + +protocol OnboardingRegionAndLanguageProvider { + var regionCode: String? { get } + var languageCode: String? { get } +} + +struct OnboardingSuggestedSearchesProvider: OnboardingSuggestionsItemsProviding { + private let countryAndLanguageProvider: OnboardingRegionAndLanguageProvider + + init(countryAndLanguageProvider: OnboardingRegionAndLanguageProvider = Locale.current) { + self.countryAndLanguageProvider = countryAndLanguageProvider + } + + var list: [ContextualOnboardingListItem] { + return [ + option1, + option2, + option3, + surpriseMe + ] + } + + private var country: String? { + countryAndLanguageProvider.regionCode + } + private var language: String? { + countryAndLanguageProvider.languageCode + } + + private var option1: ContextualOnboardingListItem { + var search: String + if language == "en" { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOption1English + } else { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOption1International + } + return ContextualOnboardingListItem.search(title: search) + } + + private var option2: ContextualOnboardingListItem { + var search: String + if country == "us" { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOption2English + } else { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOption2International + } + return ContextualOnboardingListItem.search(title: search) + } + + private var option3: ContextualOnboardingListItem { + let search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOption3 + return ContextualOnboardingListItem.search(title: search) + } + + private var surpriseMe: ContextualOnboardingListItem { + var search: String + if country == "us" { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOptionSurpriseMeEnglish + } else { + search = UserText.DaxOnboardingExperiment.ContextualOnboarding.tryASearchOptionSurpriseMeInternational + } + return ContextualOnboardingListItem.surprise(title: search) + } + +} + +extension Locale: OnboardingRegionAndLanguageProvider {} diff --git a/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSitesProvider.swift b/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSitesProvider.swift new file mode 100644 index 0000000000..ab7a120bce --- /dev/null +++ b/DuckDuckGo/OnboardingHelpers/OnboardingSuggestedSitesProvider.swift @@ -0,0 +1,115 @@ +// +// OnboardingSuggestedSitesProvider.swift +// DuckDuckGo +// +// 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 + +protocol OnboardingSuggestionsItemsProviding { + var list: [ContextualOnboardingListItem] { get } +} + +struct OnboardingSuggestedSitesProvider: OnboardingSuggestionsItemsProviding { + private let countryProvider: OnboardingRegionAndLanguageProvider + + init(countryProvider: OnboardingRegionAndLanguageProvider = Locale.current) { + self.countryProvider = countryProvider + } + + private let scheme = "https:" + + enum Countries: String { + case indonesia = "ID" + case gb = "GB" + case germany = "DE" + case canada = "CA" + case netherlands = "NL" + case australia = "AU" + case sweden = "SE" + case ireland = "IE" + } + + var list: [ContextualOnboardingListItem] { + return [ + option1, + option2, + option3, + surpriseMe + ] + } + + private var country: String { + countryProvider.regionCode ?? "" + } + + private var option1: ContextualOnboardingListItem { + let site: String + switch Countries(rawValue: country) { + case .indonesia: site = "bolasport.com" + case .gb: site = "skysports.com" + case .germany: site = "bundesliga.de" + case .canada: site = "tsn.ca" + case .netherlands: site = "voetbalprimeur.nl" + case .australia: site = "afl.com.au" + case .sweden: site = "svenskafans.com" + case .ireland: site = "skysports.com" + default: site = "ESPN.com" + } + return ContextualOnboardingListItem.site(title: scheme + site) + } + + private var option2: ContextualOnboardingListItem { + let site: String + switch Countries(rawValue: country) { + case .indonesia: site = "kompas.com" + case .gb: site = "bbc.co.uk" + case .germany: site = "tagesschau.de" + case .canada: site = "ctvnews.ca" + case .netherlands: site = "nu.nl" + case .australia: site = "yahoo.com" + case .sweden: site = "dn.se" + case .ireland: site = "bbc.co.uk" + default: site = "yahoo.com" + } + return ContextualOnboardingListItem.site(title: scheme + site) + } + + private var option3: ContextualOnboardingListItem { + let site: String + switch Countries(rawValue: country) { + case .indonesia: site = "tokopedia.com" + case .gb, .australia, .ireland: site = "eBay.com" + case .germany: site = "galeria.de" + case .canada: site = "canadiantire.ca" + case .netherlands: site = "bol.com" + case .sweden: site = "tradera.com" + default: site = "eBay.com" + } + return ContextualOnboardingListItem.site(title: scheme + site) + } + + private var surpriseMe: ContextualOnboardingListItem { + let site: String + switch Countries(rawValue: country) { + case .germany: site = "https://duden.de" + case .netherlands: site = "https://www.woorden.org/woord/eend" + case .sweden: site = "https://www.synonymer.se/sv-syn/anka" + default: site = "https:britannica.com/animal/duck" + } + return ContextualOnboardingListItem.surprise(title: site) + } +} diff --git a/DuckDuckGo/PrivacyIconAndTrackersAnimator.swift b/DuckDuckGo/PrivacyIconAndTrackersAnimator.swift index 69726cdcc3..ea1bc96313 100644 --- a/DuckDuckGo/PrivacyIconAndTrackersAnimator.swift +++ b/DuckDuckGo/PrivacyIconAndTrackersAnimator.swift @@ -38,8 +38,8 @@ final class PrivacyIconAndTrackersAnimator { private(set) var state: State = .notStarted - var onAnimationCompletion: (() -> Void)? - + private var animationCompletionObservers: [() -> Void] = [] + func configure(_ container: PrivacyInfoContainerView, with privacyInfo: PrivacyInfo) { state = .notStarted isAnimatingForDaxDialog = false @@ -96,8 +96,8 @@ final class PrivacyIconAndTrackersAnimator { if completed { self?.state = .completed - self?.onAnimationCompletion?() - self?.onAnimationCompletion = nil + self?.animationCompletionObservers.forEach { action in action() } + self?.animationCompletionObservers = [] } } } @@ -143,8 +143,8 @@ final class PrivacyIconAndTrackersAnimator { func completeForNoAnimation() { state = .completed - onAnimationCompletion?() - onAnimationCompletion = nil + animationCompletionObservers.forEach { action in action() } + animationCompletionObservers = [] } func cancelAnimations(in omniBar: OmniBar) { @@ -169,4 +169,9 @@ final class PrivacyIconAndTrackersAnimator { func resetImageProvider() { trackerAnimationImageProvider.reset() } + + func onAnimationCompletion(_ completion: @escaping () -> Void) { + animationCompletionObservers.append(completion) + } + } diff --git a/DuckDuckGo/PrivacyIconContextualOnboardingAnimator.swift b/DuckDuckGo/PrivacyIconContextualOnboardingAnimator.swift new file mode 100644 index 0000000000..10b762b238 --- /dev/null +++ b/DuckDuckGo/PrivacyIconContextualOnboardingAnimator.swift @@ -0,0 +1,38 @@ +// +// PrivacyIconContextualOnboardingAnimator.swift +// DuckDuckGo +// +// 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 + +final class PrivacyIconContextualOnboardingAnimator { + + func showPrivacyIconAnimation(in omniBar: OmniBar) { + guard let window = omniBar.window else { return } + ViewHighlighter.showIn(window, focussedOnView: omniBar.privacyInfoContainer.privacyIcon, scale: .custom(3)) + } + + func dismissPrivacyIconAnimation(_ view: PrivacyIconView) { + if isPrivacyIconHighlighted(view) { + ViewHighlighter.hideAll() + } + } + + func isPrivacyIconHighlighted(_ view: PrivacyIconView) -> Bool { + ViewHighlighter.highlightedViews.contains(where: { $0.view == view }) + } +} diff --git a/DuckDuckGo/TabDelegate.swift b/DuckDuckGo/TabDelegate.swift index 648fdb1067..ccc15e1ebe 100644 --- a/DuckDuckGo/TabDelegate.swift +++ b/DuckDuckGo/TabDelegate.swift @@ -74,7 +74,9 @@ protocol TabDelegate: AnyObject { func tabDidRequestForgetAll(tab: TabViewController) func tabDidRequestFireButtonPulse(tab: TabViewController) - + + func tabDidRequestPrivacyDashboardButtonPulse(tab: TabViewController, animated: Bool) + func tabDidRequestSearchBarRect(tab: TabViewController) -> CGRect func tab(_ tab: TabViewController, @@ -89,4 +91,6 @@ protocol TabDelegate: AnyObject { func showBars() + func tab(_ tab: TabViewController, didRequestLoadURL url: URL) + func tab(_ tab: TabViewController, didRequestLoadQuery query: String) } diff --git a/DuckDuckGo/TabManager.swift b/DuckDuckGo/TabManager.swift index cd598927a1..3b1b0f2568 100644 --- a/DuckDuckGo/TabManager.swift +++ b/DuckDuckGo/TabManager.swift @@ -37,6 +37,8 @@ class TabManager { private var previewsSource: TabPreviewsSource private var duckPlayer: DuckPlayerProtocol private var privacyProDataReporter: PrivacyProDataReporting + private let contextualOnboardingPresenter: ContextualOnboardingPresenting + private let contextualOnboardingLogic: ContextualOnboardingLogic weak var delegate: TabDelegate? @@ -50,7 +52,9 @@ class TabManager { historyManager: HistoryManaging, syncService: DDGSyncing, duckPlayer: DuckPlayer = DuckPlayer(), - privacyProDataReporter: PrivacyProDataReporting) { + privacyProDataReporter: PrivacyProDataReporting, + contextualOnboardingPresenter: ContextualOnboardingPresenting, + contextualOnboardingLogic: ContextualOnboardingLogic) { self.model = model self.previewsSource = previewsSource self.bookmarksDatabase = bookmarksDatabase @@ -58,6 +62,8 @@ class TabManager { self.syncService = syncService self.duckPlayer = duckPlayer self.privacyProDataReporter = privacyProDataReporter + self.contextualOnboardingPresenter = contextualOnboardingPresenter + self.contextualOnboardingLogic = contextualOnboardingLogic registerForNotifications() } @@ -75,7 +81,9 @@ class TabManager { historyManager: historyManager, syncService: syncService, duckPlayer: duckPlayer, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic) controller.applyInheritedAttribution(inheritedAttribution) controller.attachWebView(configuration: configuration, andLoadRequest: url == nil ? nil : URLRequest.userInitiated(url!), @@ -149,7 +157,9 @@ class TabManager { historyManager: historyManager, syncService: syncService, duckPlayer: duckPlayer, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic) controller.attachWebView(configuration: configCopy, andLoadRequest: request, consumeCookies: !model.hasActiveTabs, diff --git a/DuckDuckGo/TabSwitcherViewController.swift b/DuckDuckGo/TabSwitcherViewController.swift index fca5edc4c3..733612070e 100644 --- a/DuckDuckGo/TabSwitcherViewController.swift +++ b/DuckDuckGo/TabSwitcherViewController.swift @@ -317,11 +317,16 @@ class TabSwitcherViewController: UIViewController { @IBAction func onFirePressed(sender: AnyObject) { Pixel.fire(pixel: .forgetAllPressedTabSwitching) - - if DaxDialogs.shared.shouldShowFireButtonPulse { + let isNewOnboarding = DefaultVariantManager().isSupported(feature: .newOnboardingIntro) + + if !isNewOnboarding + && DaxDialogs.shared.shouldShowFireButtonPulse { let spec = DaxDialogs.shared.fireButtonEducationMessage() performSegue(withIdentifier: "ActionSheetDaxDialog", sender: spec) } else { + if isNewOnboarding { + ViewHighlighter.hideAll() + } let alert = ForgetDataAlert.buildAlert(forgetTabsAndDataHandler: { [weak self] in self?.forgetAll() }) diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 32db75a9d4..6a326deb5e 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -51,8 +51,10 @@ class TabViewController: UIViewController { @IBOutlet private(set) weak var errorInfoImage: UIImageView! @IBOutlet private(set) weak var errorHeader: UILabel! @IBOutlet private(set) weak var errorMessage: UILabel! + @IBOutlet weak var containerStackView: UIStackView! @IBOutlet weak var webViewContainer: UIView! var webViewBottomAnchorConstraint: NSLayoutConstraint? + var daxContextualOnboardingController: UIViewController? @IBOutlet var showBarsTapGestureRecogniser: UITapGestureRecognizer! @@ -293,7 +295,10 @@ class TabViewController: UIViewController { historyManager: HistoryManaging, syncService: DDGSyncing, duckPlayer: DuckPlayerProtocol, - privacyProDataReporter: PrivacyProDataReporting) -> TabViewController { + privacyProDataReporter: PrivacyProDataReporting, + contextualOnboardingPresenter: ContextualOnboardingPresenting, + contextualOnboardingLogic: ContextualOnboardingLogic, + onboardingPixelReporter: OnboardingCustomInteractionPixelReporting = OnboardingPixelReporter()) -> TabViewController { let storyboard = UIStoryboard(name: "Tab", bundle: nil) let controller = storyboard.instantiateViewController(identifier: "TabViewController", creator: { coder in TabViewController(coder: coder, @@ -303,7 +308,11 @@ class TabViewController: UIViewController { historyManager: historyManager, syncService: syncService, duckPlayer: duckPlayer, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic, + onboardingPixelReporter: onboardingPixelReporter + ) }) return controller } @@ -316,7 +325,11 @@ class TabViewController: UIViewController { let historyCapture: HistoryCapture var duckPlayer: DuckPlayerProtocol var duckPlayerNavigationHandler: DuckNavigationHandling? - + + let contextualOnboardingPresenter: ContextualOnboardingPresenting + let contextualOnboardingLogic: ContextualOnboardingLogic + let onboardingPixelReporter: OnboardingCustomInteractionPixelReporting + required init?(coder aDecoder: NSCoder, tabModel: Tab, appSettings: AppSettings, @@ -324,7 +337,10 @@ class TabViewController: UIViewController { historyManager: HistoryManaging, syncService: DDGSyncing, duckPlayer: DuckPlayerProtocol, - privacyProDataReporter: PrivacyProDataReporting) { + privacyProDataReporter: PrivacyProDataReporting, + contextualOnboardingPresenter: ContextualOnboardingPresenting, + contextualOnboardingLogic: ContextualOnboardingLogic, + onboardingPixelReporter: OnboardingCustomInteractionPixelReporting) { self.tabModel = tabModel self.appSettings = appSettings self.bookmarksDatabase = bookmarksDatabase @@ -334,6 +350,9 @@ class TabViewController: UIViewController { self.duckPlayer = duckPlayer self.duckPlayerNavigationHandler = DuckPlayerNavigationHandler(duckPlayer: duckPlayer) self.privacyProDataReporter = privacyProDataReporter + self.contextualOnboardingPresenter = contextualOnboardingPresenter + self.contextualOnboardingLogic = contextualOnboardingLogic + self.onboardingPixelReporter = onboardingPixelReporter super.init(coder: aDecoder) } @@ -715,7 +734,12 @@ class TabViewController: UIViewController { func disableFireproofingForDomain(_ domain: String) { preserveLoginsWorker?.handleUserDisablingFireproofing(forDomain: domain) } - + + func dismissContextualDaxFireDialog() { + guard contextualOnboardingLogic.isShowingFireDialog else { return } + contextualOnboardingPresenter.dismissContextualOnboardingIfNeeded(from: self) + } + private func checkForReloadOnError() { guard shouldReloadOnError else { return } shouldReloadOnError = false @@ -856,7 +880,7 @@ class TabViewController: UIViewController { @IBSegueAction private func makePrivacyDashboardViewController(coder: NSCoder) -> PrivacyDashboardViewController? { - PrivacyDashboardViewController(coder: coder, + return PrivacyDashboardViewController(coder: coder, privacyInfo: privacyInfo, entryPoint: .dashboard, privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, @@ -1291,7 +1315,8 @@ extension TabViewController: WKNavigationDelegate { onWebpageDidFinishLoading() instrumentation.didLoadURL() checkLoginDetectionAfterNavigation() - + trackSecondSiteVisitIfNeeded(url: webView.url) + // definitely finished with any potential login cycle by this point, so don't try and handle it any more detectedLoginURL = nil updatePreview() @@ -1348,6 +1373,12 @@ extension TabViewController: WKNavigationDelegate { } } + func trackSecondSiteVisitIfNeeded(url: URL?) { + // Track second non-SERP webpage visit + guard url?.isDuckDuckGoSearch == false else { return } + onboardingPixelReporter.trackSecondSiteVisit() + } + func showDaxDialogOrStartTrackerNetworksAnimationIfNeeded() { guard !isLinkPreview else { return } @@ -1367,9 +1398,13 @@ extension TabViewController: WKNavigationDelegate { scheduleTrackerNetworksAnimation(collapsing: true) return } - guard let spec = DaxDialogs.shared.nextBrowsingMessageIfShouldShow(for: privacyInfo) else { - + + // Dismiss Contextual onboarding if there's no message to show. + contextualOnboardingPresenter.dismissContextualOnboardingIfNeeded(from: self) + // Dismiss privacy dashbooard pulse animation when no browsing dialog to show. + delegate?.tabDidRequestPrivacyDashboardButtonPulse(tab: self, animated: false) + if DaxDialogs.shared.shouldShowFireButtonPulse { delegate?.tabDidRequestFireButtonPulse(tab: self) } @@ -1378,29 +1413,34 @@ extension TabViewController: WKNavigationDelegate { return } - isShowingFullScreenDaxDialog = true + if !DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + isShowingFullScreenDaxDialog = true + } scheduleTrackerNetworksAnimation(collapsing: !spec.highlightAddressBar) let daxDialogSourceURL = self.url DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self else { return } // https://app.asana.com/0/414709148257752/1201620790053163/f - if self?.url != daxDialogSourceURL { + if self.url != daxDialogSourceURL && self.url?.isSameDuckDuckGoSearchURL(other: daxDialogSourceURL) == false { DaxDialogs.shared.overrideShownFlagFor(spec, flag: false) - self?.isShowingFullScreenDaxDialog = false + self.isShowingFullScreenDaxDialog = false return } - self?.chromeDelegate?.omniBar.resignFirstResponder() - self?.chromeDelegate?.setBarsHidden(false, animated: true) - self?.performSegue(withIdentifier: "DaxDialog", sender: spec) + self.chromeDelegate?.omniBar.resignFirstResponder() + self.chromeDelegate?.setBarsHidden(false, animated: true) + + // Present the contextual onboarding + contextualOnboardingPresenter.presentContextualOnboarding(for: spec, in: self) if spec == DaxDialogs.BrowsingSpec.withoutTrackers { - self?.woShownRecently = true - self?.fireWoFollowUp = true + self.woShownRecently = true + self.fireWoFollowUp = true } } } - + private func scheduleTrackerNetworksAnimation(collapsing: Bool) { let trackersWorkItem = DispatchWorkItem { guard let privacyInfo = self.privacyInfo else { return } @@ -2806,6 +2846,42 @@ extension TabViewController: SaveLoginViewControllerDelegate { } } +extension TabViewController: OnboardingNavigationDelegate { + + func searchFor(_ query: String) { + delegate?.tab(self, didRequestLoadQuery: query) + } + + func navigateTo(url: URL) { + delegate?.tab(self, didRequestLoadURL: url) + } + +} + +extension TabViewController: ContextualOnboardingEventDelegate { + + func didAcknowledgeContextualOnboardingSearch() { + contextualOnboardingLogic.setSearchMessageSeen() + } + + func didAcknowledgeContextualOnboardingTrackersDialog() { + // Store when Fire contextual dialog is shown to decide if final dialog needs to be shown. + contextualOnboardingLogic.setFireEducationMessageSeen() + delegate?.tabDidRequestFireButtonPulse(tab: self) + } + + func didShowContextualOnboardingTrackersDialog() { + guard contextualOnboardingLogic.shouldShowPrivacyButtonPulse else { return } + + delegate?.tabDidRequestPrivacyDashboardButtonPulse(tab: self, animated: true) + } + + func didTapDismissContextualOnboardingAction() { + contextualOnboardingPresenter.dismissContextualOnboardingIfNeeded(from: self) + } + +} + extension WKWebView { func load(_ url: URL, in frame: WKFrameInfo?) { diff --git a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift index 64d08503ea..ca74e93fa2 100644 --- a/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerLongPressMenuExtension.swift @@ -107,7 +107,9 @@ extension TabViewController { historyManager: historyManager, syncService: syncService, duckPlayer: duckPlayer, - privacyProDataReporter: privacyProDataReporter) + privacyProDataReporter: privacyProDataReporter, + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic) tabController.isLinkPreview = true let configuration = WKWebViewConfiguration.nonPersistent() tabController.attachWebView(configuration: configuration, andLoadRequest: URLRequest.userInitiated(url), consumeCookies: false) diff --git a/DuckDuckGo/TabsBarViewController.swift b/DuckDuckGo/TabsBarViewController.swift index aef2905069..ce8e51325e 100644 --- a/DuckDuckGo/TabsBarViewController.swift +++ b/DuckDuckGo/TabsBarViewController.swift @@ -94,9 +94,7 @@ class TabsBarViewController: UIViewController { @IBAction func onFireButtonPressed() { - if DaxDialogs.shared.shouldShowFireButtonPulse { - delegate?.tabsBarDidRequestFireEducationDialog(self) - } else { + func showClearDataAlert() { let alert = ForgetDataAlert.buildAlert(forgetTabsAndDataHandler: { [weak self] in guard let self = self else { return } self.delegate?.tabsBarDidRequestForgetAll(self) @@ -104,6 +102,16 @@ class TabsBarViewController: UIViewController { self.present(controller: alert, fromView: fireButton) } + if DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + delegate?.tabsBarDidRequestFireEducationDialog(self) + showClearDataAlert() + } else { + if DaxDialogs.shared.shouldShowFireButtonPulse { + delegate?.tabsBarDidRequestFireEducationDialog(self) + } else { + showClearDataAlert() + } + } } @IBAction func onNewTabPressed() { @@ -312,8 +320,13 @@ extension MainViewController: TabsBarDelegate { } func tabsBarDidRequestFireEducationDialog(_ controller: TabsBarViewController) { - if let spec = DaxDialogs.shared.fireButtonEducationMessage() { - segueToActionSheetDaxDialogWithSpec(spec) + if DefaultVariantManager().isSupported(feature: .newOnboardingIntro) { + currentTab?.dismissContextualDaxFireDialog() + ViewHighlighter.hideAll() + } else { + if let spec = DaxDialogs.shared.fireButtonEducationMessage() { + segueToActionSheetDaxDialogWithSpec(spec) + } } } diff --git a/DuckDuckGo/TutorialSettings.swift b/DuckDuckGo/TutorialSettings.swift index 28e60443c3..039c53395b 100644 --- a/DuckDuckGo/TutorialSettings.swift +++ b/DuckDuckGo/TutorialSettings.swift @@ -20,14 +20,14 @@ import Foundation import Core -protocol TutorialSettings { +protocol TutorialSettings: AnyObject { var lastVersionSeen: Int { get } var hasSeenOnboarding: Bool { get set } } -struct DefaultTutorialSettings: TutorialSettings { +final class DefaultTutorialSettings: TutorialSettings { private struct Constants { // Set the build number of the last build that didn't force them to appear to force them to appear. diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 6d3fba3ab3..7cba00bc3a 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1227,8 +1227,29 @@ But if you *do* want a peek under the hood, you can find more information about } } - enum DefaultBrowser { - public static let message = NSLocalizedString("onboarding.defaultBrowser.message", value: "Open links with peace of mind, every time.", comment: "Subheader message for the screen to choose DuckDuckGo as default browser") + enum ContextualOnboarding { + static let onboardingTryASearchTitle = NSLocalizedString("contextual.onboarding.try-a-search.title", value: "Ready to get started?\nTry a search!", comment: "Title of a popover on the browser that invites the user to try a search") + static let onboardingTryASearchMessage = NSLocalizedString("contextual.onboarding.try-a-search.message", value: "Your DuckDuckGo searches are always anonymous.", comment: "Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous") + static let onboardingTryASiteTitle = NSLocalizedString("contextual.onboarding.try-a-site.title", value: "Next, try visiting a site!", comment: "Title of a popover on the browser that invites the user to try a visiting a website") + static let onboardingTryASiteNTPTitle = NSLocalizedString("contextual.onboarding.ntp.try-a-site.title", value: "Try visiting a site!", comment: "Title of a popover on the new tab page browser that invites the user to try a visiting a website") + static let onboardingTryASiteMessage = NSLocalizedString("contextual.onboarding.try-a-site.message", value: "I’ll block trackers so they can’t spy on you.", comment: "Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers") + static let onboardingTryFireButtonMessage = NSLocalizedString("contextual.onboarding.try-fire-button.message", value: "Instantly clear your browsing activity with the Fire Button.\n\nGive it a try! 🔥", comment: "Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break") + static let onboardingGotItButton = NSLocalizedString("contextual.onboarding.got-it.button", value: "Got it!", comment: "During onboarding steps this button is shown and takes either to the next steps or closes the onboarding.") + static let onboardingFirstSearchDoneMessage = NSLocalizedString("contextual.onboarding.first-search-done.message", value: "That’s DuckDuckGo Search. Private. Fast. Fewer ads.", comment: "After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo") + static let onboardingFinalScreenTitle = NSLocalizedString("contextual.onboarding.final-screen.title", value: "You’ve got this!", comment: "Title of the last screen of the onboarding to the browser app") + static let onboardingFinalScreenMessage = NSLocalizedString("contextual.onboarding.final-screen.message", value: "Remember: every time you browse with me a creepy ad loses its wings. 👌", comment: "Message of the last screen of the onboarding to the browser app.") + static let onboardingFinalScreenButton = NSLocalizedString("contextual.onboarding.final-screen.button", value: "High five!", comment: "Button on the last screen of the onboarding, it will dismiss the onboarding screen.") + static let tryASearchOption1English = NSLocalizedString("contextual.onboarding.try-search.option1-English", value: "how to say “duck” in spanish", comment: "Browser Search query for how to say duck in english") + static let tryASearchOption1International = NSLocalizedString("contextual.onboarding.try-search.option1international", value: "how to say “duck” in english", comment: "Browser Search query for how to say duck in english") + static let tryASearchOption2English = NSLocalizedString("contextual.onboarding.try-search.option2-english", value: "mighty ducks cast", comment: "Search query for the cast of Mighty Ducks") + static let tryASearchOption2International = NSLocalizedString("contextual.onboarding.try-search.option2-international", value: "cast of avatar", comment: "Search query for the cast of Avatar") + static let tryASearchOption3 = NSLocalizedString("contextual.onboarding.try-search.option3", value: "local weather", comment: "Browser Search query for local weather") + static let tryASearchOptionSurpriseMeTitle = NSLocalizedString("contextual.onboarding.try-search.surprise-me-title", value: "Surprise me!", comment: "Title for a button that triggers an unknown search query for the user.") + static let tryASearchOptionSurpriseMeEnglish = NSLocalizedString("contextual.onboarding.try-search.surprise-me-english", value: "chocolate chip cookie recipes", comment: "Browser Search query for chocolate chip cookie recipes") + static let tryASearchOptionSurpriseMeInternational = NSLocalizedString("contextual.onboarding.try-search.surprise-me-international", value: "dinner recipes", comment: "Browser Search query for dinner recipes") + + static let daxDialogBrowsingWithOneTracker = NSLocalizedString("contextual.onboarding.browsing.one.tracker", value: "*%1$@* was trying to track you here. I blocked them!\n\n☝️ Tap the shield for more info.", comment: "Parameter is domain name (string)") + static let daxDialogBrowsingWithMultipleTrackers = NSLocalizedString("contextual.onboarding.browsing.multiple.trackers", comment: "First parameter is a count of additional trackers, second and third are names of the tracker networks (strings)") } } } diff --git a/DuckDuckGo/ViewHighlighter.swift b/DuckDuckGo/ViewHighlighter.swift index f208154305..b08682beeb 100644 --- a/DuckDuckGo/ViewHighlighter.swift +++ b/DuckDuckGo/ViewHighlighter.swift @@ -29,9 +29,9 @@ class ViewHighlighter { static var addedViews = [WeaklyHeldView]() static var highlightedViews = [WeaklyHeldView]() - static func showIn(_ window: UIWindow, focussedOnView view: UIView) { + static func showIn(_ window: UIWindow, focussedOnView view: UIView, scale: HighlightScale = .default) { guard let center = view.superview?.convert(view.center, to: nil) else { return } - let size = max(view.frame.width, view.frame.height) * 5.5 + let size = max(view.frame.width, view.frame.height) * scale.value let highlightView = LottieAnimationView(name: "view_highlight") highlightView.frame = CGRect(x: 0, y: 0, width: size, height: size) @@ -66,3 +66,17 @@ class ViewHighlighter { } } + +extension ViewHighlighter { + enum HighlightScale { + case `default` + case custom(CGFloat) + + fileprivate var value: CGFloat { + switch self { + case .default: 5.5 + case let .custom(value): value + } + } + } +} diff --git a/DuckDuckGo/bg.lproj/Localizable.strings b/DuckDuckGo/bg.lproj/Localizable.strings index d4196aaaf8..904f763f00 100644 --- a/DuckDuckGo/bg.lproj/Localizable.strings +++ b/DuckDuckGo/bg.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Да"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* се опитва да Ви проследи тук. Блокирах го!\n\n☝️ Докоснете щита за повече информация."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Дай пет!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Запомнете: всеки път, когато сърфирате с мен, аз ще подрязвам крилцата на досадните реклами. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Нали разбрахте!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Това е DuckDuckGo Search. Поверителен. Бърз. По-малко реклами."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Разбрах!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Опитайте да посетите сайт!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Търсенето в DuckDuckGo винаги е анонимно."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Готови ли сте да започнете?\nИзпробвайте търсенето!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Ще блокирам тракерите, за да не ви шпионират."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Сега опитайте да посетите някой сайт!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Незабавно изчистване на дейностите при сърфиране с Fire Button.\n\nИзпробвайте го! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "как се казва „патица“ на испански"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "как се казва „патица“ на английски"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "прогнозата на мощните патици"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "актьори в аватар"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "местното време"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "рецепти за бисквити с парченца шоколад"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "рецепти за вечеря"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Изненадайте ме!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Винаги да се изпращат доклади за сривове"; diff --git a/DuckDuckGo/bg.lproj/Localizable.stringsdict b/DuckDuckGo/bg.lproj/Localizable.stringsdict index 04789e208c..f873602322 100644 --- a/DuckDuckGo/bg.lproj/Localizable.stringsdict +++ b/DuckDuckGo/bg.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ ☝️ Вижте лентата с адреса, за да разберете кой се опитва да ви проследи, когато посещавате нов сайт.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* и още *1 друг* се опитва да ви проследят тук. Блокирах ги! + +☝️ Докоснете щита за повече информация. + other + *%2$@, %3$@* и още *%1$d други* се опитват да ви проследят тук. Блокирах ги! + +☝️ Докоснете щита за повече информация. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/cs.lproj/Localizable.strings b/DuckDuckGo/cs.lproj/Localizable.strings index 907eadd476..5a918184da 100644 --- a/DuckDuckGo/cs.lproj/Localizable.strings +++ b/DuckDuckGo/cs.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ano"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Stránka *%1$@* se tě tady snaží sledovat. A tak jsem ji zablokoval!\n\n☝️ Klepnutím na štít si zobrazíš další informace."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Plácnutí!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Pamatuj: Když se mnou na webu surfuješ, příšerné reklamy zaženeš. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Máte to!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Tohle je vyhledávač DuckDuckGo Search. Soukromý. Rychlý. S méně reklamami."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Mám to!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Zkus přejít na nějaký web!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Tvoje vyhledávání v DuckDuckGo je vždycky anonymní."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Tak co, jdeš na to?\nZkus něco vyhledat!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Zablokujeme trackery, aby vás nemohli špehovat."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "A teď zkus přejít na nějaký web!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Vcukuletu můžeš smazat svou aktivitu při prohlížení pomocí funkce Fire Button.\n\nZkus ji! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "jak se řekne „kachna“ španělsky"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "jak se řekne anglicky „kachna“"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "herci z filmu Šampióni"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "obsazení avatara"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "aktuální počasí"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recepty na čokoládové sušenky"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recepty na večeři"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Překvap mě!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Vždycky odesílat hlášení o selhání"; diff --git a/DuckDuckGo/cs.lproj/Localizable.stringsdict b/DuckDuckGo/cs.lproj/Localizable.stringsdict index 45477ea1db..ec5759e28c 100644 --- a/DuckDuckGo/cs.lproj/Localizable.stringsdict +++ b/DuckDuckGo/cs.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Zablokovali jsme je. ☝️ Při návštěvě nového webu můžeš zjistit, kdo se tě snaží sledovat, v adresním řádku. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* a *1 další stránka* se tě tady snaží sledovat. A tak jsem je všechny zablokoval! + +☝️ Klepnutím na štít si zobrazíš další informace. + few + *%2$@, %3$@* a *%1$d další stránky* se tě tady snaží sledovat. A tak jsem je všechny zablokoval! + +☝️ Klepnutím na štít si zobrazíš další informace. + many + *%2$@, %3$@* a *%1$d další stránky* se tě tady snaží sledovat. A tak jsem je všechny zablokoval! + +☝️ Klepnutím na štít si zobrazíš další informace. + other + *%2$@, %3$@* a *%1$d dalších stránek* se tě tady snaží sledovat. A tak jsem je všechny zablokoval! + +☝️ Klepnutím na štít si zobrazíš další informace. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/da.lproj/Localizable.strings b/DuckDuckGo/da.lproj/Localizable.strings index 5598f7c374..3ed2b33688 100644 --- a/DuckDuckGo/da.lproj/Localizable.strings +++ b/DuckDuckGo/da.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ja"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* prøvede at spore dig her. Jeg blokerede dem!\n\n☝️ Tryk på skjoldet for at få mere information."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Giv mig fem!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Husk: hver gang du browser med mig, mister en uhyggelig annonce sine vinger. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Du har den!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Det er DuckDuckGo Search. Privat. Hurtig. Færre annoncer."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Forstået"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Prøv at besøge en hjemmeside!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Dine DuckDuckGo-søgninger er altid anonyme."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Er du klar til at komme i gang?\nPrøv at søge!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Jeg blokerer trackere, så de ikke kan udspionere dig."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Prøv nu at besøge en hjemmeside!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Ryd øjeblikkeligt din browseraktivitet med Fire Button.\n\nPrøv det! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "hvordan siger man \"and\" på spansk"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "hvordan siger man \"and\" på engelsk"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks film"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "skuespillere i avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokalt vejr"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "opskrift på chocolate chip cookie"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "middagsopskrifter"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Overrask mig!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Send altid fejlrapporter"; diff --git a/DuckDuckGo/da.lproj/Localizable.stringsdict b/DuckDuckGo/da.lproj/Localizable.stringsdict index fd6ccf2780..936c5b8538 100644 --- a/DuckDuckGo/da.lproj/Localizable.stringsdict +++ b/DuckDuckGo/da.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Jeg blokerede dem! ☝️ Du kan kontrollere adresselinjen for at se, hvem der prøver at spore dig, når du besøger et nyt websted.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* og *1 anden* forsøgte at spore dig her. Jeg blokerede dem! + +☝️ Tryk på skjoldet for at få mere information. + other + *%2$@, %3$@* og *%1$d andre* forsøgte at spore dig her. Jeg blokerede dem! + +☝️ Tryk på skjoldet for at få flere oplysninger. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/de.lproj/Localizable.strings b/DuckDuckGo/de.lproj/Localizable.strings index 834dc9212f..d8556e3b4a 100644 --- a/DuckDuckGo/de.lproj/Localizable.strings +++ b/DuckDuckGo/de.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ja"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* hat versucht, dich hier zu tracken. Ich hab die Domain blockiert!\n\n☝️ Tippe auf das Schild, um mehr Informationen zu erhalten."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Schlag ein!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Hinweis: Jedes Mal, wenn du mit mir browst, verliert eine gruselige Anzeige ihren Schrecken. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Gut gemacht!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Das ist DuckDuckGo Search. Privat. Schnell. Weniger Werbung."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Verstanden."; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Versuche, eine Website zu besuchen!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Deine DuckDuckGo-Suchanfragen sind immer anonym."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Bereit anzufangen?\nProbiere eine Suche aus!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Ich blockiere Tracker, damit sie dich nicht ausspionieren können."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Versuche als nächstes, eine Website zu besuchen!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Lösche deine Browseraktivitäten sofort mit dem Fire Button.\n\nProbier’s doch mal aus! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "wie sagt man „Ente“ auf Spanisch"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "wie sagt man „Ente“ auf Englisch"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "Besetzung von Mighty Ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "Besetzung von Avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "Lokales Wetter"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "Rezepte für Schokoladenkekse"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "Dinner-Rezepte"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Überrasche mich!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Absturzberichte immer senden"; diff --git a/DuckDuckGo/de.lproj/Localizable.stringsdict b/DuckDuckGo/de.lproj/Localizable.stringsdict index 0e71f3bcab..ebc0f4b52f 100644 --- a/DuckDuckGo/de.lproj/Localizable.stringsdict +++ b/DuckDuckGo/de.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Ich habe sie blockiert! ☝️Du kannst in der Adressleiste sehen, wer dich beim Besuch einer neuen Website tracken will. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* und *1 weiteres* haben versucht, dich hier zu tracken. Ich hab sie blockiert! + +☝️ Tippe auf das Schild, um weitere Informationen zu erhalten. + other + *%2$@, %3$@* und *%1$d weitere* haben versucht, dich hier zu tracken. Ich hab sie blockiert! + +☝️ Tippe auf das Schild, um weitere Informationen zu erhalten. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/el.lproj/Localizable.strings b/DuckDuckGo/el.lproj/Localizable.strings index 670442d9b7..2ed4000241 100644 --- a/DuckDuckGo/el.lproj/Localizable.strings +++ b/DuckDuckGo/el.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ναί"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Το *%1$@* προσπαθούσε να σας παρακολουθήσει εδώ. Τους εμπόδισα!\n\n☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Κόλλα πέντε!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Να θυμάστε: κάθε φορά που περιηγείστε μαζί μου, μια ανατριχιαστική διαφήμιση χάνει τη δύναμή της! 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Το έχετε!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Αυτό είναι το DuckDuckGo Search. Ιδιωτικά. Γρήγορα. Λιγότερες διαφημίσεις."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Το κατάλαβα!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Δοκιμάστε να επισκεφτείτε έναν ιστότοπο!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Οι αναζητήσεις σας στο DuckDuckGo είναι πάντα ανώνυμες."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Έτοιμοι να αρχίσετε;\nΔοκίμασε μια αναζήτηση!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Θα εμποδίσω τις εφαρμογές παρακολούθησης ώστε να μην μπορούν να σας κατασκοπεύουν."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Στη συνέχεια, δοκιμάστε να επισκεφτείτε έναν ιστότοπο!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Διαγράψτε άμεσα τη δραστηριότητα περιήγησής σας με το Fire Button.

Δοκιμάστε το! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "πώς μπορείτε να πείτε «duck» στα ισπανικά"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "πώς να πείτε «πάπια» στα αγγλικά"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "Mighty Ducks Cast"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "οι ηθοποιοί του avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "τοπικός καιρός"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "συνταγές για μπισκότα με κομματάκια σοκολάτας"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "συνταγές για δείπνο"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Κάνε μου έκπληξη!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Να αποστέλλονται πάντα αναφορές σφαλμάτων"; diff --git a/DuckDuckGo/el.lproj/Localizable.stringsdict b/DuckDuckGo/el.lproj/Localizable.stringsdict index 770acd95de..767fd73613 100644 --- a/DuckDuckGo/el.lproj/Localizable.stringsdict +++ b/DuckDuckGo/el.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ ☝️Μπορείτε να ελέγξετε τη γραμμή διευθύνσεων για να δείτε ποιος προσπαθεί να σας παρακολουθήσει όταν επισκέπτεστε έναν νέο ιστότοπο.️
+ contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* και *1 ακόμα * προσπαθούσαν να σας εντοπίσουν εδώ. Τους μπλόκαρα! + +☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες. + other + *%2$@, %3$@* και *%1$d ακόμα* προσπαθούσαν να σας εντοπίσουν εδώ. Τους μπλόκαρα! + +☝️ Πατήστε την ασπίδα για περισσότερες πληροφορίες. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 88ca572a13..55b6c1fb6a 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -721,6 +721,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Yes"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* was trying to track you here. I blocked them!\n\n☝️ Tap the shield for more info."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "High five!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Remember: every time you browse with me a creepy ad loses its wings. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "You’ve got this!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "That’s DuckDuckGo Search. Private. Fast. Fewer ads."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Got it!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Try visiting a site!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Your DuckDuckGo searches are always anonymous."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ready to get started?\nTry a search!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "I’ll block trackers so they can’t spy on you."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Next, try visiting a site!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Instantly clear your browsing activity with the Fire Button.\n\nGive it a try! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "how to say “duck” in spanish"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "how to say “duck” in english"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks cast"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "cast of avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "local weather"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "chocolate chip cookie recipes"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "dinner recipes"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Surprise me!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Always Send Crash Reports"; @@ -1645,9 +1708,6 @@ https://duckduckgo.com/mac"; /* The title of the dialog to show the privacy features that DuckDuckGo offers */ "onboarding.browsers.title" = "Privacy protections activated!"; -/* Subheader message for the screen to choose DuckDuckGo as default browser */ -"onboarding.defaultBrowser.message" = "Open links with peace of mind, every time."; - /* Button to continue the onboarding process */ "onboarding.intro.cta" = "Let’s do it!"; diff --git a/DuckDuckGo/en.lproj/Localizable.stringsdict b/DuckDuckGo/en.lproj/Localizable.stringsdict index 132ea6de50..1d535871dd 100644 --- a/DuckDuckGo/en.lproj/Localizable.stringsdict +++ b/DuckDuckGo/en.lproj/Localizable.stringsdict @@ -48,6 +48,30 @@ I blocked them! ☝️ You can check the address bar to see who is trying to track you when you visit a new site. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* and *1 other* were trying to track you here. I blocked them! + +☝️ Tap the shield for more info. + zero + *%2$@ and %3$@* were trying to track you here. I blocked them! + +☝️ Tap the shield for more info. + other + *%2$@, %3$@* and *%d others* were trying to track you here. I blocked them! + +☝️ Tap the shield for more info. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/es.lproj/Localizable.strings b/DuckDuckGo/es.lproj/Localizable.strings index 6b80d079b6..bdb462b54f 100644 --- a/DuckDuckGo/es.lproj/Localizable.strings +++ b/DuckDuckGo/es.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Sí"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* ha intentado rastrearte aquí. Los he bloqueado.\n\n☝️ Pulsa en el escudo para obtener más información."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "¡Choca esos cinco!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Recuerda: cada vez que navegas conmigo corto las alas a un anuncio horrible. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "¡Lo estás haciendo muy bien!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Eso es DuckDuckGo Search. Privado. Rápido. Menos anuncios."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Entendido"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "¡Intenta visitar un sitio!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Tus búsquedas en DuckDuckGo son siempre anónimas."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "¿Listo para empezar?\n¡Prueba con una búsqueda!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Bloquearé los rastreadores para que no puedan espiarte."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "¡A continuación, intenta visitar un sitio!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Borra al instante tu actividad de navegación con el Fire Button.\n\n¡Pruébalo! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "cómo se dice «pato» en inglés"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "cómo se dice «pato» en inglés"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "reparto de Mighty Ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "reparto de Avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "el tiempo local"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recetas de galletas con pepitas de chocolate"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recetas para la cena"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "¡Sorpréndeme!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Enviar siempre informes de fallos"; diff --git a/DuckDuckGo/es.lproj/Localizable.stringsdict b/DuckDuckGo/es.lproj/Localizable.stringsdict index dd07808a8c..ed07524435 100644 --- a/DuckDuckGo/es.lproj/Localizable.stringsdict +++ b/DuckDuckGo/es.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ ☝️Puedes consultar la barra de navegación para ver quién está tratando de rastrearte cuando visitas un nuevo sitio.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* y *1 más* intentaban rastrearte hasta aquí. ¡Los he bloqueado! + +☝️ Pulsa en el escudo para obtener más información. + other + *%2$@, %3$@* y *%1$d más* intentaban rastrearte hasta aquí. ¡Los he bloqueado! + +☝️ Pulsa en el escudo para obtener más información. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/et.lproj/Localizable.strings b/DuckDuckGo/et.lproj/Localizable.strings index 901fff2015..5e04e33347 100644 --- a/DuckDuckGo/et.lproj/Localizable.strings +++ b/DuckDuckGo/et.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Jah"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* üritas sind siin jälitada. Ma blokeerisin need!\n\n☝️ Lisateabe saamiseks vajuta kilbile."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Viska viis!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Pea meeles: iga kord kui minuga sirvid, kaotab jube reklaam oma tiivad. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Said selle!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "See on DuckDuckGo otsing. Privaatne. Kiire. Vähem reklaame."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Sain aru!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Proovi külastada saiti!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Sinu DuckDuckGo otsingud on alati anonüümsed."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Kas oled valmis alustama?\nProovi otsingut!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Mina aga blokeerin jälgijaid, et nad ei saaks sind luurata."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Järgmisena proovi mõnda saiti külastada!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Tühjenda oma sirvimistegevus hetkega Fire Button abil.\n\nProovi! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kuidas öelda „part“ hispaania keeles"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "kuidas öelda „part“ inglise keeles"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "Mighty Ducks näitlejad"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "Avatari näitlejad"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "kohalik ilm"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "šokolaadiküpsiste retseptid"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "õhtusöögi retseptid"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Üllata mind!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Saada krahhiaruanded alati"; diff --git a/DuckDuckGo/et.lproj/Localizable.stringsdict b/DuckDuckGo/et.lproj/Localizable.stringsdict index 491d6f6f65..0b336b332f 100644 --- a/DuckDuckGo/et.lproj/Localizable.stringsdict +++ b/DuckDuckGo/et.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Blokeerisin nad! ☝️Võid vaadata aadressiriba, et näha, kes üritab sind jälgida, kui külastad uut saiti.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* ja *veel 1* üritasid sind siin jälgida. Ma blokeerisin need! + +☝️ Lisateabe saamiseks vajuta kilbile. + other + *%2$@, %3$@* ja *veel %1$d* üritasid sind siin jälgida. Ma blokeerisin need! + +☝️ Lisateabe saamiseks vajuta kilbile. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/fi.lproj/Localizable.strings b/DuckDuckGo/fi.lproj/Localizable.strings index c20cc37dbf..7ba0443aa5 100644 --- a/DuckDuckGo/fi.lproj/Localizable.strings +++ b/DuckDuckGo/fi.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Kyllä"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* yritti seurata sinua. Estin ne!\n\n☝️ Napauta kilpiä saadaksesi lisätietoja."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Ylävitonen!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Muista, että joka kerta kun käytät minua selaamiseen, rasittavat mainokset katoavat. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Hyvin menee!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Tällainen on DuckDuckGo Search. Yksityinen. Nopea. Vähemmän mainoksia."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Selvä!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Siirry sivustolle!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "DuckDuckGo-hakusi ovat aina nimettömiä."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Oletko valmis aloittamaan?\nKokeile hakua!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Estän jäljittäjät, jotta he eivät voi vakoilla sinua."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Siirry seuraavaksi sivustolle!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Tyhjennä selaustoimintasi välittömästi Fire Button -painikkeella.\n\nKokeile nyt! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kuinka sanotaan \"duck\" espanjaksi"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "miten sanotaan \"ankka\" englanniksi"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks cast"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "avatarmuotti"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "paikallissää"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "suklaakeksireseptit"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "illallisreseptejä"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Yllätä minut!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Lähetä aina virheraportit"; diff --git a/DuckDuckGo/fi.lproj/Localizable.stringsdict b/DuckDuckGo/fi.lproj/Localizable.stringsdict index e169e8b258..e8ba912f1a 100644 --- a/DuckDuckGo/fi.lproj/Localizable.stringsdict +++ b/DuckDuckGo/fi.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ ☝️ Voit tarkistaa osoitekentästä, kuka yrittää seurata sinua vieraillessasi uudella sivustolla. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* ja 1 muu yritti seurata sinua täällä. Estin ne! + +☝️ Napauta kilpeä saadaksesi lisätietoja.️ + other + *%2$@, %3$@* ja *%1$d muuta* yrittivät seurata sinua täällä. Estin ne! + +☝️ Napauta kilpeä saadaksesi lisätietoja. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/fr.lproj/Localizable.strings b/DuckDuckGo/fr.lproj/Localizable.strings index 4282787776..fd90055462 100644 --- a/DuckDuckGo/fr.lproj/Localizable.strings +++ b/DuckDuckGo/fr.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Oui"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* essayait de vous suivre ici. Je l'ai bloqué !\n\n☝️ Appuyez sur le bouclier pour en savoir plus.️"; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Bien joué !"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Pensez-y : chaque fois que vous naviguez avec moi, une publicité douteuse disparaît. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Bien joué !"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "C'est DuckDuckGo Search. Privé. Rapide. Moins de publicités."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "J'ai compris !"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Essayez de visiter un site !"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Vos recherches sur DuckDuckGo sont toujours anonymes."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Prêt(e) à commencer ?\nEssayez une recherche !"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Je bloquerai les traqueurs afin qu'ils ne puissent pas vous espionner."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Ensuite, essayez de visiter un site !"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Effacez instantanément votre activité de navigation avec le Fire Button.\n\nEssayez par vous-même ! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "comment dire « duck » en espagnol"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "comment dire « canard » en anglais"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "casting de mighty ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "casting d'avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "météo locale"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recettes de cookies aux pépites de chocolat"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recettes pour le dîner"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Surprenez-moi !"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Toujours envoyer des rapports de plantage"; diff --git a/DuckDuckGo/fr.lproj/Localizable.stringsdict b/DuckDuckGo/fr.lproj/Localizable.stringsdict index 86db042ac3..36e1eb2674 100644 --- a/DuckDuckGo/fr.lproj/Localizable.stringsdict +++ b/DuckDuckGo/fr.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Je les ai bloqués ! ☝️ Vous pouvez voir dans la barre d'adresse qui essaie de vous suivre lorsque vous visitez un nouveau site.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* et *1 autre* essayaient de vous suivre ici. Je les ai bloqués ! + +☝️ Appuyez sur le bouclier pour en savoir plus.️ + other + *%2$@, %3$@* et *%1$d autres* essayaient de vous suivre ici. Je les ai bloqués ! + +☝️ Appuyez sur le bouclier pour en savoir plus.️ + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/hr.lproj/Localizable.strings b/DuckDuckGo/hr.lproj/Localizable.strings index 74585dc38f..8076f95d13 100644 --- a/DuckDuckGo/hr.lproj/Localizable.strings +++ b/DuckDuckGo/hr.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Da"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* te je pokušao pratiti ovdje. Blokirani su!\n\n☝️ Dodirni štit za više informacija."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Daj pet!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Zapamti: svaki put kada me koristiš za pregledavanje, grozne reklame odlaze u zaborav. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Možeš ti to!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "To je DuckDuckGo Search. Privatno. Brzo. Manje oglasa."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Shvaćam!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Pokušaj posjetiti web-mjesto!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Tvoja su DuckDuckGo pretraživanja uvijek anonimna."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Spreman za početak?\nIsprobaj pretraživanje!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Blokirat ću tragače kako te ne bi mogli špijunirati."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Nakon toga pokušaj posjetiti neko web-mjesto!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Odmah izbriši svoju aktivnost pregledavanja pomoću Fire Buttona.\n\nPokušaj! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kako se kaže \"patka\" na španjolskom"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "kako se kaže \"patka\" na engleskom"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "moćne patke"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "uloge u filmu Avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokalno vrijeme"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recepti za kolačiće s komadićima čokolade"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recepti za večeru"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Iznenadi me!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Uvijek šalji izvješća o padu programa"; diff --git a/DuckDuckGo/hr.lproj/Localizable.stringsdict b/DuckDuckGo/hr.lproj/Localizable.stringsdict index 040e9f59fa..25824e47bc 100644 --- a/DuckDuckGo/hr.lproj/Localizable.stringsdict +++ b/DuckDuckGo/hr.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Ja sam ih blokirao! ☝️Možeš pogledati adresnu traku da vidiš tko te pokušava pratiti kad posjetiš novo web-mjesto. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* i *1 drugi* su te pokušavali pratiti ovdje. Blokirani su! + +☝️ Dodirni štit za više informacija. + few + *%2$@, %3$@* i *%1$d druga* su te pokušavali pratiti ovdje. Blokirani su! + +☝️ Dodirni štit za više informacija. + many + *%2$@, %3$@* i *%1$d drugih* su te pokušavali pratiti ovdje. Blokirani su! + +☝️ Dodirni štit za više informacija. + other + *%2$@, %3$@* i *%1$d drugih* su te pokušavali pratiti ovdje. Blokirani su! + +☝️ Dodirni štit za više informacija. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/hu.lproj/Localizable.strings b/DuckDuckGo/hu.lproj/Localizable.strings index 9e91791b9b..f1ff068802 100644 --- a/DuckDuckGo/hu.lproj/Localizable.strings +++ b/DuckDuckGo/hu.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Igen"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "A(z) *%1$@* követni próbált téged itt. Blokkoltam őket!\n\n☝️ További információkért koppints a pajzsra."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Pacsi!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Ne feledd: minden alkalommal, amikor velem böngészel, egy undok hirdetés elveszíti az erejét. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Megvan, ez az!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Ez a DuckDuckGo Search. Privát. Gyors. Kevesebb hirdetés."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Megvan!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Próbálj meg ellátogatni egy webhelyre!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "A DuckDuckGo-kereséseid mindig névtelenek."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Készen állsz a kezdésre?\nKeress rá!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Én blokkolom a nyomkövetőket, hogy ne tudjanak kémkedni utánad."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "A következő lépésben próbálj meg ellátogatni egy webhelyre!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Töröld azonnal a böngészési tevékenységedet a Fire Button használatával.\n\nPróbáld ki! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "hogyan mondják spanyolul, hogy „kacsa”"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "hogyan mondják angolul, hogy „kacsa”"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "kerge kacsák szereposztása"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "avatar szereposztása"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "helyi időjárás"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "csokisütireceptek"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "vacsorareceptek"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Lepj meg!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Mindig küldjön hibajelentést"; diff --git a/DuckDuckGo/hu.lproj/Localizable.stringsdict b/DuckDuckGo/hu.lproj/Localizable.stringsdict index c29fa04d41..ee9de209b7 100644 --- a/DuckDuckGo/hu.lproj/Localizable.stringsdict +++ b/DuckDuckGo/hu.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Blokkoltam őket! ☝️A címsorban láthatod, hogy ki próbál meg követni, amikor új webhelyre látogatsz. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + A(z) *%2$@, a(z) %3$@* és további 1 oldal követni próbált téged itt. Blokkoltam őket! + +☝️ További információkért koppints a pajzsra. + other + A(z) *%2$@, a(z) %3$@* és további %1$d oldal követni próbált téged itt. Blokkoltam őket! + +☝️ További információkért koppints a pajzsra. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/it.lproj/Localizable.strings b/DuckDuckGo/it.lproj/Localizable.strings index ab18cd2dcd..d15c94601c 100644 --- a/DuckDuckGo/it.lproj/Localizable.strings +++ b/DuckDuckGo/it.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Sì"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* stava cercando di tracciare la tua attività qui. L'ho bloccato!\n\n☝️ Tocca lo scudo per maggiori informazioni."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Batti cinque!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Ricorda: quando navighi con me gli annunci inquietanti non possono seguirti. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Ben fatto!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "È DuckDuckGo Search. Veloce, privata e con meno annunci."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Ho capito!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Prova a visitare un sito!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Le tue ricerche su DuckDuckGo sono sempre anonime."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ti va di iniziare?\nProva una ricerca!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Bloccherò i sistemi di tracciamento in modo che non possano spiarti e"; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "In seguito, prova a visitare un sito!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Cancella istantaneamente la tua attività di navigazione con il Fire Button.\n\nProvalo! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "come si dice \"anatra\" in spagnolo"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "come si dice \"anatra\" in inglese"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "cast delle papere potenti"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "cast di avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "meteo locale"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "ricette di biscotti con gocce di cioccolato"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "ricette per la cena"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Sorprendimi!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Invia sempre segnalazioni di arresto anomalo"; diff --git a/DuckDuckGo/it.lproj/Localizable.stringsdict b/DuckDuckGo/it.lproj/Localizable.stringsdict index 49e99491a0..6448db29ae 100644 --- a/DuckDuckGo/it.lproj/Localizable.stringsdict +++ b/DuckDuckGo/it.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Li ho bloccati! ☝ Controlla la barra degli indirizzi per vedere chi sta cercando di tracciare la tua attività quando visiti un nuovo sito.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* e *1 altro* stavano cercando di tracciare la tua attività qui. Li ho bloccati! + +☝️ Tocca lo scudo per maggiori informazioni. + other + *%2$@, %3$@* e *altri %1$d* stavano cercando di tracciare la tua attività qui. Li ho bloccati! + +☝️ Tocca lo scudo per maggiori informazioni. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/lt.lproj/Localizable.strings b/DuckDuckGo/lt.lproj/Localizable.strings index 9fb2ee5497..0c7e126412 100644 --- a/DuckDuckGo/lt.lproj/Localizable.strings +++ b/DuckDuckGo/lt.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Taip"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* bandė jus sekti čia. Aš juos užblokavau!\n\n☝️ Norėdami gauti daugiau informacijos, bakstelėkite skydą."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Duok penkis!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Įsidėmėkite: kiekvieną kartą, kai naršai su manimi, bauginantis skelbimas praranda galią. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Atlikai!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Tai „DuckDuckGo Search“. Privati. Sparti. Mažiau reklamų."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Supratau!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Pabandyk apsilankyti svetainėje!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Jūsų „DuckDuckGo“ paieškos visada yra anoniminės."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ar esate pasirengę pradėti?\nIšbandykite paiešką!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Užblokuosiu stebėjimo priemones, kad jos negalėtų tavęs šnipinėti."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Pabandyk apsilankyti svetainėje!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Akimirksniu išvalykite naršymo veiklą naudodami „Fire Button“.\n\nIšbandykite! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kaip pasakyti „antis“ ispanų kalba"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "kaip pasakyti „antis“ anglų kalba"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks aktoriai"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "įsikūnijimo aktoriai"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "vietinis oras"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "šokolado drožlių sausainių receptai"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "vakarienės receptai"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Nustebink mane!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Visada siųsti strigčių ataskaitas"; diff --git a/DuckDuckGo/lt.lproj/Localizable.stringsdict b/DuckDuckGo/lt.lproj/Localizable.stringsdict index 6d8277719c..de49526660 100644 --- a/DuckDuckGo/lt.lproj/Localizable.stringsdict +++ b/DuckDuckGo/lt.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Užblokavau juos! ☝️ Patikrink adreso juostą ir pamatyk, kas bando tave sekti, kai lankaisi naujoje svetainėje.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* ir *dar 1* bandė jus sekti čia. Aš juos užblokavau! + +☝️ Norėdami gauti daugiau informacijos, bakstelėkite skydą. + few + *%2$@, %3$@* ir *dar %1$d* bandė jus sekti čia. Aš juos užblokavau! + +☝️ Norėdami gauti daugiau informacijos, bakstelėkite skydą. + many + *%2$@, %3$@* ir *dar %1$d* bandė jus sekti čia. Aš juos užblokavau! + +☝️ Norėdami gauti daugiau informacijos, bakstelėkite skydą. + other + *%2$@, %3$@* ir *dar %1$d* bandė jus sekti čia. Aš juos užblokavau! + +☝️ Norėdami gauti daugiau informacijos, bakstelėkite skydą. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/lv.lproj/Localizable.strings b/DuckDuckGo/lv.lproj/Localizable.strings index 651c25f090..261e2b2736 100644 --- a/DuckDuckGo/lv.lproj/Localizable.strings +++ b/DuckDuckGo/lv.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Jā"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* mēģināja tevi šeit izsekot. Es to nobloķēju!\n\n☝️ Pieskaries vairogam, lai uzzinātu vairāk."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Dod pieci!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Atceries: katru reizi, kad pārlūkosi kopā ar mani, uzmācīgās reklāmas zaudēs savu spēku! 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Izdevās!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Tas ir DuckDuckGo Search. Privāti. Ātri. Mazāk reklāmu."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Sapratu!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Pamēģini apmeklēt kādu vietni!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Tavi DuckDuckGo meklējumi vienmēr ir anonīmi."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Vai esat gatavs sākt?\nIzmēģini meklēšanu!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Es nobloķēšu izsekotājus, lai tie nevarētu tevi izspiegot."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Tagad pamēģini atvērt kādu vietni!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Acumirklī notīri savu pārlūkošanas vēsturi, izmantojot Fire Button.\n\nIzmēģini! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kā spāniski pateikt “pīle”"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "kā angliski pateikt “pīle”"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks aktieri"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "avatara aktieru sastāvs"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "vietējie laikapstākļi"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "cepumu ar šokolādes gabaliņiem receptes"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "vakariņu receptes"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Pārsteidz mani!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Vienmēr sūtīt avārijas ziņojumus"; diff --git a/DuckDuckGo/lv.lproj/Localizable.stringsdict b/DuckDuckGo/lv.lproj/Localizable.stringsdict index a408f10e5d..386c747e2c 100644 --- a/DuckDuckGo/lv.lproj/Localizable.stringsdict +++ b/DuckDuckGo/lv.lproj/Localizable.stringsdict @@ -50,6 +50,30 @@ Es tos nobloķēju! ☝️ Vari ieskatīties adreses joslā, lai redzētu, kas mēģina tevi izsekot, apmeklējot jaunas vietnes. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + *%2$@, %3$@* un *%1$d citi izsekošanas tīkli* mēģināja tevi šeit izsekot. Es tos nobloķēju! + +☝️ Pieskaries vairogam, lai uzzinātu vairāk. + one + *%2$@, %3$@* un *1 cits izsekošanas tīkls* mēģināja tevi šeit izsekot. Es to nobloķēju! + +☝️ Pieskaries vairogam, lai uzzinātu vairāk. + other + *%2$@, %3$@* and *%1$d citi izsekošanas tīkli* mēģināja tevi šeit izsekot. Es tos nobloķēju! + +☝️ Pieskaries vairogam, lai uzzinātu vairāk. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/nb.lproj/Localizable.strings b/DuckDuckGo/nb.lproj/Localizable.strings index 4c39b14926..5ba0fa78eb 100644 --- a/DuckDuckGo/nb.lproj/Localizable.strings +++ b/DuckDuckGo/nb.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ja"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* prøvde å spore deg her. Jeg blokkerte dem!\n\n☝️ Trykk på skjoldet for å få mer informasjon."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "High five!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Husk: Hver gang du surfer med meg, klippes vingene på en uhyggelig annonse. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Dette går bra!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Det er DuckDuckGo Search. Privat. Raskt. Færre annonser."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Skjønner!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Prøv å besøke et nettsted!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "DuckDuckGo-søkene dine er alltid anonyme."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Er du klar til å komme i gang?\nPrøv et søk!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Jeg blokkerer sporingsforsøk slik at de ikke kan spionere på deg."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Nå kan du prøve å besøke et nettsted!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Fjern nettleseraktiviteten din på et blunk med Fire Button.\n\nPrøv den! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "hvordan si «duck» på spansk"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "hva heter «and» på engelsk"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks rolleinnehavere"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "rollebesetningen i avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokalt vær"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "oppskrifter på sjokoladekjeks"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "middagsoppskrifter"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Overrask meg!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Send alltid krasjrapporter"; diff --git a/DuckDuckGo/nb.lproj/Localizable.stringsdict b/DuckDuckGo/nb.lproj/Localizable.stringsdict index 12aeaafd6c..c0d6841e3a 100644 --- a/DuckDuckGo/nb.lproj/Localizable.stringsdict +++ b/DuckDuckGo/nb.lproj/Localizable.stringsdict @@ -48,6 +48,30 @@ Jeg har blokkert dem! ☝️ Du kan sjekke adresselinjen for å se hvem som prøver å spore deg når du besøker et nytt nettsted.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* og *1 annen* prøvde å spore deg her. Jeg har blokkert dem! + +☝️ Trykk på skjoldet for å få mer informasjon. + other + *%2$@, %3$@* og *%d andre* prøvde å spore deg her. Jeg har blokkert dem! + +☝️ Trykk på skjoldet for å få mer informasjon. + zero + *%2$@ og %3$@* prøvde å spore deg her. Jeg har blokkert dem! + +☝️ Trykk på skjoldet for å få mer informasjon. + + number.of.tabs NSStringLocalizedFormatKey diff --git a/DuckDuckGo/nl.lproj/Localizable.strings b/DuckDuckGo/nl.lproj/Localizable.strings index f823ca840b..35d75ca377 100644 --- a/DuckDuckGo/nl.lproj/Localizable.strings +++ b/DuckDuckGo/nl.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ja"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* probeerde je hier te volgen. Ik heb dit geblokkeerd!\n\n☝️ Tik op het schild voor meer informatie."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "High five!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Denk eraan: elke keer als je met mij browset, verliest een enge advertentie zijn vleugels. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Je kunt het!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Dat is DuckDuckGo Search. Privé. Snel. Minder advertenties."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Ik snap het!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Bezoek eens een site!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Je DuckDuckGo-zoekopdrachten zijn altijd anoniem."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Klaar om te beginnen?\nProbeer een zoekopdracht!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Ik blokkeer trackers zodat ze je niet kunnen bespioneren."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Probeer nu een site te bezoeken!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Wis je browser-activiteit direct met de Fire Button.\n\nProbeer het maar! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "hoe zeg je 'eend' in het Spaans?"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "hoe zeg je 'eend' in het Engels?"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "cast van Mighty Ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "cast van avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokaal weer"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "Recepten voor chocoladekoekjes"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recepten voor het avondeten"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Verras me!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Crashrapporten altijd verzenden"; diff --git a/DuckDuckGo/nl.lproj/Localizable.stringsdict b/DuckDuckGo/nl.lproj/Localizable.stringsdict index 4f2be69968..816ae7743c 100644 --- a/DuckDuckGo/nl.lproj/Localizable.stringsdict +++ b/DuckDuckGo/nl.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Ik heb ze geblokkeerd! ☝️ Je kunt de URL-balk bekijken om te zien wie je probeert te volgen wanneer je een nieuwe site bezoekt.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* en nog *1 andere* probeerden je hier te volgen. Ik heb ze geblokkeerd! + +☝️ Tik op het schild voor meer informatie. + other + *%2$@, %3$@* en *%1$d anderen* probeerden je hier te volgen. Ik heb ze geblokkeerd! + +☝️ Tik op het schild voor meer informatie. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/pl.lproj/Localizable.strings b/DuckDuckGo/pl.lproj/Localizable.strings index 30e7267e1b..2c4d8505d1 100644 --- a/DuckDuckGo/pl.lproj/Localizable.strings +++ b/DuckDuckGo/pl.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Tak"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Domena *%1$@* próbowała Cię tu śledzić. Zablokowałem ją!\n\n☝️ Stuknij tarczę, aby uzyskać więcej informacji."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Piątka!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Pamiętaj: za każdym razem, gdy przeglądasz ze mną Internet, jakaś wstrętna reklama przestaje działać. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Udało się!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "To DuckDuckGo Search. Prywatna. Szybka. Z mniejszą liczbą reklam."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Rozumiem!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Spróbuj odwiedzić witrynę!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Wyszukiwania w DuckDuckGo zawsze są anonimowe."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Chcesz zacząć?\nSpróbuj coś wyszukać!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Zablokuję skrypty śledzące, aby nie mogły Cię szpiegować."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Następnie spróbuj odwiedzić witrynę!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Natychmiast wyczyść swoją aktywność związaną z przeglądaniem za pomocą przycisku Fire Button.\n\nSpróbuj! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "jak się mówi „kaczka” po hiszpańsku"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "jak się mówi „kaczka” po angielsku"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "obsada potężnych kaczorów"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "obsada avatara"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "pogoda lokalna"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "przepisy na ciastka z kawałkami czekolady"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "przepisy na obiad"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Zaskocz mnie!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Zawsze wysyłaj raporty o awariach"; diff --git a/DuckDuckGo/pl.lproj/Localizable.stringsdict b/DuckDuckGo/pl.lproj/Localizable.stringsdict index 1583733a81..0b0f1c8838 100644 --- a/DuckDuckGo/pl.lproj/Localizable.stringsdict +++ b/DuckDuckGo/pl.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Zablokowałem ich! ☝️ Możesz sprawdzić pasek adresu, aby zobaczyć, kto próbuje Cię śledzić, gdy odwiedzasz nową witrynę.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* i jeszcze *1 mechanizm śledzący* próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane! + +☝️ Stuknij tarczę, aby uzyskać więcej informacji.️ + few + *%2$@, %3$@* i jeszcze *%1$d mechanizmy śledzące* próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane! + +☝️ Stuknij tarczę, aby uzyskać więcej informacji.️ + many + *%2$@, %3$@* i jeszcze *%1$d mechanizmów śledzących* próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane! + +☝️ Stuknij tarczę, aby uzyskać więcej informacji.️ + other + *%2$@, %3$@* i jeszcze *%1$d mechanizmu śledzącego* próbowały Cię tutaj śledzić. Zostały przeze mnie zablokowane! + +☝️ Stuknij tarczę, aby uzyskać więcej informacji.️ + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/pt.lproj/Localizable.strings b/DuckDuckGo/pt.lproj/Localizable.strings index 23b4b176dd..9a973a4bca 100644 --- a/DuckDuckGo/pt.lproj/Localizable.strings +++ b/DuckDuckGo/pt.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Sim"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* tentou rastrear-te aqui.\n\n Bloqueei-os!\n\n☝️ Toca no escudo para obter mais informações."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Dá cá cinco!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Lembra-te: sempre que navegas comigo, um anúncio assustador perde as suas asas. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Você consegue!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "É a DuckDuckGo Search. Privado. Rápido. Menos anúncios."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Entendi!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Experimenta visitar um site!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "As tuas pesquisas no DuckDuckGo são sempre anónimas."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Estás pronto para começar?\nExperimenta fazer uma pesquisa!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Bloquearei rastreadores para que não possam espiá-lo."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Em seguida, experimenta visitar um site!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Limpa instantaneamente a tua atividade de navegação com o Fire Button.\n\nExperimenta! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "como dizer \"pato\" em espanhol"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "como dizer \"pato\" em inglês"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "elenco do filme A Hora dos Campeões"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "elenco de avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "meteorologia local"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "receitas de biscoitos de chocolate"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "receitas de jantar"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Surpreende-me!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Enviar sempre relatórios de falhas"; diff --git a/DuckDuckGo/pt.lproj/Localizable.stringsdict b/DuckDuckGo/pt.lproj/Localizable.stringsdict index 64681dad87..7e12b91887 100644 --- a/DuckDuckGo/pt.lproj/Localizable.stringsdict +++ b/DuckDuckGo/pt.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Bloqueei-os! ☝️ Pode consultar a barra de endereços para ver quem está a tentar localizá-lo quando visita um novo site. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* e *mais 1* tentaram rastrear-te aqui. Bloqueei-os! + +☝️ Toca no escudo para obter mais informações. + other + *%2$@, %3$@* e *mais %1$d* tentaram rastrear-te aqui. Bloqueei-os! + +☝️ Toca no escudo para obter mais informações. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/ro.lproj/Localizable.strings b/DuckDuckGo/ro.lproj/Localizable.strings index b6499c706d..c9bcacea84 100644 --- a/DuckDuckGo/ro.lproj/Localizable.strings +++ b/DuckDuckGo/ro.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Da"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* a încercat să te urmărească aici. I-am blocat!\n\n☝️ Atinge scutul pentru mai multe informații."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Bate palma!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Reține: de fiecare dată când navighezi cu mine, o reclamă terifiantă își pierde aripile. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Ai ghicit!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Acesta este DuckDuckGo Search. Privat. Rapid. Mai puține reclame."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Am înțeles!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Încearcă să vizitezi un site!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Căutările tale DuckDuckGo sunt întotdeauna anonime."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ești gata să începi?\nÎncearcă o căutare!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Voi bloca instrumentele de urmărire ca să nu te mai spioneze."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Apoi, încearcă să vizitezi un site!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Șterge instantaneu activitatea de navigare cu Fire Button.\n\nÎncearcă! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "cum se spune „rață” în spaniolă"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "cum se spune „rață” în engleză"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "distribuție mighty ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "distribuția din avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "vremea locală"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "rețete de fursecuri cu fulgi de ciocolată"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "rețete pentru cină"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Surprinde-mă!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Trimite întotdeauna rapoarte de cădere"; diff --git a/DuckDuckGo/ro.lproj/Localizable.stringsdict b/DuckDuckGo/ro.lproj/Localizable.stringsdict index 46d38bbf20..cc9ab2d73c 100644 --- a/DuckDuckGo/ro.lproj/Localizable.stringsdict +++ b/DuckDuckGo/ro.lproj/Localizable.stringsdict @@ -50,6 +50,30 @@ I-am blocat! ☝️ Poți verifica bara de adrese pentru a vedea cine încearcă să te urmărească atunci când vizitezi un nou site. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* și *încă 1* încercau să te urmărească aici. I-am blocat! + +☝️ Atinge scutul pentru mai multe informații. + few + *%2$@, %3$@* și *încă %1$d* încercau să te urmărească aici. I-am blocat! + +☝️ Atinge scutul pentru mai multe informații. + other + *%2$@, %3$@* și *încă %1$d* încercau să te urmărească aici. I-am blocat! + +☝️ Atinge scutul pentru mai multe informații. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/ru.lproj/Localizable.strings b/DuckDuckGo/ru.lproj/Localizable.strings index dc77396947..3e055c4bea 100644 --- a/DuckDuckGo/ru.lproj/Localizable.strings +++ b/DuckDuckGo/ru.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Да"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Сайт *%1$@* пытался вести за вами слежку, но мы его заблокировали.\n\n☝️ Нажмите на щит, чтобы узнать подробности."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Дай пять!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Бродить по сайтам с нами — значит подрезать крылья назойливой рекламе. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Проще некуда!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Это — DuckDuckGo Search. Надежно. Быстро. Меньше рекламы."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Понятно"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Попробуйте посетить сайт!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Ваши поисковые запросы в DuckDuckGo всегда анонимны."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ну что, приступим?\nПопробуйте ввести запрос!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Мы заблокируем трекеры и пресечем слежку."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "А теперь попробуйте посетить сайт!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Кнопка Fire Button моментально стирает из браузера данные о посещении сайтов.\n\nУбедитесь сами! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "Как сказать «утка» по-испански?"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "Как сказать «утка» по-английски?"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "Кто играет главные роли в фильме «Могучие утята»?"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "актерский состав аватара"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "Местная погода"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "рецепты печенья с шоколадной крошкой"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "рецепты на ужин"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Удиви меня!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Всегда отправлять отчеты о сбоях"; diff --git a/DuckDuckGo/ru.lproj/Localizable.stringsdict b/DuckDuckGo/ru.lproj/Localizable.stringsdict index cf8526647d..891171432e 100644 --- a/DuckDuckGo/ru.lproj/Localizable.stringsdict +++ b/DuckDuckGo/ru.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ ☝️ Заходя на новый сайт, вы всегда можете проверить, шпионит ли он за вами: просто загляните в адресную строку. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* и *еще 1 сервис* пытались вести за вами слежку, но мы их заблокировали. + +☝️ Нажмите на щит, чтобы узнать подробности. + few + *%2$@, %3$@* и *еще %1$d сервиса* пытались вести за вами слежку, но мы их заблокировали. + +☝️ Нажмите на щит, чтобы узнать подробности. + many + *%2$@, %3$@* и *еще %1$d сервисов* пытались вести за вами слежку, но мы их заблокировали. + +☝️ Нажмите на щит, чтобы узнать подробности. + other + *%2$@, %3$@* и другие сервисы *(еще %1$d)* пытались вести за вами слежку, но мы их заблокировали. + +☝️ Нажмите на щит, чтобы узнать подробности. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/sk.lproj/Localizable.strings b/DuckDuckGo/sk.lproj/Localizable.strings index 628b8006a2..8d7fe5f88c 100644 --- a/DuckDuckGo/sk.lproj/Localizable.strings +++ b/DuckDuckGo/sk.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Áno"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Služba *%1$@* sa vás tu pokúsila sledovať. Bola zablokovaná!\n\n☝️ Ťuknite na štít pre viac informácií."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Ruku na to!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Pamätajte: zakaždým, keď prehliadate v našej aplikácii, tak čudným reklamám pristrihávate krídla. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Máte to!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "To je DuckDuckGo vyhľadávanie. Súkromne. Rýchlo. Menej reklám."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Rozumiem!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Skúste navštíviť stránku!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Vyhľadávania v službe DuckDuckGo sú vždy anonymné."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ste pripravený/-á začať?\nSkúste vyhľadávanie!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Zablokujem sledovacie zariadenia, ktoré by vás mohli špehovať."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Nabudúce skúste navštíviť webovú stránku!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Okamžite vymažte svoju aktivitu pri prehliadaní pomocou Fire Button.\n\nVyskúšajte to! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "ako sa povie „kačica“ po španielsky"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "ako sa povie „kačica“ po anglicky"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mocné kačice obsadenie"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "obsadenie avatara"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "Miestne počasie"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recepty na čokoládové sušienky"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "Recepty na večeru"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Prekvapte ma!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Vždy odosielať správy o zlyhaní"; diff --git a/DuckDuckGo/sk.lproj/Localizable.stringsdict b/DuckDuckGo/sk.lproj/Localizable.stringsdict index 5a24465306..6f1ec1b765 100644 --- a/DuckDuckGo/sk.lproj/Localizable.stringsdict +++ b/DuckDuckGo/sk.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Boli zablokovaní! ☝️Môžete skontrolovať panel s adresou a zistiť, kto sa vás snaží sledovať, keď navštívite novú webovú lokalitu.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* a *%1$d ďalšia* sa vás tu pokúšala vystopovať. Zablokoval som ich! + +☝️ Klepnutím na štít zobrazíte ďalšie informácie. + few + *%2$@, %3$@* a *%1$d ďalšie* sa vás tu pokúšali vystopovať. Zablokoval som ich! + +☝️ Klepnutím na štít zobrazíte ďalšie informácie. + many + *%2$@, %3$@* a *%1$d ďalších* sa vás tu pokúšalo vystopovať. Zablokoval som ich! + +☝️ Klepnutím na štít zobrazíte ďalšie informácie. + other + *%2$@, %3$@* a *%1$d ďalších* sa vás tu pokúšalo vystopovať. Zablokoval som ich! + +☝️ Klepnutím na štít zobrazíte ďalšie informácie. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/sl.lproj/Localizable.strings b/DuckDuckGo/sl.lproj/Localizable.strings index f8bd85104a..93eab67d05 100644 --- a/DuckDuckGo/sl.lproj/Localizable.strings +++ b/DuckDuckGo/sl.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Da"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "Domena *%1$@* vam je tukaj poskušala slediti. Blokiral sem jo!\n\n☝️ Tapnite ščit za več informacij."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Petka!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Ne pozabite: Vedno kadar brskate z mano, shrljivemu oglasu pristrižete peruti. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Uspelo ti bo!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "To je iskanje DuckDuckGo Search. Zasebno. Hitro. Z manj oglasi."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Razumem!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Poskusite obiskati spletno stran!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Vaša iskanja v DuckDuckGo so vedno anonimna."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Ste pripravljeni začeti?\nPreizkusite iskanje!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Blokiral bom sledilce, da ne bodo vohunili."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Nato obiščite spletno mesto!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Takoj počistite svojo dejavnost brskanja z gumbom Fire Button.\n\nPoskusite! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "kako se reče »raca« v španščini"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "kako se reče »raca« v angleščini"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "zasedba mogočnih racmanov"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "igralska zasedba avatarja"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokalno vreme"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recepti za čokoladne piškote"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "recepti za večerjo"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Preseneti me!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Vedno pošlji poročila o zrušitvah"; diff --git a/DuckDuckGo/sl.lproj/Localizable.stringsdict b/DuckDuckGo/sl.lproj/Localizable.stringsdict index 71cbacd7a0..c52bc031b0 100644 --- a/DuckDuckGo/sl.lproj/Localizable.stringsdict +++ b/DuckDuckGo/sl.lproj/Localizable.stringsdict @@ -58,6 +58,34 @@ Blokiral sem jih! ☝️ V naslovni vrstici lahko preverite kdo vam poskuša slediti, ko obiščete novo spletno mesto.️ + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* in *še 1* so vam tukaj poskušali slediti. Blokiral sem jih! + +☝️ Za več informacij se dotaknite ščita. + two + *%2$@, %3$@* in *še %1$d* so vam tukaj poskušali slediti. Blokiral sem jih! + +☝️ Za več informacij se dotaknite ščita. + few + *%2$@, %3$@* in *še %1$d* so vam tukaj poskušali slediti. Blokiral sem jih! + +☝️ Za več informacij se dotaknite ščita. + other + *%2$@, %3$@* in *še %1$d* so vam tukaj poskušali slediti. Blokiral sem jih! + +☝️ Za več informacij se dotaknite ščita. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/sv.lproj/Localizable.strings b/DuckDuckGo/sv.lproj/Localizable.strings index 917bf774ff..19aa12e63f 100644 --- a/DuckDuckGo/sv.lproj/Localizable.strings +++ b/DuckDuckGo/sv.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Ja"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* försökte spåra dig här. Jag blockerade det!\n\n☝️ Tryck på skölden för mer info."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "High Five!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Kom ihåg: varje gång du surfar med mig förlorar en läskig annons sina vingar. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "Du klarar det här!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "Det är DuckDuckGo Search. Privat. Snabbt. Färre annonser."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Jag förstår!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Prova att besöka en webbplats!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "Dina DuckDuckGo-sökningar är alltid anonyma."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Är du redo att komma igång?\nProva att söka!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Jag blockerar trackers så att de inte kan spionera på dig."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Prova sedan att besöka en webbplats!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Rensa omedelbart din surfaktivitet med Fire Button.\n\nProva! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "vad heter ”anka” på spanska"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "hur säger man ”anka” på engelska"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "medverkande i mighty ducks"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "rollbesättningen för avatar"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "lokalt väder"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "recept på chokladkakor"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "middagsrecept"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Överraska mig!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Skicka alltid kraschrapporter"; diff --git a/DuckDuckGo/sv.lproj/Localizable.stringsdict b/DuckDuckGo/sv.lproj/Localizable.stringsdict index 176097f1ce..f744d48e50 100644 --- a/DuckDuckGo/sv.lproj/Localizable.stringsdict +++ b/DuckDuckGo/sv.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Jag blockerade dem! ☝️ Du kan kontrollera adressfältet för att se vem som försöker spåra dig när du besöker en ny sida. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* och *1 annan* försökte spåra dig här. Jag blockerade dem! + +☝️ Tryck på skölden för mer information. + other + *%2$@, %3$@* och *%1$d andra* försökte spåra dig här. Jag blockerade dem! + +☝️ Tryck på skölden för mer information. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGo/tr.lproj/Localizable.strings b/DuckDuckGo/tr.lproj/Localizable.strings index eb09a6892a..40e1e11c0b 100644 --- a/DuckDuckGo/tr.lproj/Localizable.strings +++ b/DuckDuckGo/tr.lproj/Localizable.strings @@ -724,6 +724,69 @@ /* Button to answer question 'Did turning off protections resolve the issue on this site?' */ "broken.site.report.toggle.alert.yes.button" = "Evet"; +/* First parameter is a count of additional trackers, second and third are names of the tracker networks (strings) */ +"contextual.onboarding.browsing.multiple.trackers" = "contextual.onboarding.browsing.multiple.trackers"; + +/* Parameter is domain name (string) */ +"contextual.onboarding.browsing.one.tracker" = "*%1$@* sizi burada izlemeye çalışıyordu. Onları engelledik!\n\n☝️ Daha fazla bilgi için kalkana dokunun."; + +/* Button on the last screen of the onboarding, it will dismiss the onboarding screen. */ +"contextual.onboarding.final-screen.button" = "Çak Bir Beşlik!"; + +/* Message of the last screen of the onboarding to the browser app. */ +"contextual.onboarding.final-screen.message" = "Unutmayın: İnterneti benimle ne kadar çok gezerseniz rahatsız edici reklamları da o kadar az görürsünüz. 👌"; + +/* Title of the last screen of the onboarding to the browser app */ +"contextual.onboarding.final-screen.title" = "İşte bu kadar!"; + +/* After the user performs their first search using the browser, this dialog explains the advantages of using DuckDuckGo */ +"contextual.onboarding.first-search-done.message" = "DuckDuckGo Search bu işte. Özel. Hızlı. Daha az reklam."; + +/* During onboarding steps this button is shown and takes either to the next steps or closes the onboarding. */ +"contextual.onboarding.got-it.button" = "Anladım!"; + +/* Title of a popover on the new tab page browser that invites the user to try a visiting a website */ +"contextual.onboarding.ntp.try-a-site.title" = "Bir siteyi ziyaret etmeyi deneyin!"; + +/* Message of a popover on the browser that invites the user to try a search explaining that their searches are anonymous */ +"contextual.onboarding.try-a-search.message" = "DuckDuckGo aramalarınız her zaman anonimdir."; + +/* Title of a popover on the browser that invites the user to try a search */ +"contextual.onboarding.try-a-search.title" = "Başlamaya hazır mısınız?\nBir şeyler arayın!"; + +/* Message of a popover on the browser that invites the user to try visiting a website to explain that we block trackers */ +"contextual.onboarding.try-a-site.message" = "Sizi gözetlemelerini önlemek için izleyicileri engelleyeceğim."; + +/* Title of a popover on the browser that invites the user to try a visiting a website */ +"contextual.onboarding.try-a-site.title" = "Sonra, bir siteyi ziyaret etmeyi deneyin!"; + +/* Message of a popover on the browser that invites the user to try visiting the browser Fire Button. Please leave the line break */ +"contextual.onboarding.try-fire-button.message" = "Fire Button ile göz atma etkinliğinizi anında temizleyin.\n\nDeneyin! 🔥"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1-English" = "ispanyolca \"ördek\" nasıl denir"; + +/* Browser Search query for how to say duck in english */ +"contextual.onboarding.try-search.option1international" = "ingilizcede \"ördek\" nasıl denir"; + +/* Search query for the cast of Mighty Ducks */ +"contextual.onboarding.try-search.option2-english" = "mighty ducks oyuncu kadrosu"; + +/* Search query for the cast of Avatar */ +"contextual.onboarding.try-search.option2-international" = "avatar oyuncu kadrosu"; + +/* Browser Search query for local weather */ +"contextual.onboarding.try-search.option3" = "yerel hava durumu"; + +/* Browser Search query for chocolate chip cookie recipes */ +"contextual.onboarding.try-search.surprise-me-english" = "çikolata parçacıklı kurabiye tarifleri"; + +/* Browser Search query for dinner recipes */ +"contextual.onboarding.try-search.surprise-me-international" = "akşam yemeği tarifleri"; + +/* Title for a button that triggers an unknown search query for the user. */ +"contextual.onboarding.try-search.surprise-me-title" = "Şaşırt beni!"; + /* Crash Report always send button title */ "crash.report.dialog.always.send" = "Kilitlenme Raporlarını Her Zaman Gönder"; diff --git a/DuckDuckGo/tr.lproj/Localizable.stringsdict b/DuckDuckGo/tr.lproj/Localizable.stringsdict index 5677f82dc5..64f2a50af2 100644 --- a/DuckDuckGo/tr.lproj/Localizable.stringsdict +++ b/DuckDuckGo/tr.lproj/Localizable.stringsdict @@ -42,6 +42,26 @@ Onları engelledim! ☝️ Yeni bir siteyi ziyaret ettiğinizde sizi kimin izlemeye çalıştığını görmek için URL çubuğunu kontrol edebilirsiniz. + contextual.onboarding.browsing.multiple.trackers + + NSStringLocalizedFormatKey + %1#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + *%2$@, %3$@* ve *1 diğer* site sizi burada izlemeye çalışıyordu. Onları engelledik! + +☝️ Daha fazla bilgi için kalkana dokunun. + other + *%2$@, %3$@* ve *%1$d diğer* site sizi burada izlemeye çalışıyordu. Onları engelledik! + +☝️ Daha fazla bilgi için kalkana dokunun. + + privacy.protection.major.trackers.found NSStringLocalizedFormatKey diff --git a/DuckDuckGoTests/AnimatableTypingTextModelTests.swift b/DuckDuckGoTests/AnimatableTypingTextModelTests.swift index 7da4544530..cc1670fa8d 100644 --- a/DuckDuckGoTests/AnimatableTypingTextModelTests.swift +++ b/DuckDuckGoTests/AnimatableTypingTextModelTests.swift @@ -40,7 +40,7 @@ final class AnimatableTypingTextModelTests: XCTestCase { func testWhenStartAnimatingIsCalledThenTimerIsStarted() { // GIVEN - let sut = AnimatableTypingTextModel(text: "Hello World!!!", onTypingFinished: nil, timerFactory: factoryMock) + let sut = AnimatableTypingTextModel(text: NSAttributedString(string: "Hello World!!!"), onTypingFinished: nil, timerFactory: factoryMock) XCTAssertFalse(factoryMock.didCallMakeTimer) XCTAssertNil(factoryMock.capturedInterval) XCTAssertNil(factoryMock.capturedRepeats) @@ -56,7 +56,7 @@ final class AnimatableTypingTextModelTests: XCTestCase { func testWhenStopAnimatingIsCalledThenTimerIsInvalidate() throws { // GIVEN - let sut = AnimatableTypingTextModel(text: "Hello World!!!", onTypingFinished: nil, timerFactory: factoryMock) + let sut = AnimatableTypingTextModel(text: NSAttributedString(string: "Hello World!!!"), onTypingFinished: nil, timerFactory: factoryMock) sut.startAnimating() let timerMock = try XCTUnwrap(factoryMock.createdTimer) XCTAssertFalse(timerMock.didCallInvalidate) @@ -70,7 +70,7 @@ final class AnimatableTypingTextModelTests: XCTestCase { func testWhenTimerFiresThenTypedTextIsPublished_iOS15() throws { // GIVEN - let text = "Hello World!!!" + let text = NSAttributedString(string: "Hello World!!!") var typedText: NSAttributedString = .init(string: "") let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock) sut.startAnimating() @@ -83,18 +83,18 @@ final class AnimatableTypingTextModelTests: XCTestCase { .store(in: &cancellables) XCTAssertTrue(typedText.string.isEmpty) - for i in 0 ..< text.count { + for i in 0 ..< text.length { // WHEN timerMock.fire() - - // THEN checks that the right character doesn't have clear color applied but the rest of the string has - XCTAssertTrue(assertTypedChar(forTypedText: typedText, at: i)) + + // THEN + XCTAssertTrue(isAttributedStringColorsCorrect(typedText, visibleLength: i + 1)) } } func testWhenStopAnimatingIsCalledThenWholeTextIsPublished_iOS15() throws { // GIVEN - let text = "Hello World!!!" + let text = NSAttributedString(string: "Hello World!!!") var typedText: NSAttributedString = .init(string: "") let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock) sut.startAnimating() @@ -112,7 +112,7 @@ final class AnimatableTypingTextModelTests: XCTestCase { sut.stopAnimating() // THEN the string does not have any clear character - XCTAssertEqual(typedText.string, text) + XCTAssertEqual(typedText, text) let attributes = typedText.attributes(at: 0, effectiveRange: nil) let foregroundcColor = attributes[.foregroundColor] as? UIColor XCTAssertNil(foregroundcColor) @@ -120,14 +120,14 @@ final class AnimatableTypingTextModelTests: XCTestCase { func testWhenTimerFiresLastCharOfTextThenTimerIsInvalidated() throws { // GIVEN - let text = "Hello World!!!" + let text = NSAttributedString(string: "Hello World!!!") let sut = AnimatableTypingTextModel(text: text, onTypingFinished: nil, timerFactory: factoryMock) sut.startAnimating() let timerMock = try XCTUnwrap(factoryMock.createdTimer) XCTAssertFalse(timerMock.didCallInvalidate) // WHEN - text.forEach { _ in + text.string.forEach { _ in timerMock.fire() } timerMock.fire() // Simulate timer firing after whole text shown @@ -139,13 +139,13 @@ final class AnimatableTypingTextModelTests: XCTestCase { func testWhenTimerFinishesThenOnTypingFinishedBlockIsCalled() throws { // GIVEN let expectation = self.expectation(description: #function) - let text = "Hello World!!!" + let text = NSAttributedString(string: "Hello World!!!") let sut = AnimatableTypingTextModel(text: text, onTypingFinished: { expectation.fulfill() }, timerFactory: factoryMock) sut.startAnimating() let timerMock = try XCTUnwrap(factoryMock.createdTimer) // WHEN - text.forEach { _ in + text.string.forEach { _ in timerMock.fire() } timerMock.fire() // Simulate timer firing after whole text shown @@ -158,23 +158,25 @@ final class AnimatableTypingTextModelTests: XCTestCase { private extension AnimatableTypingTextModelTests { - func assertTypedChar(forTypedText typedText: NSAttributedString, at position: Int) -> Bool { - let typedTextAttribute = typedText.attribute(.foregroundColor, at: position, effectiveRange: nil) - - let location = position + 1 - - // If it's the last char just check the currenct character as there's no remaining string to check - guard location < typedText.length else { - return typedTextAttribute == nil + func isAttributedStringColorsCorrect(_ attributedString: NSAttributedString, visibleLength: Int) -> Bool { + var isCorrect = true + let range = NSRange(location: 0, length: attributedString.length) + attributedString.enumerateAttribute(.foregroundColor, in: range, options: []) { value, range, _ in + guard let color = value as? UIColor else { + isCorrect = false + return + } + if range.location < visibleLength { + if color != .label { + isCorrect = false + } + } else { + if color != .clear { + isCorrect = false + } + } } - - // Checks that the remaining substring has a clear color - let remainingTextRange = NSRange(location: location, length: typedText.string.count) - let remainingTextAttributes = typedText.attributes(at: location, longestEffectiveRange: nil, in: remainingTextRange) - let remainingTextForegroundColor = remainingTextAttributes[.foregroundColor] as? UIColor - - return typedTextAttribute == nil && - remainingTextForegroundColor == .clear + return isCorrect } } diff --git a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift new file mode 100644 index 0000000000..5ca4961dea --- /dev/null +++ b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift @@ -0,0 +1,388 @@ +// +// ContextualDaxDialogsFactoryTests.swift +// DuckDuckGo +// +// 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 XCTest +import SwiftUI +import Core +@testable import DuckDuckGo + +final class ContextualDaxDialogsFactoryTests: XCTestCase { + private var sut: ExperimentContextualDaxDialogsFactory! + private var delegate: ContextualOnboardingDelegateMock! + private var settingsMock: ContextualOnboardingSettingsMock! + private var pixelReporterMock: OnboardingPixelReporterMock! + private var window: UIWindow! + + override func setUpWithError() throws { + try super.setUpWithError() + delegate = ContextualOnboardingDelegateMock() + settingsMock = ContextualOnboardingSettingsMock() + pixelReporterMock = OnboardingPixelReporterMock() + sut = ExperimentContextualDaxDialogsFactory( + contextualOnboardingLogic: ContextualOnboardingLogicMock(), + contextualOnboardingSettings: settingsMock, + contextualOnboardingPixelReporter: pixelReporterMock + ) + window = UIWindow(frame: UIScreen.main.bounds) + window.makeKeyAndVisible() + } + + override func tearDownWithError() throws { + window.isHidden = true + window = nil + delegate = nil + settingsMock = nil + pixelReporterMock = nil + sut = nil + try super.tearDownWithError() + } + + // MARK: - After Search + + func testWhenMakeViewForAfterSearchSpecThenCreatesOnboardingFirstSearchDoneDialog() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.afterSearch + + // WHEN + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + + // THEN + let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) + XCTAssertTrue(view.viewModel.delegate === delegate) + } + + func test_WhenMakeViewForAfterSearchSpec_AndActionIsTapped_AndTrackersDialogHasShown_ThenDidTapDismissContextualOnboardingActionIsCalledOnDelegate() throws { + // GIVEN + settingsMock.userHasSeenTrackersDialog = true + let spec = DaxDialogs.BrowsingSpec.afterSearch + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + + // WHEN + view.gotItAction() + + // THEN + XCTAssertTrue(delegate.didCallDidTapDismissContextualOnboardingAction) + XCTAssertFalse(delegate.didCallDidAcknowledgeContextualOnboardingSearch) + } + + func test_WhenMakeViewForAfterSearchSpec_AndActionIsTapped_AndTrackersDialogHasNotShown_ThenDidTapDismissContextualOnboardingActionIsCalledOnDelegate() throws { + // GIVEN + settingsMock.userHasSeenTrackersDialog = false + let spec = DaxDialogs.BrowsingSpec.afterSearch + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + + // WHEN + view.gotItAction() + + // THEN + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + XCTAssertTrue(delegate.didCallDidAcknowledgeContextualOnboardingSearch) + } + + // MARK: - Visit Website + + func test_WhenMakeViewForVisitWebsiteSpec_AndActionIsTapped_AndTrackersDialogHasShown_ThenNavigateToActionIsCalledOnDelegate() throws { + // GIVEN + settingsMock.userHasSeenTrackersDialog = true + let spec = DaxDialogs.BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .onboardingIntroShownUnique, type: .visitWebsite) + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingTryVisitingSiteDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + + // WHEN + let urlString = "some.site" + view.viewModel.listItemPressed(ContextualOnboardingListItem.site(title: urlString)) + + // THEN + XCTAssertTrue(delegate.didCallNavigateToURL) + XCTAssertEqual(delegate.urlToNavigateTo, URL(string: urlString)) + } + + // MARK: - Trackers + + func test_WhenMakeViewForTrackerSpec_ThenReturnViewOnboardingTrackersDoneDialog() throws { + // GIVEN + try [DaxDialogs.BrowsingSpec.siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withoutTrackers, .withoutTrackers].forEach { spec in + // WHEN + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + + // THEN + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) + XCTAssertNotNil(view) + } + } + + func test_WhenMakeViewForTrackerSpec_AndFireDialogHasNotShown_ThenActionCallsDidAcknowledgeContextualOnboardingTrackersDialog() throws { + try [DaxDialogs.BrowsingSpec.siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withoutTrackers, .withoutTrackers].forEach { spec in + // GIVEN + delegate = ContextualOnboardingDelegateMock() + settingsMock.userHasSeenFireDialog = false + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidAcknowledgeContextualOnboardingTrackersDialog) + + // WHEN + view.blockedTrackersCTAAction() + + // THEN + XCTAssertTrue(delegate.didCallDidAcknowledgeContextualOnboardingTrackersDialog) + } + } + + func test_WhenMakeViewForTrackerSpec_AndFireDialogHasShown_ThenActionCallsDidTapDismissContextualOnboardingAction() throws { + try [DaxDialogs.BrowsingSpec.siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withoutTrackers, .withoutTrackers].forEach { spec in + // GIVEN + delegate = ContextualOnboardingDelegateMock() + settingsMock.userHasSeenFireDialog = true + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + + // WHEN + view.blockedTrackersCTAAction() + + // THEN + XCTAssertTrue(delegate.didCallDidTapDismissContextualOnboardingAction) + } + } + + // MARK: - Fire + func test_WhenMakeViewFire_ThenReturnViewOnboardingFireDialog() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .onboardingIntroShownUnique, type: .fire) + + // WHEN + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + + // THEN + let view = try XCTUnwrap(find(OnboardingFireDialog.self, in: result)) + XCTAssertNotNil(view) + } + + // MARK: - Final + + func test_WhenMakeViewForFinalSpec_ThenReturnViewOnboardingFinalDialog() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.final + + // WHEN + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + + // THEN + let view = try XCTUnwrap(find(OnboardingFinalDialog.self, in: result)) + XCTAssertNotNil(view) + } + + func test_WhenCallActionOnOnboardingFinalDialog_ThenDidTapDismissContextualOnboardingActionOnDelegateIsCalled() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.final + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingFinalDialog.self, in: result)) + XCTAssertFalse(delegate.didCallDidTapDismissContextualOnboardingAction) + + // WHEN + view.highFiveAction() + + // THEN + XCTAssertTrue(delegate.didCallDidTapDismissContextualOnboardingAction) + } + + // MARK: - Pixels + + func testWhenViewForAfterSearchSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.afterSearch + let expectedPixel = Pixel.Event.daxDialogsSerpUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForVisitSiteSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.visitWebsite + let expectedPixel = Pixel.Event.onboardingContextualTryVisitSiteUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForWithoutTrackersSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.withoutTrackers + let expectedPixel = Pixel.Event.daxDialogsWithoutTrackersUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForWithOneTrackerSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.withOneTracker + let expectedPixel = Pixel.Event.daxDialogsWithTrackersUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForWithTrackersSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.withMultipleTrackers + let expectedPixel = Pixel.Event.daxDialogsWithTrackersUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForSiteIsMajorTrackerSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.siteIsMajorTracker + let expectedPixel = Pixel.Event.daxDialogsSiteIsMajorUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForSiteIsOwnedByMajorTrackerSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.siteOwnedByMajorTracker + let expectedPixel = Pixel.Event.daxDialogsSiteOwnedByMajorUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForFireSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.fire + let expectedPixel = Pixel.Event.daxDialogsFireEducationShownUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenViewForFinalSpecAppearsThenExpectedPixelFires() { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.final + let expectedPixel = Pixel.Event.daxDialogsEndOfJourneyTabUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: expectedPixel) + } + + func testWhenAfterSearchCTAIsTappedAndTryVisitWebsiteDialogThenExpectedPixelFires() throws { + try [DaxDialogs.BrowsingSpec.siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withoutTrackers, .withoutTrackers].forEach { spec in + // GIVEN + settingsMock.userHasSeenFireDialog = false + pixelReporterMock = OnboardingPixelReporterMock() + sut = ExperimentContextualDaxDialogsFactory( + contextualOnboardingLogic: ContextualOnboardingLogicMock(), + contextualOnboardingSettings: settingsMock, + contextualOnboardingPixelReporter: pixelReporterMock + ) + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: result)) + XCTAssertFalse(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertNil(pixelReporterMock.capturedScreenImpression) + + // WHEN + view.blockedTrackersCTAAction() + + // THEN + XCTAssertTrue(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertEqual(pixelReporterMock.capturedScreenImpression, .daxDialogsFireEducationShownUnique) + } + } + + func testWhenTrackersDialogCTAIsTappedAndFireDialogThenExpectedPixelFires() throws { + // GIVEN + let spec = DaxDialogs.BrowsingSpec.afterSearch + let result = sut.makeView(for: spec, delegate: delegate, onSizeUpdate: {}) + let view = try XCTUnwrap(find(OnboardingFirstSearchDoneDialog.self, in: result)) + XCTAssertFalse(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertNil(pixelReporterMock.capturedScreenImpression) + + // WHEN + view.gotItAction() + + // THEN + XCTAssertTrue(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertEqual(pixelReporterMock.capturedScreenImpression, .onboardingContextualTryVisitSiteUnique) + } +} + +extension ContextualDaxDialogsFactoryTests { + + func testDialogDefinedBy(spec: DaxDialogs.BrowsingSpec, firesEvent event: Pixel.Event) { + // GIVEN + let expectation = self.expectation(description: #function) + XCTAssertFalse(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertNil(pixelReporterMock.capturedScreenImpression) + + // WHEN + let view = sut.makeView(for: spec, delegate: ContextualOnboardingDelegateMock(), onSizeUpdate: {}).rootView + let host = OnboardingHostingControllerMock(rootView: AnyView(view)) + host.onAppearExpectation = expectation + window.rootViewController = host + XCTAssertNotNil(host.view) + + // THEN + waitForExpectations(timeout: 2.0) + XCTAssertTrue(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertEqual(pixelReporterMock.capturedScreenImpression, event) + } + +} + +final class ContextualOnboardingSettingsMock: ContextualOnboardingSettings { + var userHasSeenTrackersDialog: Bool = false + var userHasSeenFireDialog: Bool = false +} + + +final class ContextualOnboardingDelegateMock: ContextualOnboardingDelegate { + private(set) var didCallDidShowContextualOnboardingTrackersDialog = false + private(set) var didCallDidAcknowledgeContextualOnboardingTrackersDialog = false + private(set) var didCallDidTapDismissContextualOnboardingAction = false + private(set) var didCallSearchForQuery = false + private(set) var didCallNavigateToURL = false + private(set) var didCallDidAcknowledgeContextualOnboardingSearch = false + private(set) var urlToNavigateTo: URL? + + func didShowContextualOnboardingTrackersDialog() { + didCallDidShowContextualOnboardingTrackersDialog = true + } + + func didAcknowledgeContextualOnboardingTrackersDialog() { + didCallDidAcknowledgeContextualOnboardingTrackersDialog = true + } + + func didTapDismissContextualOnboardingAction() { + didCallDidTapDismissContextualOnboardingAction = true + } + + func searchFor(_ query: String) { + didCallSearchForQuery = true + } + + func navigateTo(url: URL) { + didCallNavigateToURL = true + urlToNavigateTo = url + } + + func didAcknowledgeContextualOnboardingSearch() { + didCallDidAcknowledgeContextualOnboardingSearch = true + } + +} diff --git a/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift new file mode 100644 index 0000000000..de22783748 --- /dev/null +++ b/DuckDuckGoTests/ContextualOnboardingNewTabDialogFactoryTests.swift @@ -0,0 +1,173 @@ +// +// ContextualOnboardingNewTabDialogFactoryTests.swift +// DuckDuckGo +// +// 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 XCTest +import SwiftUI +import Core +@testable import DuckDuckGo + +class ContextualOnboardingNewTabDialogFactoryTests: XCTestCase { + + var factory: NewTabDaxDialogFactory! + var mockDelegate: CapturingOnboardingNavigationDelegate! + var contextualOnboardingLogicMock: ContextualOnboardingLogicMock! + var pixelReporterMock: OnboardingPixelReporterMock! + var onDismissCalled: Bool! + var window: UIWindow! + + override func setUp() { + super.setUp() + mockDelegate = CapturingOnboardingNavigationDelegate() + contextualOnboardingLogicMock = ContextualOnboardingLogicMock() + onDismissCalled = false + pixelReporterMock = OnboardingPixelReporterMock() + factory = NewTabDaxDialogFactory(delegate: mockDelegate, contextualOnboardingLogic: contextualOnboardingLogicMock, onboardingPixelReporter: pixelReporterMock) + window = UIWindow(frame: UIScreen.main.bounds) + window.makeKeyAndVisible() + } + + override func tearDown() { + window.isHidden = true + window = nil + factory = nil + mockDelegate = nil + onDismissCalled = nil + contextualOnboardingLogicMock = nil + pixelReporterMock = nil + super.tearDown() + } + + func testCreateInitialDialogCreatesAnOnboardingTrySearchDialog() { + // Given + let homeDialog = DaxDialogs.HomeScreenSpec.initial + + // When + let view = factory.createDaxDialog(for: homeDialog, onDismiss: {}) + let host = UIHostingController(rootView: view) + XCTAssertNotNil(host.view) + + // Then + let trySearchDialog = find(OnboardingTrySearchDialog.self, in: host) + XCTAssertNotNil(trySearchDialog) + XCTAssertTrue(trySearchDialog?.viewModel.delegate === mockDelegate) + } + + func testCreateSubsequentDialogCreatesAnOnboardingTryVisitingSiteDialog() { + // Given + let homeDialog = DaxDialogs.HomeScreenSpec.subsequent + + // When + let view = factory.createDaxDialog(for: homeDialog, onDismiss: {}) + let host = UIHostingController(rootView: view) + XCTAssertNotNil(host.view) + + // Then + let trySiteDialog = find(OnboardingTryVisitingSiteDialog.self, in: host) + XCTAssertNotNil(trySiteDialog) + XCTAssertTrue(trySiteDialog?.viewModel.delegate === mockDelegate) + } + + func testCreateFinalDialogCreatesAnOnboardingFinalDialog() { + // Given + let expectation = XCTestExpectation(description: "action triggered") + contextualOnboardingLogicMock.expectation = expectation + var onDismissedRun = false + let homeDialog = DaxDialogs.HomeScreenSpec.final + let onDimsiss = { onDismissedRun = true } + + // When + let view = factory.createDaxDialog(for: homeDialog, onDismiss: onDimsiss) + let host = UIHostingController(rootView: view) + window.rootViewController = host + XCTAssertNotNil(host.view) + + // Then + let finalDialog = find(OnboardingFinalDialog.self, in: host) + XCTAssertNotNil(finalDialog) + finalDialog?.highFiveAction() + XCTAssertTrue(onDismissedRun) + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue(contextualOnboardingLogicMock.didCallsetFinalOnboardingDialogSeen) + } + + func testCreateAddFavoriteDialogCreatesAContextualDaxDialog() { + // Given + let homeDialog = DaxDialogs.HomeScreenSpec.addFavorite + + // When + let view = factory.createDaxDialog(for: homeDialog, onDismiss: {}) + let host = UIHostingController(rootView: view) + XCTAssertNotNil(host.view) + + // Then + let addFavoriteDialog = find(ContextualDaxDialogContent.self, in: host) + XCTAssertNotNil(addFavoriteDialog) + XCTAssertEqual(addFavoriteDialog?.message.string, homeDialog.message) + } + + // MARK: - Pixels + + func testWhenOnboardingTrySearchDialogAppearForTheFirstTime_ThenSendFireExpectedPixel() { + // GIVEN + let spec = DaxDialogs.HomeScreenSpec.initial + let pixelEvent = Pixel.Event.onboardingContextualTrySearchUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: pixelEvent) + } + + func testWhenOnboardingTryVisitSiteDialogAppearForTheFirstTime_ThenSendFireExpectedPixel() { + // GIVEN + let spec = DaxDialogs.HomeScreenSpec.subsequent + let pixelEvent = Pixel.Event.onboardingContextualTryVisitSiteUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: pixelEvent) + } + + func testWhenOnboardingFinalDialogAppearForTheFirstTime_ThenSendFireExpectedPixel() { + // GIVEN + let spec = DaxDialogs.HomeScreenSpec.final + let pixelEvent = Pixel.Event.daxDialogsEndOfJourneyNewTabUnique + // TEST + testDialogDefinedBy(spec: spec, firesEvent: pixelEvent) + } + +} + +private extension ContextualOnboardingNewTabDialogFactoryTests { + + func testDialogDefinedBy(spec: DaxDialogs.HomeScreenSpec, firesEvent event: Pixel.Event) { + // GIVEN + let expectation = self.expectation(description: #function) + XCTAssertFalse(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertNil(pixelReporterMock.capturedScreenImpression) + + // WHEN + let view = factory.createDaxDialog(for: spec, onDismiss: {}) + let host = OnboardingHostingControllerMock(rootView: AnyView(view)) + host.onAppearExpectation = expectation + window.rootViewController = host + XCTAssertNotNil(host.view) + + // THEN + waitForExpectations(timeout: 2.0) + XCTAssertTrue(pixelReporterMock.didCallTrackScreenImpressionCalled) + XCTAssertEqual(pixelReporterMock.capturedScreenImpression, event) + } + +} diff --git a/DuckDuckGoTests/ContextualOnboardingPresenterMock.swift b/DuckDuckGoTests/ContextualOnboardingPresenterMock.swift new file mode 100644 index 0000000000..7199d0cc2b --- /dev/null +++ b/DuckDuckGoTests/ContextualOnboardingPresenterMock.swift @@ -0,0 +1,36 @@ +// +// ContextualOnboardingPresenterMock.swift +// DuckDuckGo +// +// 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 +@testable import DuckDuckGo + +final class ContextualOnboardingPresenterMock: ContextualOnboardingPresenting { + private(set) var didCallPresentContextualOnboarding = false + private(set) var capturedBrowsingSpec: DaxDialogs.BrowsingSpec? + private(set) var didCallDismissContextualOnboardingIfNeeded = false + + func presentContextualOnboarding(for spec: DaxDialogs.BrowsingSpec, in vc: TabViewOnboardingDelegate) { + didCallPresentContextualOnboarding = true + capturedBrowsingSpec = spec + } + + func dismissContextualOnboardingIfNeeded(from vc: TabViewOnboardingDelegate) { + didCallDismissContextualOnboardingIfNeeded = true + } +} diff --git a/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift new file mode 100644 index 0000000000..31780c5876 --- /dev/null +++ b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift @@ -0,0 +1,228 @@ +// +// ContextualOnboardingPresenterTests.swift +// DuckDuckGo +// +// 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 XCTest +import SwiftUI +@testable import DuckDuckGo + +final class ContextualOnboardingPresenterTests: XCTestCase { + + func testWhenPresentContextualOnboardingAndVariantDoesNotSupportOnboardingIntroThenOldContextualOnboardingIsPresented() throws { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature != .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + XCTAssertFalse(parent.didCallPerformSegue) + XCTAssertNil(parent.capturedSegueIdentifier) + XCTAssertNil(parent.capturedSender) + + // WHEN + sut.presentContextualOnboarding(for: .afterSearch, in: parent) + + // THEN + XCTAssertTrue(parent.didCallPerformSegue) + XCTAssertEqual(parent.capturedSegueIdentifier, "DaxDialog") + let sender = try XCTUnwrap(parent.capturedSender as? DaxDialogs.BrowsingSpec) + XCTAssertEqual(sender, DaxDialogs.BrowsingSpec.afterSearch) + } + + func testWhenPresentContextualOnboardingAndVariantSupportsNewOnboardingIntroThenThenNewContextualOnboardingIsPresented() { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature == .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + XCTAssertFalse(parent.didCallAddChild) + XCTAssertNil(parent.capturedChild) + + // WHEN + sut.presentContextualOnboarding(for: .afterSearch, in: parent) + + // THEN + XCTAssertTrue(parent.didCallAddChild) + XCTAssertNotNil(parent.capturedChild) + } + + func testWhenPresentContextualOnboardingForFireEducational_andBarAtTheTop_TheMessageHandPointsInTheRightDirection() throws { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature == .newOnboardingIntro + } + let appSettings = AppSettingsMock() + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock, appSettings: appSettings) + let parent = TabViewControllerMock() + + // WHEN + sut.presentContextualOnboarding(for: .withOneTracker, in: parent) + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: parent)) + + // THEN + XCTAssertTrue(view.message.string.contains("☝️")) + } + + func testWhenPresentContextualOnboardingForFireEducational_andBarAtTheBottom_TheMessageHandPointsInTheRightDirection() throws { + // GIVEN + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature == .newOnboardingIntro + } + let appSettings = AppSettingsMock() + appSettings.currentAddressBarPosition = .bottom + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock, appSettings: appSettings) + let parent = TabViewControllerMock() + + // WHEN + sut.presentContextualOnboarding(for: .withOneTracker, in: parent) + let view = try XCTUnwrap(find(OnboardingTrackersDoneDialog.self, in: parent)) + + // THEN + XCTAssertTrue(view.message.string.contains("👇")) + } + + func testWhenDismissContextualOnboardingAndVariantSupportsNewOnboardingIntroThenContextualOnboardingIsDismissed() { + // GIVEN + let expectation = self.expectation(description: #function) + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature == .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + let daxController = DaxContextualOnboardingControllerMock() + daxController.removeFromParentExpectation = expectation + parent.daxContextualOnboardingController = daxController + parent.daxDialogsStackView.addArrangedSubview(daxController.view) + XCTAssertFalse(daxController.didCallRemoveFromParent) + XCTAssertNotNil(parent.daxContextualOnboardingController) + XCTAssertTrue(parent.daxDialogsStackView.arrangedSubviews.contains(daxController.view)) + + // WHEN + sut.dismissContextualOnboardingIfNeeded(from: parent) + + // THEN + waitForExpectations(timeout: 1.0) + XCTAssertTrue(daxController.didCallRemoveFromParent) + XCTAssertNil(parent.daxContextualOnboardingController) + XCTAssertFalse(parent.daxDialogsStackView.arrangedSubviews.contains(daxController.view)) + } + + func testWhenDismissContextualOnboardingAndVariantDoesNotSupportsNewOnboardingIntroThenNothingHappens() { + // GIVEN + let expectation = self.expectation(description: #function) + expectation.isInverted = true + var variantManagerMock = MockVariantManager() + variantManagerMock.isSupportedBlock = { feature in + feature != .newOnboardingIntro + } + let sut = ContextualOnboardingPresenter(variantManager: variantManagerMock) + let parent = TabViewControllerMock() + let daxController = DaxContextualOnboardingControllerMock() + daxController.removeFromParentExpectation = expectation + parent.daxContextualOnboardingController = daxController + XCTAssertFalse(daxController.didCallRemoveFromParent) + + // WHEN + sut.dismissContextualOnboardingIfNeeded(from: parent) + + // THEN + waitForExpectations(timeout: 0.4) + XCTAssertFalse(daxController.didCallRemoveFromParent) + } + +} + +final class TabViewControllerMock: UIViewController, TabViewOnboardingDelegate { + + var daxDialogsStackView: UIStackView = UIStackView() + var webViewContainerView: UIView = UIView() + var daxContextualOnboardingController: UIViewController? + + private(set) var didCallPerformSegue = false + private(set) var capturedSegueIdentifier: String? + private(set) var capturedSender: Any? + + private(set) var didCallAddChild = false + private(set) var capturedChild: UIViewController? + + private(set) var didCalldidShowTrackersDialog = false + private(set) var didCallDidShowTrackersDialog = false + private(set) var didCallDidAcknowledgeTrackersDialog = false + private(set) var didCallDidTapDismissAction = false + private(set) var didCallSearchForQuery = false + private(set) var capturedQuery: String? + private(set) var didCallNavigateToURL = false + private(set) var capturedURL: URL? + + override func performSegue(withIdentifier identifier: String, sender: Any?) { + didCallPerformSegue = true + capturedSegueIdentifier = identifier + capturedSender = sender + } + + override func addChild(_ childController: UIViewController) { + didCallAddChild = true + capturedChild = childController + } + + func didShowContextualOnboardingTrackersDialog() { + didCalldidShowTrackersDialog = true + } + + func didAcknowledgeContextualOnboardingTrackersDialog() { + didCallDidAcknowledgeTrackersDialog = true + } + + func didTapDismissContextualOnboardingAction() { + didCallDidTapDismissAction = true + } + + func searchFor(_ query: String) { + didCallSearchForQuery = true + capturedQuery = query + } + + func navigateTo(url: URL) { + didCallNavigateToURL = true + capturedURL = url + } + + func didAcknowledgeContextualOnboardingSearch() { + + } + +} + +final class DaxContextualOnboardingControllerMock: UIViewController { + + private(set) var didCallRemoveFromParent = false + + var removeFromParentExpectation: XCTestExpectation? + + override func removeFromParent() { + didCallRemoveFromParent = true + removeFromParentExpectation?.fulfill() + } + +} diff --git a/DuckDuckGoTests/CoreDataDatabaseTestUtilities.swift b/DuckDuckGoTests/CoreDataDatabaseTestUtilities.swift new file mode 100644 index 0000000000..48d8e81ec8 --- /dev/null +++ b/DuckDuckGoTests/CoreDataDatabaseTestUtilities.swift @@ -0,0 +1,48 @@ +// +// CoreDataDatabaseTestUtilities.swift +// DuckDuckGo +// +// 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 Persistence +import Bookmarks + +extension CoreDataDatabase { + + static func mock( + bundle: Bundle, + modelName: String, + dbName: String = "Test", + containerLocation: URL = MockBookmarksDatabase.tempDBDir() + ) -> CoreDataDatabase { + let model = CoreDataDatabase.loadModel(from: bundle, named: modelName)! + let db = CoreDataDatabase(name: "Test", containerLocation: tempDBDir(), model: model) + db.loadStore() + return db + } + +} + +// MARK: - CoreDataDataBaseMock + Bookmarks + +extension CoreDataDatabase { + + static var bookmarksMock: CoreDataDatabase { + mock(bundle: Bookmarks.bundle, modelName: "BookmarksModel") + } + +} diff --git a/DuckDuckGoTests/DaxDialogTests.swift b/DuckDuckGoTests/DaxDialogTests.swift index 9d222aa239..3f112a8db7 100644 --- a/DuckDuckGoTests/DaxDialogTests.swift +++ b/DuckDuckGoTests/DaxDialogTests.swift @@ -26,7 +26,7 @@ import XCTest @testable import Core @testable import DuckDuckGo -private struct MockEntityProvider: EntityProviding { +struct MockEntityProvider: EntityProviding { func entity(forHost host: String) -> Entity? { let mapper = ["www.example.com": ("https://www.example.com", [], 1.0), @@ -49,16 +49,18 @@ final class DaxDialog: XCTestCase { static let example = URL(string: "https://www.example.com")! static let ddg = URL(string: "https://duckduckgo.com?q=test")! + static let ddg2 = URL(string: "https://duckduckgo.com?q=testSomethingElse")! static let facebook = URL(string: "https://www.facebook.com")! static let google = URL(string: "https://www.google.com")! static let ownedByFacebook = URL(string: "https://www.instagram.com")! + static let ownedByFacebook2 = URL(string: "https://www.whatsapp.com")! static let amazon = URL(string: "https://www.amazon.com")! static let tracker = URL(string: "https://www.1dmp.io")! } let settings: InMemoryDaxDialogsSettings = InMemoryDaxDialogsSettings() - lazy var mockVariantManager = MockVariantManager(isSupportedReturns: true) + lazy var mockVariantManager = MockVariantManager(isSupportedReturns: false) lazy var onboarding = DaxDialogs(settings: settings, entityProviding: MockEntityProvider(), variantManager: mockVariantManager) @@ -71,12 +73,13 @@ final class DaxDialog: XCTestCase { } func testWhenResumingRegularFlowThenNextHomeMessageIsBlankUntilBrowsingMessagesShown() { + mockVariantManager.isSupportedReturns = false onboarding.enableAddFavoriteFlow() onboarding.resumeRegularFlow() XCTAssertNil(onboarding.nextHomeScreenMessage()) XCTAssertEqual(settings.homeScreenMessagesSeen, 1) XCTAssertNotNil(onboarding.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.google))) - XCTAssertEqual(onboarding.nextHomeScreenMessage(), .subsequent) + XCTAssertEqual(onboarding.nextHomeScreenMessage(), .final) XCTAssertEqual(settings.homeScreenMessagesSeen, 2) } @@ -96,7 +99,6 @@ final class DaxDialog: XCTestCase { } func testWhenEachVersionOfTrackersMessageIsShownThenFormattedCorrectlyAndNotShownAgain() { - let testCases = [ (urls: [ URLs.google ], expected: DaxDialogs.BrowsingSpec.withOneTracker.format(args: "Google"), line: #line), (urls: [ URLs.google, URLs.amazon ], expected: DaxDialogs.BrowsingSpec.withMultipleTrackers.format(args: 0, "Google", "Amazon.com"), line: #line), @@ -261,7 +263,7 @@ final class DaxDialog: XCTestCase { XCTAssertEqual(settings.homeScreenMessagesSeen, 1) XCTAssertNotNil(onboarding.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example))) XCTAssertTrue(onboarding.shouldShowFireButtonPulse) - XCTAssertEqual(DaxDialogs.HomeScreenSpec.subsequent, onboarding.nextHomeScreenMessage()) + XCTAssertEqual(DaxDialogs.HomeScreenSpec.final, onboarding.nextHomeScreenMessage()) XCTAssertEqual(settings.homeScreenMessagesSeen, 2) XCTAssertNil(onboarding.nextHomeScreenMessage()) XCTAssertEqual(settings.homeScreenMessagesSeen, 2) @@ -273,7 +275,7 @@ final class DaxDialog: XCTestCase { XCTAssertEqual(settings.homeScreenMessagesSeen, 1) XCTAssertNotNil(onboarding.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example))) XCTAssertTrue(onboarding.shouldShowFireButtonPulse) - XCTAssertEqual(DaxDialogs.HomeScreenSpec.subsequent, onboarding.nextHomeScreenMessage()) + XCTAssertEqual(DaxDialogs.HomeScreenSpec.final, onboarding.nextHomeScreenMessage()) XCTAssertEqual(settings.homeScreenMessagesSeen, 2) } @@ -302,6 +304,632 @@ final class DaxDialog: XCTestCase { XCTAssertTrue(DefaultDaxDialogsSettings().isDismissed) } + // MARK: - Experiment + + func testWhenExperimentAndBrowsingSpecIsWithOneTrackerThenHighlightAddressBarIsFalse() throws { + // GIVEN + mockVariantManager.isSupportedReturns = true + let sut = makeExperimentSUT(settings: InMemoryDaxDialogsSettings()) + let privacyInfo = makePrivacyInfo(url: URLs.example) + let detectedTracker = detectedTrackerFrom(URLs.google, pageUrl: URLs.example.absoluteString) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URLs.example) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: privacyInfo)) + + // THEN + XCTAssertEqual(result.type, .withOneTracker) + XCTAssertFalse(result.highlightAddressBar) + } + + func testWhenExperimentAndBrowsingSpecIsWithMultipleTrackerThenHighlightAddressBarIsFalse() throws { + // GIVEN + mockVariantManager.isSupportedReturns = true + let sut = makeExperimentSUT(settings: InMemoryDaxDialogsSettings()) + let privacyInfo = makePrivacyInfo(url: URLs.example) + [URLs.google, URLs.amazon].forEach { tracker in + let detectedTracker = detectedTrackerFrom(tracker, pageUrl: URLs.example.absoluteString) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URLs.example) + } + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: privacyInfo)) + + // THEN + XCTAssertEqual(result.type, .withMultipleTrackers) + XCTAssertFalse(result.highlightAddressBar) + } + + func testWhenExperimentGroupAndURLIsDuckDuckGoSearchAndSearchDialogHasNotBeenSeenThenReturnSpecTypeAfterSearch() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingAfterSearchShown = false + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertEqual(result?.type, .afterSearch) + } + + func testWhenExperimentGroupAndURLIsMajorTrackerWebsiteAndMajorTrackerDialogHasNotBeenSeenThenReturnSpecTypeSiteIsMajorTracker() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingMajorTrackingSiteShown = false + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.facebook) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: privacyInfo) + + // THEN + XCTAssertEqual(result?.type, .siteIsMajorTracker) + } + + func testWhenExperimentGroupAndURLIsOwnedByMajorTrackerAndMajorTrackerDialogHasNotBeenSeenThenReturnSpecTypeSiteOwnedByMajorTracker() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingMajorTrackingSiteShown = false + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.ownedByFacebook) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: privacyInfo) + + // THEN + XCTAssertEqual(result?.type, .siteOwnedByMajorTracker) + } + + func testWhenExperimentGroupAndURLHasTrackersAndMultipleTrackersDialogHasNotBeenSeenThenReturnSpecTypeWithMultipleTrackers() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithTrackersShown = false + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.example) + [URLs.google, URLs.amazon].forEach { url in + let detectedTracker = detectedTrackerFrom(url, pageUrl: URLs.example.absoluteString) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URLs.example) + } + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: privacyInfo) + + // THEN + XCTAssertEqual(result?.type, .withMultipleTrackers) + } + + func testWhenExperimentGroupAndURLHasNoTrackersAndIsNotSERPAndNoTrakcersDialogHasNotBeenSeenThenReturnSpecTypeWithoutTrackers() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithoutTrackersShown = false + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example)) + + // THEN + XCTAssertEqual(result?.type, .withoutTrackers) + } + + func testWhenExperimentGroupAndURLIsDuckDuckGoSearchAndHasVisitedWebsiteThenSpecTypeSearchIsReturned() throws { + try [DaxDialogs.BrowsingSpec.withoutTrackers, .siteIsMajorTracker, .siteOwnedByMajorTracker, .withOneTracker, .withMultipleTrackers].forEach { spec in + // GIVEN + let isExperiment = true + let mockVariantManager = MockVariantManager(isSupportedReturns: isExperiment) + let settings = InMemoryDaxDialogsSettings() + let sut = DaxDialogs(settings: settings, entityProviding: entityProvider, variantManager: mockVariantManager) + sut.overrideShownFlagFor(spec, flag: true) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg))) + + // THEN + XCTAssertEqual(result.type, .afterSearch) + } + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogNotSeen_AndSearchDone_ThenFinalBrowsingSpecIsReturned() throws { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingAfterSearchShown = true + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg))) + + // THEN + XCTAssertEqual(result, .final) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogNotSeen_AndWebsiteWithoutTracker_ThenFinalBrowsingSpecIsReturned() throws { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithoutTrackersShown = true + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example))) + + // THEN + XCTAssertEqual(result, .final) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogNotSeen_AndWebsiteWithTracker_ThenFinalBrowsingSpecIsReturned() throws { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithTrackersShown = true + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.example) + let detectedTracker = detectedTrackerFrom(URLs.google, pageUrl: URLs.example.absoluteString) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URLs.example) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: privacyInfo)) + + // THEN + XCTAssertEqual(result, .final) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogNotSeen_AndWebsiteMajorTracker_ThenFinalBrowsingSpecIsReturned() throws { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingMajorTrackingSiteShown = true + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.ownedByFacebook) + + // WHEN + let result = try XCTUnwrap(sut.nextBrowsingMessageIfShouldShow(for: privacyInfo)) + + // THEN + XCTAssertEqual(result, .final) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogSeen_AndSearchDone_ThenBrowsingSpecIsNil() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingAfterSearchShown = true + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertNil(result) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogSeen_AndWebsiteWithoutTracker_ThenBrowsingSpecIsNotFinal() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithoutTrackersShown = true + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example)) + + // THEN + XCTAssertNil(result) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogSeen_AndWebsiteWithTracker_ThenBrowsingSpecIsNil() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithTrackersShown = true + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = true + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.example) + let detectedTracker = detectedTrackerFrom(URLs.google, pageUrl: URLs.example.absoluteString) + privacyInfo.trackerInfo.addDetectedTracker(detectedTracker, onPageWithURL: URLs.example) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: privacyInfo) + + // THEN + XCTAssertNil(result) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogSeen_AndWebsiteMajorTracker_ThenFinalBrowsingSpecIsReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingMajorTrackingSiteShown = true + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = true + let sut = makeExperimentSUT(settings: settings) + let privacyInfo = makePrivacyInfo(url: URLs.ownedByFacebook) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: privacyInfo) + + // THEN + XCTAssertNil(result) + } + + func testWhenExperimentGroup_AndFireButtonSeen_AndFinalDialogSeen_AndSearchNotSeen_ThenAfterSearchSpecIsReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithoutTrackersShown = true + settings.browsingWithTrackersShown = true + settings.browsingMajorTrackingSiteShown = true + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertEqual(result, .afterSearch) + } + + func testWhenExperimentGroup_AndSearchDialogSeen_OnReload_SearchDialogReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertEqual(result1, .afterSearch) + XCTAssertEqual(result1, result2) + } + + func testWhenExperimentGroup_AndSearchDialogSeen_OnLoadingAnotherSearch_NilReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg2)) + + // THEN + XCTAssertEqual(result1, .afterSearch) + XCTAssertNil(result2) + } + + func testWhenExperimentGroup_AndMajorTrackerDialogSeen_OnReload_MajorTrackerDialogReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + + // THEN + XCTAssertEqual(result1?.type, .siteIsMajorTracker) + XCTAssertEqual(result1, result2) + } + + func testWhenExperimentGroup_AndMajorTrackerDialogSeen_OnLoadingAnotherSearch_NilReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.google)) + + // THEN + XCTAssertEqual(result1?.type, .siteIsMajorTracker) + XCTAssertNil(result2) + } + + func testWhenExperimentGroup_AndMajorTrackerOwnerMessageSeen_OnReload_MajorTrackerOwnerDialogReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ownedByFacebook)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ownedByFacebook)) + + // THEN + XCTAssertEqual(result1?.type, .siteOwnedByMajorTracker) + XCTAssertEqual(result1, result2) + } + + func testWhenExperimentGroup_AndMajorTrackerOwnerMessageSeen_OnLoadingAnotherSearch_NilReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ownedByFacebook)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ownedByFacebook2)) + + // THEN + XCTAssertEqual(result1?.type, .siteOwnedByMajorTracker) + XCTAssertNil(result2) + } + + func testWhenExperimentGroup_AndWithoutTrackersMessageSeen_OnReload_WithoutTrackersDialogReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.tracker)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.tracker)) + + // THEN + XCTAssertEqual(result1?.type, .withoutTrackers) + XCTAssertEqual(result1, result2) + } + + func testWhenExperimentGroup_AndWithoutTrackersMessageSeen_OnLoadingAnotherSearch_NilReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.tracker)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example)) + + // THEN + XCTAssertEqual(result1?.type, .withoutTrackers) + XCTAssertNil(result2) + } + + func testWhenExperimentGroup_AndFinalMessageSeen_OnReload_NilReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithoutTrackersShown = true + settings.fireMessageExperimentShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example)) + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.example)) + + // THEN + XCTAssertEqual(result1?.type, .final) + XCTAssertNil(result2) + } + + func testWhenExperimentGroup_AndVisitWebsiteSeen_OnReload_VisitWebsiteReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + sut.setSearchMessageSeen() + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + sut.setSearchMessageSeen() + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + let result3 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertEqual(result1?.type, .afterSearch) + XCTAssertEqual(result2?.type, .visitWebsite) + XCTAssertEqual(result2, result3) + } + + func testWhenExperimentGroup_AndVisitWebsiteSeen_OnLoadingAnotherSearch_NilIseturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + sut.setSearchMessageSeen() + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + sut.setSearchMessageSeen() + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + let result3 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg2)) + + // THEN + XCTAssertEqual(result1?.type, .afterSearch) + XCTAssertEqual(result2?.type, .visitWebsite) + XCTAssertNil(result3) + } + + func testWhenExperimentGroup_AndFireMessageSeen_OnReload_FireMessageReturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + sut.setSearchMessageSeen() + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + sut.setFireEducationMessageSeen() + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + let result3 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + + // THEN + XCTAssertEqual(result1?.type, .siteIsMajorTracker) + XCTAssertEqual(result2?.type, .fire) + XCTAssertEqual(result2, result3) + } + + func testWhenExperimentGroup_AndSearchNotSeen_AndFireMessageSeen_OnLoadingAnotherSearch_ExpectedDialogIseturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + sut.setSearchMessageSeen() + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + sut.setFireEducationMessageSeen() + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + let result3 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + + // THEN + XCTAssertEqual(result1?.type, .siteIsMajorTracker) + XCTAssertEqual(result2?.type, .fire) + XCTAssertEqual(result3?.type, .afterSearch) + } + + func testWhenExperimentGroup_AndSearchSeen_AndFireMessageSeen_OnLoadingAnotherSearch_ExpectedDialogIseturned() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + sut.setSearchMessageSeen() + + // WHEN + let result1 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + sut.setFireEducationMessageSeen() + let result2 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.facebook)) + let result3 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg)) + settings.browsingAfterSearchShown = true + let result4 = sut.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg2)) + + // THEN + XCTAssertEqual(result1?.type, .siteIsMajorTracker) + XCTAssertEqual(result2?.type, .fire) + XCTAssertEqual(result3?.type, .afterSearch) + XCTAssertEqual(result4?.type, .final) + } + + func testWhenExperimentGroup_AndBrowserWithTrackersShown_AndPrivacyAnimationNotShown_ThenShowPrivacyAnimationPulse() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithTrackersShown = true + settings.privacyButtonPulseShown = false + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.shouldShowPrivacyButtonPulse + + // THEN + XCTAssertTrue(result) + } + + func testWhenExperimentGroup_AndBrowserWithTrackersShown_AndPrivacyAnimationShown_ThenDoNotShowPrivacyAnimationPulse() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.browsingWithTrackersShown = true + settings.privacyButtonPulseShown = true + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.shouldShowPrivacyButtonPulse + + // THEN + XCTAssertFalse(result) + } + + func testWhenExperimentGroup_AndCallSetPrivacyButtonPulseSeen_ThenSetPrivacyButtonPulseShownFlagToTrue() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + XCTAssertFalse(settings.privacyButtonPulseShown) + + // WHEN + sut.setPrivacyButtonPulseSeen() + + // THEN + XCTAssertTrue(settings.privacyButtonPulseShown) + } + + func testWhenExperimentGroup_AndSetFireEducationMessageSeenIsCalled_ThenSetPrivacyButtonPulseShownToTrue() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + let sut = makeExperimentSUT(settings: settings) + XCTAssertFalse(settings.privacyButtonPulseShown) + + // WHEN + sut.setFireEducationMessageSeen() + + // THEN + XCTAssertTrue(settings.privacyButtonPulseShown) + } + + func testWhenExperimentGroup_AndFireButtonAnimationPulseNotShown__AndShouldShowFireButtonPulseIsCalled_ThenReturnTrue() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.privacyButtonPulseShown = true + settings.browsingWithTrackersShown = true + settings.fireButtonPulseDateShown = nil + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.shouldShowFireButtonPulse + + // THEN + XCTAssertTrue(result) + } + + func testWhenExperimentGroup_AndFireButtonAnimationPulseShown_AndShouldShowFireButtonPulseIsCalled_ThenReturnFalse() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.privacyButtonPulseShown = true + settings.browsingWithTrackersShown = true + settings.fireButtonPulseDateShown = Date() + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.shouldShowFireButtonPulse + + // THEN + XCTAssertFalse(result) + } + + func testWhenExperimentGroup_AndFireEducationMessageSeen_AndFinalMessageNotSeen_ThenShowFinalMessage() { + // GIVEN + let settings = InMemoryDaxDialogsSettings() + settings.fireMessageExperimentShown = true + settings.browsingFinalDialogShown = false + let sut = makeExperimentSUT(settings: settings) + + // WHEN + let result = sut.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(result, .final) + } + + func testWhenExperimentGroup_AndCanEnableAddFavoritesFlowIsCalled_ThenReturnFalse() { + // GIVEN + let sut = makeExperimentSUT(settings: InMemoryDaxDialogsSettings()) + + // WHEN + let result = sut.canEnableAddFavoriteFlow() + + // THEN + XCTAssertFalse(result) + } + + func testWhenControlGroup_AndCanEnableAddFavoritesFlowIsCalled_ThenReturnTrue() { + // WHEN + let result = onboarding.canEnableAddFavoriteFlow() + + // THEN + XCTAssertTrue(result) + } + + func testWhenControlGroup_AndEnableAddFavoritesFlowIsCalled_ThenIsAddFavoriteFlowIsTrue() { + // GIVEN + XCTAssertFalse(onboarding.isAddFavoriteFlow) + + // WHEN + onboarding.enableAddFavoriteFlow() + + // THEN + XCTAssertTrue(onboarding.isAddFavoriteFlow) + } + + func testWhenExperimentGroup_AndEnableAddFavoritesFlowIsCalled_ThenIsAddFavoriteFlowIsFalse() { + // GIVEN + let sut = makeExperimentSUT(settings: InMemoryDaxDialogsSettings()) + XCTAssertFalse(sut.isAddFavoriteFlow) + + // WHEN + sut.enableAddFavoriteFlow() + + // THEN + XCTAssertFalse(sut.isAddFavoriteFlow) + } private func detectedTrackerFrom(_ url: URL, pageUrl: String) -> DetectedRequest { let entity = entityProvider.entity(forHost: url.host!) @@ -325,4 +953,9 @@ final class DaxDialog: XCTestCase { parentEntity: entityProvider.entity(forHost: url.host!), protectionStatus: protectionStatus) } + + private func makeExperimentSUT(settings: DaxDialogsSettings) -> DaxDialogs { + let mockVariantManager = MockVariantManager(isSupportedReturns: true) + return DaxDialogs(settings: settings, entityProviding: entityProvider, variantManager: mockVariantManager) + } } diff --git a/DuckDuckGoTests/DaxDialogsBrowsingSpecTests.swift b/DuckDuckGoTests/DaxDialogsBrowsingSpecTests.swift index 4312a60639..f2cf62ec2a 100644 --- a/DuckDuckGoTests/DaxDialogsBrowsingSpecTests.swift +++ b/DuckDuckGoTests/DaxDialogsBrowsingSpecTests.swift @@ -18,10 +18,11 @@ // import XCTest +import PrivacyDashboard @testable import DuckDuckGo class DaxDialogsBrowsingSpecTests: XCTestCase { - + func testWhenSiteIsOwnedByMajorTrackerIsFormattedThenContainsNamesDomainAndPercentage() { let majorTracker1 = "TestTracker1" let domain = "testtracker.com" @@ -84,7 +85,7 @@ class DaxDialogsBrowsingSpecTests: XCTestCase { let message = DaxDialogs.BrowsingSpec.withOneTracker.format(args: majorTracker).message XCTAssertTrue(message.contains(majorTracker)) } - + } // From: https://stackoverflow.com/a/49547114/73479 diff --git a/DuckDuckGoTests/DaxDialogsNewTabTests.swift b/DuckDuckGoTests/DaxDialogsNewTabTests.swift new file mode 100644 index 0000000000..6561d1bed0 --- /dev/null +++ b/DuckDuckGoTests/DaxDialogsNewTabTests.swift @@ -0,0 +1,194 @@ +// +// DaxDialogsNewTabTests.swift +// DuckDuckGo +// +// 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 XCTest +import TrackerRadarKit +@testable import DuckDuckGo + +final class DaxDialogsNewTabTests: XCTestCase { + + var daxDialogs: DaxDialogs! + var settings: DaxDialogsSettings! + + override func setUp() { + settings = MockDaxDialogsSettings() + let mockVariantManager = MockVariantManager(isSupportedReturns: true) + daxDialogs = DaxDialogs(settings: settings, entityProviding: MockEntityProvider(), variantManager: mockVariantManager) + } + + override func tearDown() { + settings = nil + daxDialogs = nil + } + + func testIfIsAddFavoriteFlow_OnNextHomeScreenMessageNew_ReturnsAddFavorite() { + XCTExpectFailure("Add Favrite flow, is currenty disabled for new onboarding. Remove failure expectation once we support it.") + // GIVEN + daxDialogs.enableAddFavoriteFlow() + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .addFavorite) + } + + func testIfBrowsingAfterSearchNotShown_OnNextHomeScreenMessageNew_ReturnsInitial() { + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .initial) + } + + func testIfBrowsingAfterSearchShown_OnNextHomeScreenMessageNew_ReturnsSubsequent() { + // GIVEN + settings.browsingAfterSearchShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .subsequent) + } + + func testIfBrowsingAfterSearchShown_andBrowsingMajorTrackingSiteShown_OnNextHomeScreenMessageNew_ReturnsFinal() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingMajorTrackingSiteShown = true + settings.fireMessageExperimentShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .final) + } + + func testIfBrowsingAfterSearchShown_andBrowsingWithTrackersShown_andFireAnimationShown_OnNextHomeScreenMessageNew_ReturnsFinal() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingWithTrackersShown = true + settings.fireMessageExperimentShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .final) + } + + func testIfBrowsingAfterSearchShown_andBrowsingWithoutTrackersShown_andFireAnimationShown_OnNextHomeScreenMessageNew_ReturnsFinal() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingWithoutTrackersShown = true + settings.fireMessageExperimentShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertEqual(homeScreenMessage, .final) + } + + func testIfBrowsingAfterSearchShown_andTrackersDialogsShown_andFirreButtonFialogNotShown_OnNextHomeScreenMessageNew_ReturnsNil() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingWithoutTrackersShown = true + settings.browsingMajorTrackingSiteShown = true + settings.browsingWithTrackersShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertNil(homeScreenMessage) + XCTAssertFalse(settings.browsingFinalDialogShown) + } + + + func testIfBrowsingAfterSearchShown_andBrowsingMajorTrackingSiteShown_andFinalDialogAlreadyShown_OnNextHomeScreenMessageNew_ReturnsNil() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingMajorTrackingSiteShown = true + settings.browsingFinalDialogShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertNil(homeScreenMessage) + } + + func testIfBrowsingAfterSearchShown_andBrowsingWithTrackersShown_andFinalDialogAlreadyShown_OnNextHomeScreenMessageNew_ReturnsNil() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingWithTrackersShown = true + settings.browsingFinalDialogShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertNil(homeScreenMessage) + } + + func testIfBrowsingAfterSearchShown_andBrowsingWithoutTrackersShown_andFinalDialogAlreadyShown_OnNextHomeScreenMessageNew_ReturnsNil() { + // GIVEN + settings.browsingAfterSearchShown = true + settings.browsingWithoutTrackersShown = true + settings.browsingFinalDialogShown = true + + // WHEN + let homeScreenMessage = daxDialogs.nextHomeScreenMessageNew() + + // THEN + XCTAssertNil(homeScreenMessage) + } + +} + +class MockDaxDialogsSettings: DaxDialogsSettings { + + var lastVisitedOnboardingWebsiteURLPath: String? + + var lastShownContextualOnboardingDialogType: String? + + var isDismissed: Bool = false + + var homeScreenMessagesSeen: Int = 0 + + var browsingAfterSearchShown: Bool = false + + var browsingWithTrackersShown: Bool = false + + var browsingWithoutTrackersShown: Bool = false + + var browsingMajorTrackingSiteShown: Bool = false + + var fireButtonEducationShownOrExpired: Bool = false + + var fireMessageExperimentShown: Bool = false + + var privacyButtonPulseShown: Bool = false + + var fireButtonPulseDateShown: Date? + + var browsingFinalDialogShown: Bool = false +} diff --git a/DuckDuckGoTests/DuckPlayerMocks.swift b/DuckDuckGoTests/DuckPlayerMocks.swift index 0294c12287..10946ef060 100644 --- a/DuckDuckGoTests/DuckPlayerMocks.swift +++ b/DuckDuckGoTests/DuckPlayerMocks.swift @@ -159,3 +159,7 @@ final class MockDuckPlayerFeatureFlagger: FeatureFlagger { } } + +final class MockDuckPlayerStorage: DuckPlayerStorage { + var userInteractedWithDuckPlayer: Bool = false +} diff --git a/DuckDuckGoTests/HomeViewControllerDaxDialogTests.swift b/DuckDuckGoTests/HomeViewControllerDaxDialogTests.swift new file mode 100644 index 0000000000..1f1db8b0ca --- /dev/null +++ b/DuckDuckGoTests/HomeViewControllerDaxDialogTests.swift @@ -0,0 +1,235 @@ +// +// HomeViewControllerDaxDialogTests.swift +// DuckDuckGo +// +// 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 XCTest +@testable import DuckDuckGo +import Bookmarks +import Combine +import Core +import SwiftUI +import Persistence +import BrowserServicesKit + +final class HomeViewControllerDaxDialogTests: XCTestCase { + + var variantManager: CapturingVariantManager! + var dialogFactory: CapturingNewTabDaxDialogProvider! + var specProvider: MockNewTabDialogSpecProvider! + var hvc: HomeViewController! + + override func setUpWithError() throws { + let db = CoreDataDatabase.bookmarksMock + variantManager = CapturingVariantManager() + dialogFactory = CapturingNewTabDaxDialogProvider() + specProvider = MockNewTabDialogSpecProvider() + let dataProviders = SyncDataProviders( + bookmarksDatabase: db, + secureVaultFactory: AutofillSecureVaultFactory, + secureVaultErrorReporter: SecureVaultReporter(), + settingHandlers: [], + favoritesDisplayModeStorage: MockFavoritesDisplayModeStoring(), + syncErrorHandler: SyncErrorHandler() + ) + let remoteMessagingClient = RemoteMessagingClient( + bookmarksDatabase: db, + appSettings: AppSettingsMock(), + internalUserDecider: DefaultInternalUserDecider(), + configurationStore: MockConfigurationStoring(), + database: db, + errorEvents: nil, + remoteMessagingAvailabilityProvider: MockRemoteMessagingAvailabilityProviding(), + duckPlayerStorage: MockDuckPlayerStorage()) + let homePageConfiguration = HomePageConfiguration(remoteMessagingClient: remoteMessagingClient, privacyProDataReporter: MockPrivacyProDataReporter()) + let dependencies = HomePageDependencies( + homePageConfiguration: homePageConfiguration, + model: Tab(), + favoritesViewModel: MockFavoritesListInteracting(), + appSettings: AppSettingsMock(), + syncService: MockDDGSyncing(authState: .active, isSyncInProgress: false), + syncDataProviders: dataProviders, + privacyProDataReporter: MockPrivacyProDataReporter(), + variantManager: variantManager, + newTabDialogFactory: dialogFactory, + newTabDialogTypeProvider: specProvider) + hvc = HomeViewController.loadFromStoryboard( + homePageDependecies: dependencies) + + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + window.rootViewController?.present(hvc, animated: false, completion: nil) + + let viewLoadedExpectation = expectation(description: "View is loaded") + DispatchQueue.main.async { + XCTAssertNotNil(self.hvc.view, "The view should be loaded") + viewLoadedExpectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + specProvider.nextHomeScreenMessageCalled = false + specProvider.nextHomeScreenMessageNewCalled = false + } + + override func tearDownWithError() throws { + variantManager = nil + dialogFactory = nil + specProvider = nil + hvc = nil + } + + func testWhenNewOnboarding_OnDidAppear_CorrectTypePassedToDialogFactory() throws { + // GIVEN + variantManager.isSupported = true + let expectedSpec = randomDialogType() + specProvider.specToReturn = expectedSpec + + // WHEN + hvc.viewDidAppear(false) + + // THEN + XCTAssertEqual(self.variantManager.capturedFeatureName?.rawValue, FeatureName.newOnboardingIntro.rawValue) + XCTAssertFalse(self.specProvider.nextHomeScreenMessageCalled) + XCTAssertTrue(self.specProvider.nextHomeScreenMessageNewCalled) + XCTAssertEqual(self.dialogFactory.homeDialog, expectedSpec) + XCTAssertNotNil(self.dialogFactory.onDismiss) + } + + func testWhenOldOnboarding_OnDidAppear_NothingPassedDialogFactory() throws { + // GIVEN + variantManager.isSupported = false + + // WHEN + hvc.viewDidAppear(false) + + // THEN + XCTAssertTrue(specProvider.nextHomeScreenMessageCalled) + XCTAssertFalse(specProvider.nextHomeScreenMessageNewCalled) + XCTAssertNil(dialogFactory.homeDialog) + XCTAssertNil(dialogFactory.onDismiss) + } + + func testWhenNewOnboarding_OnOnboardingComplete_CorrectTypePassedToDialogFactory() throws { + // GIVEN + variantManager.isSupported = true + let expectedSpec = randomDialogType() + specProvider.specToReturn = expectedSpec + + // WHEN + hvc.onboardingCompleted() + + // THEN + XCTAssertEqual(self.variantManager.capturedFeatureName?.rawValue, FeatureName.newOnboardingIntro.rawValue) + XCTAssertFalse(self.specProvider.nextHomeScreenMessageCalled) + XCTAssertTrue(self.specProvider.nextHomeScreenMessageNewCalled) + XCTAssertEqual(self.dialogFactory.homeDialog, expectedSpec) + XCTAssertNotNil(self.dialogFactory.onDismiss) + } + + func testWhenOldOnboarding_OnOnboardingComplete_NothingPassedDialogFactory() throws { + // GIVEN + variantManager.isSupported = false + + // WHEN + hvc.onboardingCompleted() + + // THEN + XCTAssertTrue(specProvider.nextHomeScreenMessageCalled) + XCTAssertFalse(specProvider.nextHomeScreenMessageNewCalled) + XCTAssertNil(dialogFactory.homeDialog) + XCTAssertNil(dialogFactory.onDismiss) + } + + func testWhenOldOnboarding_OnOpenedAsNewTab_NothingPassedDialogFactory() throws { + // GIVEN + variantManager.isSupported = false + + // WHEN + hvc.openedAsNewTab(allowingKeyboard: true) + + // THEN + XCTAssertTrue(specProvider.nextHomeScreenMessageCalled) + XCTAssertFalse(specProvider.nextHomeScreenMessageNewCalled) + XCTAssertNil(dialogFactory.homeDialog) + XCTAssertNil(dialogFactory.onDismiss) + } + + private func randomDialogType() -> DaxDialogs.HomeScreenSpec { + let specs: [DaxDialogs.HomeScreenSpec] = [.initial, .subsequent, .final, .addFavorite] + return specs.randomElement()! + } +} + +class CapturingVariantManager: VariantManager { + var currentVariant: Variant? + var capturedFeatureName: FeatureName? + var isSupported = false + + func assignVariantIfNeeded(_ newInstallCompletion: (BrowserServicesKit.VariantManager) -> Void) { + } + + func isSupported(feature: FeatureName) -> Bool { + capturedFeatureName = feature + return isSupported + } +} + + +class MockFavoritesListInteracting: FavoritesListInteracting { + var favoritesDisplayMode: Bookmarks.FavoritesDisplayMode = .displayNative(.mobile) + var favorites: [Bookmarks.BookmarkEntity] = [] + func favorite(at index: Int) -> Bookmarks.BookmarkEntity? { + return nil + } + func removeFavorite(_ favorite: Bookmarks.BookmarkEntity) {} + func moveFavorite(_ favorite: Bookmarks.BookmarkEntity, fromIndex: Int, toIndex: Int) { } + var externalUpdates: AnyPublisher = Empty().eraseToAnyPublisher() + var localUpdates: AnyPublisher = Empty().eraseToAnyPublisher() + func reloadData() {} +} + +class CapturingNewTabDaxDialogProvider: NewTabDaxDialogProvider { + var homeDialog: DaxDialogs.HomeScreenSpec? + var onDismiss: (() -> Void)? + func createDaxDialog(for homeDialog: DaxDialogs.HomeScreenSpec, onDismiss: @escaping () -> Void) -> some View { + self.homeDialog = homeDialog + self.onDismiss = onDismiss + return EmptyView() + } +} + + +class MockNewTabDialogSpecProvider: NewTabDialogSpecProvider { + var nextHomeScreenMessageCalled = false + var nextHomeScreenMessageNewCalled = false + var dismissCalled = false + var specToReturn: DaxDialogs.HomeScreenSpec? + + func nextHomeScreenMessage() -> DaxDialogs.HomeScreenSpec? { + nextHomeScreenMessageCalled = true + return specToReturn + } + + func nextHomeScreenMessageNew() -> DaxDialogs.HomeScreenSpec? { + nextHomeScreenMessageNewCalled = true + return specToReturn + } + + func dismiss() { + dismissCalled = true + } +} diff --git a/DuckDuckGoTests/MockPrivacyDataReporter.swift b/DuckDuckGoTests/MockPrivacyDataReporter.swift new file mode 100644 index 0000000000..79003fceda --- /dev/null +++ b/DuckDuckGoTests/MockPrivacyDataReporter.swift @@ -0,0 +1,93 @@ +// +// MockPrivacyDataReporter.swift +// DuckDuckGo +// +// 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 DDGSync +@testable import DuckDuckGo + +final class MockPrivacyProDataReporter: PrivacyProDataReporting { + + func isReinstall() -> Bool { + false + } + + func isFireButtonUser() -> Bool { + false + } + + func isSyncUsed() -> Bool { + false + } + + func isFireproofingUsed() -> Bool { + false + } + + func isAppOnboardingCompleted() -> Bool { + false + } + + func isEmailEnabled() -> Bool { + false + } + + func isWidgetAdded() -> Bool { + false + } + + func isFrequentUser() -> Bool { + false + } + + func isLongTermUser() -> Bool { + false + } + + func isAutofillUser() -> Bool { + false + } + + func isValidOpenTabsCount() -> Bool { + false + } + + func isSearchUser() -> Bool { + false + } + + func injectSyncService(_ service: DDGSync) {} + + func injectTabsModel(_ model: DuckDuckGo.TabsModel) {} + + func saveFireCount() {} + + func saveWidgetAdded() async {} + + func saveApplicationLastSessionEnded() {} + + func saveSearchCount() {} + + func randomizedParameters(for useCase: DuckDuckGo.PrivacyProDataReportingUseCase) -> [String: String] { + [:] + } + + func mergeRandomizedParameters(for useCase: DuckDuckGo.PrivacyProDataReportingUseCase, with parameters: [String: String]) -> [String: String] { + [:] + } +} diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift new file mode 100644 index 0000000000..e271e41a6c --- /dev/null +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -0,0 +1,137 @@ +// +// MockTabDelegate.swift +// DuckDuckGo +// +// 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 struct UIKit.UIKeyModifierFlags +import WebKit +import BrowserServicesKit +import PrivacyDashboard +import Core +import Persistence +@testable import DuckDuckGo + +final class MockTabDelegate: TabDelegate { + private(set) var didRequestLoadQueryCalled = false + private(set) var capturedQuery: String? + private(set) var didRequestLoadURLCalled = false + private(set) var capturedURL: URL? + private(set) var didRequestFireButtonPulseCalled = false + private(set) var tabDidRequestPrivacyDashboardButtonPulseCalled = false + private(set) var privacyDashboardAnimated: Bool? + + + func tabWillRequestNewTab(_ tab: DuckDuckGo.TabViewController) -> UIKeyModifierFlags? { nil } + + func tabDidRequestNewTab(_ tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestNewWebViewWithConfiguration configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, inheritingAttribution: BrowserServicesKit.AdClickAttributionLogic.State?) -> WKWebView? { nil } + + func tabDidRequestClose(_ tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestNewTabForUrl url: URL, openedByPage: Bool, inheritingAttribution: BrowserServicesKit.AdClickAttributionLogic.State?) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestNewBackgroundTabForUrl url: URL, inheritingAttribution: BrowserServicesKit.AdClickAttributionLogic.State?) {} + + func tabLoadingStateDidChange(tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didUpdatePreview preview: UIImage) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didChangePrivacyInfo privacyInfo: PrivacyDashboard.PrivacyInfo?) {} + + func tabDidRequestReportBrokenSite(tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestToggleReportWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {} + + func tabDidRequestBookmarks(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestEditBookmark(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestDownloads(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestAutofillLogins(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestSettings(tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestSettingsToLogins account: BrowserServicesKit.SecureVaultModels.WebsiteAccount) {} + + func tabDidRequestFindInPage(tab: DuckDuckGo.TabViewController) {} + + func closeFindInPage(tab: DuckDuckGo.TabViewController) {} + + func tabContentProcessDidTerminate(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestForgetAll(tab: DuckDuckGo.TabViewController) {} + + func tabDidRequestFireButtonPulse(tab: DuckDuckGo.TabViewController) { + didRequestFireButtonPulseCalled = true + } + + func tabDidRequestPrivacyDashboardButtonPulse(tab: DuckDuckGo.TabViewController, animated: Bool) { + tabDidRequestPrivacyDashboardButtonPulseCalled = true + privacyDashboardAnimated = animated + } + + func tabDidRequestSearchBarRect(tab: DuckDuckGo.TabViewController) -> CGRect { .zero } + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestPresentingTrackerAnimation privacyInfo: PrivacyDashboard.PrivacyInfo, isCollapsing: Bool) {} + + func tabDidRequestShowingMenuHighlighter(tab: DuckDuckGo.TabViewController) {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestPresentingAlert alert: UIAlertController) {} + + func tabCheckIfItsBeingCurrentlyPresented(_ tab: DuckDuckGo.TabViewController) -> Bool { false } + + func showBars() {} + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestLoadURL url: URL) { + didRequestLoadURLCalled = true + capturedURL = url + } + + func tab(_ tab: DuckDuckGo.TabViewController, didRequestLoadQuery query: String) { + didRequestLoadQueryCalled = true + capturedQuery = query + } + +} + +extension TabViewController { + + static func fake( + contextualOnboardingPresenter: ContextualOnboardingPresenting = ContextualOnboardingPresenterMock(), + contextualOnboardingLogic: ContextualOnboardingLogic = ContextualOnboardingLogicMock(), + contextualOnboardingPixelReporter: OnboardingCustomInteractionPixelReporting = OnboardingPixelReporterMock() + ) -> TabViewController { + let tab = TabViewController.loadFromStoryboard( + model: .init(link: Link(title: nil, url: .ddg)), + appSettings: AppSettingsMock(), + bookmarksDatabase: CoreDataDatabase.bookmarksMock, + historyManager: MockHistoryManager(historyCoordinator: MockHistoryCoordinator(), isEnabledByUser: true, historyFeatureEnabled: true), + syncService: MockDDGSyncing(authState: .active, isSyncInProgress: false), + duckPlayer: MockDuckPlayer(settings: MockDuckPlayerSettings(privacyConfigManager: PrivacyConfigurationManagerMock())), + privacyProDataReporter: MockPrivacyProDataReporter(), + contextualOnboardingPresenter: contextualOnboardingPresenter, + contextualOnboardingLogic: contextualOnboardingLogic, + onboardingPixelReporter: contextualOnboardingPixelReporter + ) + tab.attachWebView(configuration: .nonPersistent(), andLoadRequest: nil, consumeCookies: false) + return tab + } + +} diff --git a/DuckDuckGoTests/MockTimer.swift b/DuckDuckGoTests/MockTimer.swift index 22ce47b43d..3dc19672b4 100644 --- a/DuckDuckGoTests/MockTimer.swift +++ b/DuckDuckGoTests/MockTimer.swift @@ -50,7 +50,7 @@ final class MockTimerFactory: TimerCreating { private(set) var createdTimer: MockTimer? - func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface { + func makeTimer(withTimeInterval interval: TimeInterval, repeats: Bool, on runLoop: RunLoop, block: @escaping @Sendable (TimerInterface) -> Void) -> TimerInterface { didCallMakeTimer = true capturedInterval = interval capturedRepeats = repeats diff --git a/DuckDuckGoTests/MockTutorialSettings.swift b/DuckDuckGoTests/MockTutorialSettings.swift new file mode 100644 index 0000000000..a4945640cf --- /dev/null +++ b/DuckDuckGoTests/MockTutorialSettings.swift @@ -0,0 +1,30 @@ +// +// MockTutorialSettings.swift +// DuckDuckGo +// +// 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 +@testable import DuckDuckGo + +class MockTutorialSettings: TutorialSettings { + var lastVersionSeen: Int { 0 } + var hasSeenOnboarding: Bool + + init(hasSeenOnboarding: Bool) { + self.hasSeenOnboarding = hasSeenOnboarding + } +} diff --git a/DuckDuckGoTests/OnboardingDaxFavouritesTests.swift b/DuckDuckGoTests/OnboardingDaxFavouritesTests.swift new file mode 100644 index 0000000000..2d72febe57 --- /dev/null +++ b/DuckDuckGoTests/OnboardingDaxFavouritesTests.swift @@ -0,0 +1,171 @@ +// +// OnboardingDaxFavouritesTests.swift +// DuckDuckGo +// +// 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 XCTest +import Persistence +import Bookmarks +import DDGSync +import History +import BrowserServicesKit +import RemoteMessaging +import Configuration +import Core +@testable import DuckDuckGo + +final class OnboardingDaxFavouritesTests: XCTestCase { + private var sut: MainViewController! + private var tutorialSettingsMock: MockTutorialSettings! + private var contextualOnboardingLogicMock: ContextualOnboardingLogicMock! + + override func setUpWithError() throws { + try super.setUpWithError() + let db = CoreDataDatabase.bookmarksMock + let bookmarkDatabaseCleaner = BookmarkDatabaseCleaner(bookmarkDatabase: db, errorEvents: nil) + let dataProviders = SyncDataProviders( + bookmarksDatabase: db, + secureVaultFactory: AutofillSecureVaultFactory, + secureVaultErrorReporter: SecureVaultReporter(), + settingHandlers: [], + favoritesDisplayModeStorage: MockFavoritesDisplayModeStoring(), + syncErrorHandler: SyncErrorHandler() + ) + + let remoteMessagingClient = RemoteMessagingClient( + bookmarksDatabase: db, + appSettings: AppSettingsMock(), + internalUserDecider: DefaultInternalUserDecider(), + configurationStore: MockConfigurationStoring(), + database: db, + errorEvents: nil, + remoteMessagingAvailabilityProvider: MockRemoteMessagingAvailabilityProviding(), + duckPlayerStorage: MockDuckPlayerStorage() + ) + let homePageConfiguration = HomePageConfiguration(remoteMessagingClient: remoteMessagingClient, privacyProDataReporter: MockPrivacyProDataReporter()) + let tabsModel = TabsModel(desktop: true) + tutorialSettingsMock = MockTutorialSettings(hasSeenOnboarding: false) + contextualOnboardingLogicMock = ContextualOnboardingLogicMock() + sut = MainViewController( + bookmarksDatabase: db, + bookmarksDatabaseCleaner: bookmarkDatabaseCleaner, + historyManager: MockHistoryManager(historyCoordinator: MockHistoryCoordinator(), isEnabledByUser: true, historyFeatureEnabled: true), + homePageConfiguration: homePageConfiguration, + syncService: MockDDGSyncing(authState: .active, isSyncInProgress: false), + syncDataProviders: dataProviders, + appSettings: AppSettingsMock(), + previewsSource: TabPreviewsSource(), + tabsModel: tabsModel, + syncPausedStateManager: CapturingSyncPausedStateManager(), + privacyProDataReporter: MockPrivacyProDataReporter(), + variantManager: MockVariantManager(), + contextualOnboardingPresenter: ContextualOnboardingPresenterMock(), + contextualOnboardingLogic: contextualOnboardingLogicMock, + contextualOnboardingPixelReporter: OnboardingPixelReporterMock(), + tutorialSettings: tutorialSettingsMock + ) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + window.rootViewController?.present(sut, animated: false, completion: nil) + } + + override func tearDownWithError() throws { + sut = nil + try super.tearDownWithError() + } + + func testWhenMakeOnboardingSeenIsCalled_ThenSetHasSeenOnboardingTrue() { + // GIVEN + XCTAssertFalse(tutorialSettingsMock.hasSeenOnboarding) + + // WHEN + tutorialSettingsMock.hasSeenOnboarding = true + + // THEN + XCTAssertTrue(tutorialSettingsMock.hasSeenOnboarding) + } + + func testWhenHasSeenOnboardingIntroIsCalled_AndHasSeenOnboardingSettingIsTrue_ThenReturnFalse() throws { + // GIVEN + tutorialSettingsMock.hasSeenOnboarding = true + + // WHEN + let result = sut.needsToShowOnboardingIntro() + + // THEN + XCTAssertFalse(result) + } + + func testWhenHasSeenOnboardingIntroIsCalled_AndHasSeenOnboardingIsFalse_ThenReturnTrue() throws { + // GIVEN + tutorialSettingsMock.hasSeenOnboarding = false + + // WHEN + let result = sut.needsToShowOnboardingIntro() + + // THEN + XCTAssertTrue(result) + } + + func testWhenAddFavouriteIsCalled_ThenItShouldAskContextualOnboardingLogicIfAddFavoriteFlowCanStart() { + // GIVEN + XCTAssertFalse(contextualOnboardingLogicMock.didCallCanEnableAddFavoriteFlow) + + // WHEN + sut.startAddFavoriteFlow() + + // THEN + XCTAssertTrue(contextualOnboardingLogicMock.didCallCanEnableAddFavoriteFlow) + } + + func testWhenAddFavouriteIsCalled_AndCanStartAddFavouriteFlow_ThenItShouldEnableAddFavouriteFlowOnContextualOnboardingLogic() { + // GIVEN + contextualOnboardingLogicMock.canStartFavoriteFlow = true + XCTAssertFalse(contextualOnboardingLogicMock.didCallEnableAddFavoriteFlow) + + // WHEN + sut.startAddFavoriteFlow() + + // THEN + XCTAssertTrue(contextualOnboardingLogicMock.didCallEnableAddFavoriteFlow) + } + + func testWhenAddFavouriteIsCalled_AndCannotStartAddFavouriteFlow_ThenItShouldNotEnableAddFavouriteFlowOnContextualOnboardingLogic() { + // GIVEN + contextualOnboardingLogicMock.canStartFavoriteFlow = false + XCTAssertFalse(contextualOnboardingLogicMock.didCallEnableAddFavoriteFlow) + + // WHEN + sut.startAddFavoriteFlow() + + // THEN + XCTAssertFalse(contextualOnboardingLogicMock.didCallEnableAddFavoriteFlow) + } + + func testWhenAddFavouriteIsCalled_AndCannotStartAddFavouriteFlow_ThenOpenANewTab() { + // GIVEN + contextualOnboardingLogicMock.canStartFavoriteFlow = false + XCTAssertEqual(sut.tabManager.model.tabs.count, 1) + + // WHEN + sut.startAddFavoriteFlow() + + // THEN + XCTAssertEqual(sut.tabManager.model.tabs.count, 2) + } +} diff --git a/DuckDuckGoTests/OnboardingHostingControllerMock.swift b/DuckDuckGoTests/OnboardingHostingControllerMock.swift new file mode 100644 index 0000000000..29a7864a2d --- /dev/null +++ b/DuckDuckGoTests/OnboardingHostingControllerMock.swift @@ -0,0 +1,32 @@ +// +// OnboardingHostingControllerMock.swift +// DuckDuckGo +// +// 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 SwiftUI +import XCTest + +final class OnboardingHostingControllerMock: UIHostingController { + + var onAppearExpectation: XCTestExpectation? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + onAppearExpectation?.fulfill() + } + +} diff --git a/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift b/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift new file mode 100644 index 0000000000..ab48dedbbc --- /dev/null +++ b/DuckDuckGoTests/OnboardingNavigationDelegateTests.swift @@ -0,0 +1,211 @@ +// +// OnboardingNavigationDelegateTests.swift +// DuckDuckGo +// +// 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 XCTest +import Persistence +import Bookmarks +import DDGSync +import History +import BrowserServicesKit +import RemoteMessaging +import Configuration +import Combine +@testable import DuckDuckGo +@testable import Core + +final class OnboardingNavigationDelegateTests: XCTestCase { + + var mainVC: MainViewController! + var onboardingPixelReporter: OnboardingPixelReporterMock! + + override func setUp() { + let db = CoreDataDatabase.bookmarksMock + let bookmarkDatabaseCleaner = BookmarkDatabaseCleaner(bookmarkDatabase: db, errorEvents: nil) + let dataProviders = SyncDataProviders( + bookmarksDatabase: db, + secureVaultFactory: AutofillSecureVaultFactory, + secureVaultErrorReporter: SecureVaultReporter(), + settingHandlers: [], + favoritesDisplayModeStorage: MockFavoritesDisplayModeStoring(), + syncErrorHandler: SyncErrorHandler() + ) + + let remoteMessagingClient = RemoteMessagingClient( + bookmarksDatabase: db, + appSettings: AppSettingsMock(), + internalUserDecider: DefaultInternalUserDecider(), + configurationStore: MockConfigurationStoring(), + database: db, + errorEvents: nil, + remoteMessagingAvailabilityProvider: MockRemoteMessagingAvailabilityProviding(), + duckPlayerStorage: MockDuckPlayerStorage() + ) + let homePageConfiguration = HomePageConfiguration(remoteMessagingClient: remoteMessagingClient, privacyProDataReporter: MockPrivacyProDataReporter()) + let tabsModel = TabsModel(desktop: true) + onboardingPixelReporter = OnboardingPixelReporterMock() + mainVC = MainViewController( + bookmarksDatabase: db, + bookmarksDatabaseCleaner: bookmarkDatabaseCleaner, + historyManager: MockHistoryManager(historyCoordinator: MockHistoryCoordinator(), isEnabledByUser: true, historyFeatureEnabled: true), + homePageConfiguration: homePageConfiguration, + syncService: MockDDGSyncing(authState: .active, isSyncInProgress: false), + syncDataProviders: dataProviders, + appSettings: AppSettingsMock(), + previewsSource: TabPreviewsSource(), + tabsModel: tabsModel, + syncPausedStateManager: CapturingSyncPausedStateManager(), + privacyProDataReporter: MockPrivacyProDataReporter(), + variantManager: MockVariantManager(), + contextualOnboardingPresenter: ContextualOnboardingPresenterMock(), + contextualOnboardingLogic: ContextualOnboardingLogicMock(), + contextualOnboardingPixelReporter: onboardingPixelReporter) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController() + window.makeKeyAndVisible() + window.rootViewController?.present(mainVC, animated: false, completion: nil) + + let viewLoadedExpectation = expectation(description: "View is loaded") + DispatchQueue.main.async { + XCTAssertNotNil(self.mainVC.view, "The view should be loaded") + viewLoadedExpectation.fulfill() + } + waitForExpectations(timeout: 5, handler: nil) + mainVC.loadQueryInNewTab("try something") + } + + override func tearDown() { + mainVC = nil + } + + func testSearchForQueryLoadsQueryInCurrentTab() throws { + // GIVEN + let query = "Some query" + let expectedUrl = try XCTUnwrap(URL.makeSearchURL(query: query, queryContext: nil)) + + // WHEN + mainVC.searchFor(query) + + // THEN + assertExpected(queryURL: expectedUrl) + } + + func testNavigateToURLLoadsSiteInCurrentTab() throws { + // GIVEN + let site = "duckduckgo.com" + let expectedUrl = try XCTUnwrap(URL(string: site)) + + // WHEN + mainVC.navigateTo(url: expectedUrl) + + // THEN + assertExpected(url: expectedUrl) + } + + func testWhenDidRequestLoadQueryIsCalledThenLoadsQueryInCurrentTab() throws { + // GIVEN + let query = "Some query" + let expectedUrl = try XCTUnwrap(URL.makeSearchURL(query: query, queryContext: nil)) + + // WHEN + mainVC.tab(.fake(), didRequestLoadQuery: query) + + // THEN + assertExpected(queryURL: expectedUrl) + } + + func testWhenDidRequestLoadsURLIsCalledThenLoadSiteInCurrentTab() throws { + // GIVEN + let site = "duckduckgo.com" + let expectedUrl = try XCTUnwrap(URL(string: site)) + + // WHEN + mainVC.tab(.fake(), didRequestLoadURL: expectedUrl) + + // THEN + assertExpected(url: expectedUrl) + } + + func assertExpected(queryURL: URL) { + XCTAssertNotNil(mainVC.currentTab?.url) + XCTAssertEqual(mainVC.currentTab?.url?.scheme, queryURL.scheme) + XCTAssertEqual(mainVC.currentTab?.url?.host, queryURL.host) + XCTAssertEqual(mainVC.currentTab?.url?.query, queryURL.query) + } + + func assertExpected(url: URL) { + XCTAssertNotNil(mainVC.currentTab?.url) + XCTAssertEqual(mainVC.currentTab?.url, url) + } + + // MARK: Pixel + + func testWhenPrivacyBarIconIsPressed_AndPrivacyIconIsHighlighted_ThenFireFirstTimePrivacyDashboardUsedPixel() { + // GIVEN + let isHighlighted = true + XCTAssertFalse(onboardingPixelReporter.didCallTrackPrivacyDashboardOpenedForFirstTime) + + // WHEN + mainVC.onPrivacyIconPressed(isHighlighted: isHighlighted) + + // THEN + XCTAssertTrue(onboardingPixelReporter.didCallTrackPrivacyDashboardOpenedForFirstTime) + } + + func testWhenPrivacyBarIconIsPressed_AndPrivacyIconIsNotHighlighted_ThenDoNotFireFirstTimePrivacyDashboardUsedPixel() { + // GIVEN + let isHighlighted = false + XCTAssertFalse(onboardingPixelReporter.didCallTrackPrivacyDashboardOpenedForFirstTime) + + // WHEN + mainVC.onPrivacyIconPressed(isHighlighted: isHighlighted) + + // THEN + XCTAssertFalse(onboardingPixelReporter.didCallTrackPrivacyDashboardOpenedForFirstTime) + } + +} + +class MockConfigurationStoring: ConfigurationStoring { + func loadData(for configuration: Configuration) -> Data? { + return nil + } + + func loadEtag(for configuration: Configuration) -> String? { + return nil + } + + func loadEmbeddedEtag(for configuration: Configuration) -> String? { + return nil + } + + func saveData(_ data: Data, for configuration: Configuration) throws { + } + + func saveEtag(_ etag: String, for configuration: Configuration) throws { + } + +} + +class MockRemoteMessagingAvailabilityProviding: RemoteMessagingAvailabilityProviding { + var isRemoteMessagingAvailable: Bool = false + + var isRemoteMessagingAvailablePublisher: AnyPublisher = Just(false) + .eraseToAnyPublisher() + +} diff --git a/DuckDuckGoTests/OnboardingPixelReporterMock.swift b/DuckDuckGoTests/OnboardingPixelReporterMock.swift new file mode 100644 index 0000000000..fcfe097b50 --- /dev/null +++ b/DuckDuckGoTests/OnboardingPixelReporterMock.swift @@ -0,0 +1,67 @@ +// +// OnboardingPixelReporterMock.swift +// DuckDuckGo +// +// 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 Core +@testable import DuckDuckGo + +final class OnboardingPixelReporterMock: OnboardingSiteSuggestionsPixelReporting, OnboardingSearchSuggestionsPixelReporting, OnboardingCustomInteractionPixelReporting, OnboardingScreenImpressionReporting { + private(set) var didCallTrackSearchOptionTapped = false + private(set) var didCallTrackSiteOptionTapped = false + private(set) var didCallTrackCustomSearch = false + private(set) var didCallTrackCustomSite = false + private(set) var didCallTrackSecondSiteVisit = false { + didSet { + secondSiteVisitCounter += 1 + } + } + private(set) var secondSiteVisitCounter = 0 + private(set) var didCallTrackScreenImpressionCalled = false + private(set) var capturedScreenImpression: Pixel.Event? + private(set) var didCallTrackPrivacyDashboardOpenedForFirstTime = false + + func trackSiteSuggetionOptionTapped() { + didCallTrackSiteOptionTapped = true + } + + func trackSearchSuggetionOptionTapped() { + didCallTrackSearchOptionTapped = true + } + + func trackCustomSearch() { + didCallTrackCustomSearch = true + } + + func trackCustomSite() { + didCallTrackCustomSite = true + } + + func trackSecondSiteVisit() { + didCallTrackSecondSiteVisit = true + } + + func trackScreenImpression(event: Pixel.Event) { + didCallTrackScreenImpressionCalled = true + capturedScreenImpression = event + } + + func trackPrivacyDashboardOpenedForFirstTime() { + didCallTrackPrivacyDashboardOpenedForFirstTime = true + } +} diff --git a/DuckDuckGoTests/OnboardingPixelReporterTests.swift b/DuckDuckGoTests/OnboardingPixelReporterTests.swift index b89a4f18a1..6eaa415171 100644 --- a/DuckDuckGoTests/OnboardingPixelReporterTests.swift +++ b/DuckDuckGoTests/OnboardingPixelReporterTests.swift @@ -22,16 +22,29 @@ import Core @testable import DuckDuckGo final class OnboardingPixelReporterTests: XCTestCase { + private static let suiteName = "testing_onboarding_pixel_store" private var sut: OnboardingPixelReporter! + private var statisticsStoreMock: MockStatisticsStore! + private var now: Date! + private var userDefaultsMock: UserDefaults! override func setUpWithError() throws { - sut = OnboardingPixelReporter(pixel: OnboardingPixelFireMock.self, uniquePixel: OnboardingUniquePixelFireMock.self) + statisticsStoreMock = MockStatisticsStore() + now = Date() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0)) + userDefaultsMock = UserDefaults(suiteName: Self.suiteName) + sut = OnboardingPixelReporter(pixel: OnboardingPixelFireMock.self, uniquePixel: OnboardingUniquePixelFireMock.self, statisticsStore: statisticsStoreMock, calendar: calendar, dateProvider: { self.now }, userDefaults: userDefaultsMock) try super.setUpWithError() } override func tearDownWithError() throws { OnboardingPixelFireMock.tearDown() OnboardingUniquePixelFireMock.tearDown() + statisticsStoreMock = nil + now = nil + userDefaultsMock.removePersistentDomain(forName: Self.suiteName) + userDefaultsMock = nil sut = nil try super.tearDownWithError() } @@ -93,4 +106,184 @@ final class OnboardingPixelReporterTests: XCTestCase { XCTAssertEqual(OnboardingPixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) } + // MARK: - List + + func testWhenTrackSearchSuggestionOptionTappedThenSearchOptionTappedFires() { + // GIVEN + let expectedPixel = Pixel.Event.onboardingContextualSearchOptionTappedUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackSearchSuggetionOptionTapped() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_onboarding_search_option_tapped_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } + + func testWhenTrackSiteSuggestionThenSiteOptionsTappedFires() { + // GIVEN + let expectedPixel = Pixel.Event.onboardingContextualSiteOptionTappedUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackSiteSuggetionOptionTapped() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_onboarding_visit_site_option_tapped_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } + + // MARK: - Custom Interactions + + func testWhenTrackCustomSearchIsCalledThenSearchCustomFires() { + // GIVEN + let expectedPixel = Pixel.Event.onboardingContextualSearchCustomUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackCustomSearch() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_onboarding_search_custom_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } + + func testWhenTrackCustomSiteIsCalledThenSiteCustomFires() { + // GIVEN + let expectedPixel = Pixel.Event.onboardingContextualSiteCustomUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackCustomSite() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_onboarding_visit_site_custom_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } + + func testWhenTrackSecondVisitIsCalledAndStoreDoesNotContainPixelThenPixelIsNotFired() { + // GIVEN + XCTAssertNil(userDefaultsMock.value(forKey: "com.duckduckgo.ios.site-visited")) + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackSecondSiteVisit() + + // THEN + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + } + + func testWhenTrackSecondVisitIsCalledThenFiresOnlyOnSecondTime() { + // GIVEN + let key = "com.duckduckgo.ios.site-visited" + userDefaultsMock.set(true, forKey: key) + XCTAssertTrue(userDefaultsMock.bool(forKey: key)) + let expectedPixel = Pixel.Event.onboardingContextualSecondSiteVisitUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackSecondSiteVisit() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_second_sitevisit_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } + + func testWhenTrackPrivacyDashboardOpenedForFirstTimeThenPrivacyDashboardFirstTimeOpenedPixelFires() { + // GIVEN + let expectedPixel = Pixel.Event.privacyDashboardFirstTimeOpenedUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackPrivacyDashboardOpenedForFirstTime() + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, "m_privacy_dashboard_first_time_used_unique") + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion]) + } + + func testWhenTrackPrivacyDashboardOpenedForFirstTimeThenFromOnboardingParameterIsSetToTrue() { + // GIVEN + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + + // WHEN + sut.trackPrivacyDashboardOpenedForFirstTime() + + // THEN + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams["from_onboarding"], "true") + } + + func testWhenTrackPrivacyDashboardOpenedForFirstTimeThenDaysSinceInstallParameterIsSet() { + // GIVEN + let installDate = Date(timeIntervalSince1970: 1722348000) // 30th July 2024 GMT + now = Date(timeIntervalSince1970: 1722607200) // 1st August 2024 GMT + statisticsStoreMock.installDate = installDate + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams, [:]) + + // WHEN + sut.trackPrivacyDashboardOpenedForFirstTime() + + // THEN + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedParams["daysSinceInstall"], "3") + } + + // MARK: - Screen Impressions + + func testWhenTrackScreenImpressionIsCalledThenPixelFires() { + // GIVEN + let expectedPixel = Pixel.Event.daxDialogsSerpUnique + XCTAssertFalse(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertNil(OnboardingUniquePixelFireMock.capturedPixelEvent) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, []) + + // WHEN + sut.trackScreenImpression(event: expectedPixel) + + // THEN + XCTAssertTrue(OnboardingUniquePixelFireMock.didCallFire) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedPixelEvent, expectedPixel) + XCTAssertEqual(expectedPixel.name, expectedPixel.name) + XCTAssertEqual(OnboardingUniquePixelFireMock.capturedIncludeParameters, [.appVersion, .atb]) + } } diff --git a/DuckDuckGoTests/OnboardingSuggestedSearchesProviderTests.swift b/DuckDuckGoTests/OnboardingSuggestedSearchesProviderTests.swift new file mode 100644 index 0000000000..85f1315ea5 --- /dev/null +++ b/DuckDuckGoTests/OnboardingSuggestedSearchesProviderTests.swift @@ -0,0 +1,78 @@ +// +// OnboardingSuggestedSearchesProviderTests.swift +// DuckDuckGo +// +// 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 XCTest +@testable import DuckDuckGo + +class OnboardingSuggestedSearchesProviderTests: XCTestCase { + + let userText = UserText.DaxOnboardingExperiment.ContextualOnboarding.self + + func testSearchesListForEnglishLanguageAndUsRegion() { + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "us", languageCode: "en") + let provider = OnboardingSuggestedSearchesProvider(countryAndLanguageProvider: mockProvider) + + let expectedSearches = [ + ContextualOnboardingListItem.search(title: userText.tryASearchOption1English), + ContextualOnboardingListItem.search(title: userText.tryASearchOption2English), + ContextualOnboardingListItem.search(title: userText.tryASearchOption3), + ContextualOnboardingListItem.surprise(title: userText.tryASearchOptionSurpriseMeEnglish) + ] + + XCTAssertEqual(provider.list, expectedSearches) + } + + func testSearchesListForNonEnglishLanguageAndNonUSRegion() { + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "fr", languageCode: "fr") + let provider = OnboardingSuggestedSearchesProvider(countryAndLanguageProvider: mockProvider) + + let expectedSearches = [ + ContextualOnboardingListItem.search(title: userText.tryASearchOption1International), + ContextualOnboardingListItem.search(title: userText.tryASearchOption2International), + ContextualOnboardingListItem.search(title: userText.tryASearchOption3), + ContextualOnboardingListItem.surprise(title: userText.tryASearchOptionSurpriseMeInternational) + ] + + XCTAssertEqual(provider.list, expectedSearches) + } + + func testSearchesListForUSRegionAndNonEnglishLanguage() { + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "us", languageCode: "es") + let provider = OnboardingSuggestedSearchesProvider(countryAndLanguageProvider: mockProvider) + + let expectedSearches = [ + ContextualOnboardingListItem.search(title: userText.tryASearchOption1International), + ContextualOnboardingListItem.search(title: userText.tryASearchOption2English), + ContextualOnboardingListItem.search(title: userText.tryASearchOption3), + ContextualOnboardingListItem.surprise(title: userText.tryASearchOptionSurpriseMeEnglish) + ] + + XCTAssertEqual(provider.list, expectedSearches) + } +} + +class MockOnboardingRegionAndLanguageProvider: OnboardingRegionAndLanguageProvider { + var regionCode: String? + var languageCode: String? + + init(regionCode: String?, languageCode: String?) { + self.regionCode = regionCode + self.languageCode = languageCode + } +} diff --git a/DuckDuckGoTests/OnboardingSuggestedSitesProviderTests.swift b/DuckDuckGoTests/OnboardingSuggestedSitesProviderTests.swift new file mode 100644 index 0000000000..019cf2104b --- /dev/null +++ b/DuckDuckGoTests/OnboardingSuggestedSitesProviderTests.swift @@ -0,0 +1,175 @@ +// +// OnboardingSuggestedSitesProviderTests.swift +// DuckDuckGo +// +// 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 XCTest +@testable import DuckDuckGo + +class OnboardingSuggestedSitesProviderTests: XCTestCase { + let scheme = "https:" + + func testSuggestedSitesForIndonesia() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "ID", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "bolasport.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "kompas.com")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "tokopedia.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForGB() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "GB", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "skysports.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "bbc.co.uk")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForGermany() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "DE", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "bundesliga.de")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "tagesschau.de")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "galeria.de")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: "https://duden.de")) + } + + func testSuggestedSitesForCanada() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "CA", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "tsn.ca")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "ctvnews.ca")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "canadiantire.ca")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForNetherlands() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "NL", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "voetbalprimeur.nl")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "nu.nl")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "bol.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: "https://www.woorden.org/woord/eend")) + } + + func testSuggestedSitesForAustralia() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "AU", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "afl.com.au")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForSweden() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "SE", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "svenskafans.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "dn.se")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "tradera.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: "https://www.synonymer.se/sv-syn/anka")) + } + + func testSuggestedSitesForIreland() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "IE", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "skysports.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "bbc.co.uk")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForUS() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "US", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "ESPN.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } + + func testSuggestedSitesForUnknownCountry() { + // GIVEN + let mockProvider = MockOnboardingRegionAndLanguageProvider(regionCode: "UNKNOWN", languageCode: "") + let sut = OnboardingSuggestedSitesProvider(countryProvider: mockProvider) + + // WHEN + let sitesList = sut.list + + // THEN + XCTAssertEqual(sitesList[0], ContextualOnboardingListItem.site(title: scheme + "ESPN.com")) + XCTAssertEqual(sitesList[1], ContextualOnboardingListItem.site(title: scheme + "yahoo.com")) + XCTAssertEqual(sitesList[2], ContextualOnboardingListItem.site(title: scheme + "eBay.com")) + XCTAssertEqual(sitesList[3], ContextualOnboardingListItem.surprise(title: scheme + "britannica.com/animal/duck")) + } +} diff --git a/DuckDuckGoTests/OnboardingSuggestionsViewModelsTests.swift b/DuckDuckGoTests/OnboardingSuggestionsViewModelsTests.swift new file mode 100644 index 0000000000..2ce7ad24cc --- /dev/null +++ b/DuckDuckGoTests/OnboardingSuggestionsViewModelsTests.swift @@ -0,0 +1,159 @@ +// +// OnboardingSuggestionsViewModelsTests.swift +// DuckDuckGo +// +// 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 XCTest +import Core +@testable import DuckDuckGo + +final class OnboardingSuggestionsViewModelsTests: XCTestCase { + var suggestionsProvider: MockOnboardingSuggestionsProvider! + var navigationDelegate: CapturingOnboardingNavigationDelegate! + var searchSuggestionsVM: OnboardingSearchSuggestionsViewModel! + var siteSuggestionsVM: OnboardingSiteSuggestionsViewModel! + var pixelReporterMock: OnboardingPixelReporterMock! + + override func setUp() { + suggestionsProvider = MockOnboardingSuggestionsProvider() + navigationDelegate = CapturingOnboardingNavigationDelegate() + pixelReporterMock = OnboardingPixelReporterMock() + searchSuggestionsVM = OnboardingSearchSuggestionsViewModel(suggestedSearchesProvider: suggestionsProvider, delegate: navigationDelegate, pixelReporter: pixelReporterMock) + siteSuggestionsVM = OnboardingSiteSuggestionsViewModel(title: "", suggestedSitesProvider: suggestionsProvider, delegate: navigationDelegate, pixelReporter: pixelReporterMock) + } + + override func tearDown() { + suggestionsProvider = nil + navigationDelegate = nil + pixelReporterMock = nil + searchSuggestionsVM = nil + siteSuggestionsVM = nil + } + + func testSearchSuggestionsViewModelReturnsExpectedSuggestionsList() { + // GIVEN + let expectedSearchList = [ + ContextualOnboardingListItem.search(title: "search something"), + ContextualOnboardingListItem.surprise(title: "search something else") + ] + suggestionsProvider.list = expectedSearchList + + // THEN + XCTAssertEqual(searchSuggestionsVM.itemsList, expectedSearchList) + } + + func testSearchSuggestionsViewModelOnListItemPressed_AsksDelegateToSearchForQuery() { + // GIVEN + let item1 = ContextualOnboardingListItem.search(title: "search something") + let item2 = ContextualOnboardingListItem.surprise(title: "search something else") + suggestionsProvider.list = [item1, item2] + let randomItem = [item1, item2].randomElement()! + + // WHEN + searchSuggestionsVM.listItemPressed(randomItem) + + // THEN + XCTAssertEqual(navigationDelegate.suggestedSearchQuery, randomItem.title) + } + + func testSiteSuggestionsViewModelReturnsExpectedSuggestionsList() { + // GIVEN + let expectedSiteList = [ + ContextualOnboardingListItem.site(title: "somesite.com"), + ContextualOnboardingListItem.surprise(title: "someothersite.com") + ] + suggestionsProvider.list = expectedSiteList + + // THEN + XCTAssertEqual(siteSuggestionsVM.itemsList, expectedSiteList) + } + + func testSiteSuggestionsViewModelOnListItemPressed_AsksDelegateToNavigateToURL() { + // GIVEN + let item1 = ContextualOnboardingListItem.site(title: "somesite.com") + let item2 = ContextualOnboardingListItem.surprise(title: "someothersite.com") + suggestionsProvider.list = [item1, item2] + let randomItem = [item1, item2].randomElement()! + + // WHEN + siteSuggestionsVM.listItemPressed(randomItem) + + // THEN + XCTAssertNotNil(navigationDelegate.urlToNavigateTo) + XCTAssertEqual(navigationDelegate.urlToNavigateTo, URL(string: randomItem.title)) + } + + // MARK: - Pixels + + func testWhenSearchSuggestionsTapped_ThenPixelReporterIsCalled() { + // GIVEN + let searches: [ContextualOnboardingListItem] = [ + ContextualOnboardingListItem.search(title: "First"), + ContextualOnboardingListItem.search(title: "Second"), + ContextualOnboardingListItem.search(title: "Third"), + ContextualOnboardingListItem.surprise(title: "Surprise"), + ] + suggestionsProvider.list = searches + XCTAssertFalse(pixelReporterMock.didCallTrackSearchOptionTapped) + + // WHEN + searches.forEach { searchItem in + searchSuggestionsVM.listItemPressed(searchItem) + } + + // THEN + XCTAssertTrue(pixelReporterMock.didCallTrackSearchOptionTapped) + } + + func testWhenSiteSuggestionsTapped_ThenPixelReporterIsCalled() { + // GIVEN + let searches: [ContextualOnboardingListItem] = [ + ContextualOnboardingListItem.site(title: "First"), + ContextualOnboardingListItem.site(title: "Second"), + ContextualOnboardingListItem.site(title: "Third"), + ContextualOnboardingListItem.surprise(title: "Surprise"), + ] + suggestionsProvider.list = searches + XCTAssertFalse(pixelReporterMock.didCallTrackSiteOptionTapped) + + // WHEN + searches.forEach { searchItem in + siteSuggestionsVM.listItemPressed(searchItem) + } + + // THEN + XCTAssertTrue(pixelReporterMock.didCallTrackSiteOptionTapped) + } + +} + +class MockOnboardingSuggestionsProvider: OnboardingSuggestionsItemsProviding { + var list: [ContextualOnboardingListItem] = [] +} + +class CapturingOnboardingNavigationDelegate: OnboardingNavigationDelegate { + var suggestedSearchQuery: String? + var urlToNavigateTo: URL? + + func searchFor(_ query: String) { + suggestedSearchQuery = query + } + + func navigateTo(url: URL) { + urlToNavigateTo = url + } +} diff --git a/DuckDuckGoTests/Subscription/PrivacyProDataReporterTests.swift b/DuckDuckGoTests/Subscription/PrivacyProDataReporterTests.swift index f6b6718c4a..845d68fe1a 100644 --- a/DuckDuckGoTests/Subscription/PrivacyProDataReporterTests.swift +++ b/DuckDuckGoTests/Subscription/PrivacyProDataReporterTests.swift @@ -172,15 +172,6 @@ final class PrivacyProDataReporterTests: XCTestCase { } } -struct MockTutorialSettings: TutorialSettings { - var lastVersionSeen: Int { 0 } - var hasSeenOnboarding: Bool - - init(hasSeenOnboarding: Bool) { - self.hasSeenOnboarding = hasSeenOnboarding - } -} - class MockEmailStorage: EmailManagerStorage { private let username: String? private let token: String? diff --git a/DuckDuckGoTests/SwiftUITestUtilities.swift b/DuckDuckGoTests/SwiftUITestUtilities.swift new file mode 100644 index 0000000000..b4a518060a --- /dev/null +++ b/DuckDuckGoTests/SwiftUITestUtilities.swift @@ -0,0 +1,39 @@ +// +// SwiftUITestUtilities.swift +// DuckDuckGo +// +// 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 SwiftUI + +/// Recursively searches for a SwiftUI view of type `T` within the given root object. +/// +/// - Parameters: +/// - type: The type of view to search for. +/// - root: The root object to start searching from. +/// - Returns: An optional view of type `T`, or `nil` if no such view is found. +func find(_ type: T.Type, in root: Any) -> T? { + let mirror = Mirror(reflecting: root) + for child in mirror.children { + if let view = child.value as? T { + return view + } + if let found = find(type, in: child.value) { + return found + } + } + return nil +} diff --git a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift new file mode 100644 index 0000000000..30d9b2662f --- /dev/null +++ b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift @@ -0,0 +1,280 @@ +// +// TabViewControllerDaxDialogTests.swift +// DuckDuckGo +// +// 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 XCTest +import Persistence +import Core +import WebKit +@testable import DuckDuckGo + +final class TabViewControllerDaxDialogTests: XCTestCase { + private var sut: TabViewController! + private var delegateMock: MockTabDelegate! + private var onboardingPresenterMock: ContextualOnboardingPresenterMock! + private var onboardingLogicMock: ContextualOnboardingLogicMock! + private var onboardingPixelReporterMock: OnboardingPixelReporterMock! + + override func setUpWithError() throws { + try super.setUpWithError() + delegateMock = MockTabDelegate() + onboardingPresenterMock = ContextualOnboardingPresenterMock() + onboardingLogicMock = ContextualOnboardingLogicMock() + onboardingPixelReporterMock = OnboardingPixelReporterMock() + sut = .fake(contextualOnboardingPresenter: onboardingPresenterMock, contextualOnboardingLogic: onboardingLogicMock, contextualOnboardingPixelReporter: onboardingPixelReporterMock) + sut.delegate = delegateMock + } + + override func tearDownWithError() throws { + delegateMock = nil + onboardingPresenterMock = nil + onboardingLogicMock = nil + sut = nil + try super.tearDownWithError() + } + + func testWhenSearchForQueryIsCalledThenDidRequestLoadQueryIsCalledOnDelegate() { + // GIVEN + let query = "How to say Duck in Spanish" + XCTAssertFalse(delegateMock.didRequestLoadQueryCalled) + XCTAssertNil(delegateMock.capturedQuery) + + // WHEN + sut.searchFor(query) + + // THEN + XCTAssertTrue(delegateMock.didRequestLoadQueryCalled) + XCTAssertEqual(delegateMock.capturedQuery, query) + } + + func testWhenNavigateToURLIsCalledThenDidRequestLoadURLIsCalledOnDelegate() { + // GIVEN + XCTAssertFalse(delegateMock.didRequestLoadURLCalled) + XCTAssertNil(delegateMock.capturedURL) + + // WHEN + sut.navigateTo(url: .ddg) + + // THEN + XCTAssertTrue(delegateMock.didRequestLoadURLCalled) + XCTAssertEqual(delegateMock.capturedURL, .ddg) + } + + func testWhenDidShowTrackersDialogIsCalled_AndShouldShowPrivacyAnimation_ThenTabDidRequestPrivacyDashboardButtonPulseIsCalledOnDelegate() { + // GIVEN + onboardingLogicMock.shouldShowPrivacyButtonPulse = true + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + + // WHEN + sut.didShowContextualOnboardingTrackersDialog() + + // THEN + XCTAssertTrue(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + } + + func testWhenDidShowTrackersDialogIsCalled_AndShouldNotShowPrivacyAnimation_ThenTabDidRequestPrivacyDashboardButtonPulseIsNotCalledOnDelegate() { + // GIVEN + onboardingLogicMock.shouldShowPrivacyButtonPulse = false + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + + // WHEN + sut.didShowContextualOnboardingTrackersDialog() + + // THEN + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + } + + func testWhenDidAcknowledgeTrackersDialogIsCalledThenTabDidRequestFireButtonPulseIsCalledOnDelegate() { + // GIVEN + XCTAssertFalse(delegateMock.didRequestFireButtonPulseCalled) + + // WHEN + sut.didAcknowledgeContextualOnboardingTrackersDialog() + + // THEN + XCTAssertTrue(delegateMock.didRequestFireButtonPulseCalled) + } + + func testWhenDidTapDismissActionIsCalledThenAskPresenterToDismissContextualOnboarding() { + // GIVEN + XCTAssertFalse(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + + // WHEN + sut.didTapDismissContextualOnboardingAction() + + // THEN + XCTAssertTrue(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + } + + func testWhenDidAcknowledgedTrackersDialogIsCalledThenSetFireEducationMessageSeenIsCalledOnLogic() { + // GIVEN + XCTAssertFalse(onboardingLogicMock.didCallSetFireEducationMessageSeen) + + // WHEN + sut.didAcknowledgeContextualOnboardingTrackersDialog() + + // THEN + XCTAssertTrue(onboardingLogicMock.didCallSetFireEducationMessageSeen) + } + + func testWhenDidAcknowledgeContextualOnboardingSearchIsCalledThenSetSearchMessageSeenOnLogic() { + // GIVEN + XCTAssertFalse(onboardingLogicMock.didCallsetsetSearchMessageSeen) + + // WHEN + sut.didAcknowledgeContextualOnboardingSearch() + + // THEN + XCTAssertTrue(onboardingLogicMock.didCallsetsetSearchMessageSeen) + } + + func testWhenDidShowContextualOnboardingTrackersDialog_AndShouldShowPrivacyAnimation_ShieldIconAnimationActivated() { + // GIVEN + onboardingLogicMock.shouldShowPrivacyButtonPulse = true + XCTAssertNil(delegateMock.privacyDashboardAnimated) + + // WHEN + sut.didShowContextualOnboardingTrackersDialog() + + // THEN + XCTAssertTrue(delegateMock.privacyDashboardAnimated ?? false) + } + + func testWhenDismissContextualDaxFireDialog_andNewOnboarding_andFireDialogPresented_ThenAskPresenterToDismissDialog() { + // GIVEN + onboardingLogicMock.isShowingFireDialog = true + XCTAssertFalse(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + + // WHEN + sut.dismissContextualDaxFireDialog() + + // THEN + XCTAssertTrue(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + } + + func testWhenDismissContextualDaxFireDialog_andNewOnboarding_andFireDialogIsNotPresented_ThenDoNotAskPresenterToDismissDialog() { + // GIVEN + onboardingLogicMock.isShowingFireDialog = false + XCTAssertFalse(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + + // WHEN + sut.dismissContextualDaxFireDialog() + + // THEN + XCTAssertFalse(onboardingPresenterMock.didCallDismissContextualOnboardingIfNeeded) + } + + // MARK: - SecondSite Visit Pixel + + func testWhenWebsiteFinishLoading_andIsNotSERP_ThenFireSecondSiteVisitPixel() { + // GIVEN + WKNavigation.swizzleDealloc() + let url = URL.ddg + let webView = MockWebView() + webView.setCurrentURL(url) + XCTAssertFalse(onboardingPixelReporterMock.didCallTrackSecondSiteVisit) + + // WHEN + sut.webView(webView, didFinish: WKNavigation()) + + // THEN + XCTAssertTrue(onboardingPixelReporterMock.didCallTrackSecondSiteVisit) + WKNavigation.restoreDealloc() + } + + func testWhenWebsiteFinishLoading_andIsSERP_ThenDoNotFireSecondSiteVisitPixel() throws { + // GIVEN + WKNavigation.swizzleDealloc() + let url = try XCTUnwrap(URL.makeSearchURL(text: "test")) + let webView = MockWebView() + webView.setCurrentURL(url) + XCTAssertFalse(onboardingPixelReporterMock.didCallTrackSecondSiteVisit) + + // WHEN + sut.webView(webView, didFinish: WKNavigation()) + + // THEN + XCTAssertFalse(onboardingPixelReporterMock.didCallTrackSecondSiteVisit) + WKNavigation.restoreDealloc() + } + +} + +final class ContextualOnboardingLogicMock: ContextualOnboardingLogic { + var expectation: XCTestExpectation? + private(set) var didCallSetFireEducationMessageSeen = false + private(set) var didCallsetFinalOnboardingDialogSeen = false + private(set) var didCallsetsetSearchMessageSeen = false + private(set) var didCallCanEnableAddFavoriteFlow = false + private(set) var didCallEnableAddFavoriteFlow = false + + var canStartFavoriteFlow = false + + var isShowingFireDialog: Bool = false + var shouldShowPrivacyButtonPulse: Bool = false + var isShowingSearchSuggestions: Bool = false + var isShowingSitesSuggestions: Bool = false + + func setFireEducationMessageSeen() { + didCallSetFireEducationMessageSeen = true + } + + func setFinalOnboardingDialogSeen() { + didCallsetFinalOnboardingDialogSeen = true + expectation?.fulfill() + } + + func setSearchMessageSeen() { + didCallsetsetSearchMessageSeen = true + } + + func setPrivacyButtonPulseSeen() { + + } + + func canEnableAddFavoriteFlow() -> Bool { + didCallCanEnableAddFavoriteFlow = true + return canStartFavoriteFlow + } + + func enableAddFavoriteFlow() { + didCallEnableAddFavoriteFlow = true + } + +} + +private extension WKNavigation { + private static var isSwizzled = false + private static let originalDealloc = { class_getInstanceMethod(WKNavigation.self, NSSelectorFromString("dealloc"))! }() + private static let swizzledDealloc = { class_getInstanceMethod(WKNavigation.self, #selector(swizzled_dealloc))! }() + + static func swizzleDealloc() { + guard !self.isSwizzled else { return } + self.isSwizzled = true + method_exchangeImplementations(originalDealloc, swizzledDealloc) + } + + static func restoreDealloc() { + guard self.isSwizzled else { return } + self.isSwizzled = false + method_exchangeImplementations(originalDealloc, swizzledDealloc) + } + + @objc + func swizzled_dealloc() { } +} diff --git a/LocalPackages/DuckUI/Sources/DuckUI/Button.swift b/LocalPackages/DuckUI/Sources/DuckUI/Button.swift index ef7c90261c..d28599404c 100644 --- a/LocalPackages/DuckUI/Sources/DuckUI/Button.swift +++ b/LocalPackages/DuckUI/Sources/DuckUI/Button.swift @@ -132,9 +132,9 @@ public struct SecondaryFillButtonStyle: ButtonStyle { public struct GhostButtonStyle: ButtonStyle { @Environment(\.colorScheme) private var colorScheme - + public init() {} - + public func makeBody(configuration: Configuration) -> some View { configuration.label .font(Font(UIFont.boldAppFont(ofSize: Consts.fontSize)))