From fa9ef119b3d53a7871ffb681738c813608e6a392 Mon Sep 17 00:00:00 2001 From: Sabrina Tardio <44158575+SabrinaTardio@users.noreply.github.com> Date: Thu, 25 Jul 2024 02:45:03 +0200 Subject: [PATCH] Alessandro/refresh dax dialog and ntp logo (#3129) Co-authored-by: Alessandro Boron --- Core/UserDefaultsPropertyWrapper.swift | 2 + DuckDuckGo/DaxDialogs.swift | 144 ++++++++++-- DuckDuckGo/DaxDialogsSettings.swift | 14 ++ .../HomeViewController+DaxDialogs.swift | 10 +- .../ContextualOnboardingDialogs.swift | 22 +- .../ContextualDaxDialogsFactory.swift | 20 +- DuckDuckGo/TabViewController.swift | 8 +- .../ContextualDaxDialogsFactoryTests.swift | 42 ++++ .../ContextualOnboardingPresenterTests.swift | 42 ++++ DuckDuckGoTests/DaxDialogTests.swift | 206 +++++++++++++++++- DuckDuckGoTests/DaxDialogsNewTabTests.swift | 4 + DuckDuckGoTests/MockTabDelegate.swift | 6 +- .../TabViewControllerDaxDialogTests.swift | 47 +++- 13 files changed, 529 insertions(+), 38 deletions(-) diff --git a/Core/UserDefaultsPropertyWrapper.swift b/Core/UserDefaultsPropertyWrapper.swift index 79a2a30f61..66f0bfcbed 100644 --- a/Core/UserDefaultsPropertyWrapper.swift +++ b/Core/UserDefaultsPropertyWrapper.swift @@ -51,6 +51,8 @@ public struct UserDefaultsWrapper { case daxFireButtonEducationShownOrExpired = "com.duckduckgo.ios.daxfireButtonEducationShownOrExpired" case fireButtonPulseDateShown = "com.duckduckgo.ios.fireButtonPulseDateShown" 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/DaxDialogs.swift b/DuckDuckGo/DaxDialogs.swift index 18c124ae2b..0638420f17 100644 --- a/DuckDuckGo/DaxDialogs.swift +++ b/DuckDuckGo/DaxDialogs.swift @@ -37,6 +37,7 @@ protocol NewTabDialogSpecProvider { } protocol ContextualOnboardingLogic { + func setSearchMessageSeen() func setFireEducationMessageSeen() func setFinalOnboardingDialogSeen() } @@ -77,11 +78,15 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { 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.fireButtonEducationShownOrExpired = flag case .final: settings.browsingFinalDialogShown = flag } @@ -90,13 +95,15 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { 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 @@ -299,6 +306,61 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { 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 BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .daxDialogsFireEducationConfirmed, type: .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 BrowsingSpec(message: "", cta: "", highlightAddressBar: false, pixelName: .daxDialogsFireEducationConfirmed, type: .fire) + case BrowsingSpec.SpecType.final.rawValue: + return nil + default: return nil + } + } + func fireButtonPulseStarted() { if settings.fireButtonPulseDateShown == nil { settings.fireButtonPulseDateShown = Date() @@ -325,9 +387,15 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { return ActionSheetSpec.fireButtonEducation } + func setSearchMessageSeen() { + guard isNewOnboarding else { return } + saveLastShownDaxDialog(specType: .visitWebsite) + } + func setFireEducationMessageSeen() { guard isNewOnboarding else { return } settings.fireButtonEducationShownOrExpired = true + saveLastShownDaxDialog(specType: .fire) } func setFinalOnboardingDialogSeen() { @@ -336,12 +404,13 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { } func nextBrowsingMessageIfShouldShow(for privacyInfo: PrivacyInfo) -> BrowsingSpec? { - guard privacyInfo.url != lastURLDaxDialogReturnedFor else { return nil } - - let message = if isNewOnboarding { - nextBrowsingMessageExperiment(privacyInfo: privacyInfo) + + var message: BrowsingSpec? + if isNewOnboarding { + message = nextBrowsingMessageExperiment(privacyInfo: privacyInfo) } else { - nextBrowsingMessage(privacyInfo: privacyInfo) + guard privacyInfo.url != lastURLDaxDialogReturnedFor else { return nil } + message = nextBrowsingMessage(privacyInfo: privacyInfo) } if message != nil { @@ -378,43 +447,59 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { } 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 } guard isEnabled, nextHomeScreenMessageOverride == nil else { return nil } + guard let host = privacyInfo.domain else { return nil } + var spec: BrowsingSpec? + if privacyInfo.url.isDuckDuckGoSearch && !settings.browsingAfterSearchShown { - return searchMessage() + spec = searchMessage() } // won't be shown if owned by major tracker message has already been shown if isFacebookOrGoogle(privacyInfo.url) && !settings.browsingMajorTrackingSiteShown { - return majorTrackerMessage(host) + spec = majorTrackerMessage(host) } // won't be shown if major tracker message has already been shown if let owner = isOwnedByFacebookOrGoogle(host), !settings.browsingMajorTrackingSiteShown { - return majorTrackerOwnerMessage(host, owner) + spec = majorTrackerOwnerMessage(host, owner) } if let entityNames = blockedEntityNames(privacyInfo.trackerInfo), !settings.browsingWithTrackersShown { - return trackersBlockedMessage(entityNames) + spec = trackersBlockedMessage(entityNames) } // if non duck duck go search and no trackers found and no tracker message already shown, show no trackers message if !settings.browsingWithoutTrackersShown && !privacyInfo.url.isDuckDuckGoSearch && !hasTrackers(host: host) { - return noTrackersMessage() + spec = noTrackersMessage() } // If the user visited a website and saw the fire dialog if shouldDisplayFinalContextualBrowsingDialog { - return finalMessage() + spec = finalMessage() } - return nil + if let spec { + saveLastShownDaxDialog(specType: spec.type) + saveLastVisitedOnboardingWebsite(url: privacyInfo.url) + } else { + removeLastVisitedOnboardingWebsite() + removeLastShownDaxDialog() + } + + return spec } func nextHomeScreenMessage() -> HomeScreenSpec? { @@ -481,7 +566,8 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { } 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 @@ -492,7 +578,8 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { } 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 @@ -512,7 +599,7 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { } private func trackersBlockedMessage(_ entitiesBlocked: [String]) -> BrowsingSpec? { - guard !settings.browsingWithTrackersShown else { return nil } + if !isNewOnboarding && settings.browsingWithTrackersShown { return nil } var spec: BrowsingSpec? switch entitiesBlocked.count { @@ -563,4 +650,27 @@ final class DaxDialogs: NewTabDialogSpecProvider, ContextualOnboardingLogic { 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 + } + + guard url1.isDuckDuckGoSearch && url2.isDuckDuckGoSearch else { return false } + + // Extract 'q' parameter from both URLs + let queryValue1 = URLComponents(url: url1, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "q" })?.value + let queryValue2 = URLComponents(url: url2, 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 + } } diff --git a/DuckDuckGo/DaxDialogsSettings.swift b/DuckDuckGo/DaxDialogsSettings.swift index 5d03e6603f..c6253073f1 100644 --- a/DuckDuckGo/DaxDialogsSettings.swift +++ b/DuckDuckGo/DaxDialogsSettings.swift @@ -39,6 +39,10 @@ protocol DaxDialogsSettings { var browsingFinalDialogShown: Bool { get set } + var lastVisitedOnboardingWebsiteURLPath: String? { get set } + + var lastShownContextualOnboardingDialogType: String? { get set } + } class DefaultDaxDialogsSettings: DaxDialogsSettings { @@ -70,6 +74,12 @@ class DefaultDaxDialogsSettings: DaxDialogsSettings { @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 { @@ -92,4 +102,8 @@ class InMemoryDaxDialogsSettings: DaxDialogsSettings { var browsingFinalDialogShown = false + var lastVisitedOnboardingWebsiteURLPath: String? + + var lastShownContextualOnboardingDialogType: String? + } diff --git a/DuckDuckGo/HomeViewController+DaxDialogs.swift b/DuckDuckGo/HomeViewController+DaxDialogs.swift index 575f5a1744..aa23834231 100644 --- a/DuckDuckGo/HomeViewController+DaxDialogs.swift +++ b/DuckDuckGo/HomeViewController+DaxDialogs.swift @@ -52,10 +52,10 @@ extension HomeViewController { } func showNextDaxDialogNew(dialogProvider: NewTabDialogSpecProvider, factory: any NewTabDaxDialogProvider) { - dismissHostingController() + dismissHostingController(didFinishNTPOnboarding: false) let onDismiss = { dialogProvider.dismiss() - self.dismissHostingController() + self.dismissHostingController(didFinishNTPOnboarding: true) } guard let spec = dialogProvider.nextHomeScreenMessageNew() else { return } let daxDialogView = AnyView(factory.createDaxDialog(for: spec, onDismiss: onDismiss)) @@ -75,10 +75,12 @@ extension HomeViewController { configureCollectionView() } - private func dismissHostingController() { + private func dismissHostingController(didFinishNTPOnboarding: Bool) { hostingController?.willMove(toParent: nil) hostingController?.view.removeFromSuperview() hostingController?.removeFromParent() - delegate?.home(self, didRequestHideLogo: false) + if didFinishNTPOnboarding { + delegate?.home(self, didRequestHideLogo: false) + } } } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift index 88512af5ab..af263e66ea 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualDaxDialogs/ContextualOnboardingDialogs.swift @@ -106,20 +106,28 @@ struct OnboardingFirstSearchDoneDialog: View { OnboardingTryVisitingSiteDialogContent(viewModel: viewModel) } else { ContextualDaxDialogContent(message: message, cta: cta) { - buttonAction() + gotItAction() + withAnimation { + if shouldFollowUp { + showNextScreen = true + } + } } } } } } } +} - private func buttonAction() { - withAnimation { - if shouldFollowUp { - showNextScreen = true - } else { - gotItAction() +struct OnboardingFireDialog: View { + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + DaxDialogView(logoPosition: .left) { + VStack { + OnboardingFireButtonDialogContent() + } } } } diff --git a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift index 686f3cb555..2c474d1541 100644 --- a/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift +++ b/DuckDuckGo/OnboardingExperiment/ContextualOnboarding/ContextualDaxDialogsFactory.swift @@ -23,6 +23,7 @@ import SwiftUI /// 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. @@ -52,8 +53,12 @@ final class ExperimentContextualDaxDialogsFactory: ContextualDaxDialogsFactory { switch spec.type { case .afterSearch: rootView = AnyView(afterSearchDialog(shouldFollowUpToWebsiteSearch: !contextualOnboardingSettings.userHasSeenTrackersDialog, delegate: delegate)) + case .visitWebsite: + rootView = AnyView(tryVisitingSiteDialog(delegate: delegate)) case .siteIsMajorTracker, .siteOwnedByMajorTracker, .withMultipleTrackers, .withOneTracker, .withoutTrackers: rootView = AnyView(withTrackersDialog(for: spec, shouldFollowUpToFireDialog: !contextualOnboardingSettings.userHasSeenFireDialog, delegate: delegate)) + case .fire: + rootView = AnyView(OnboardingFireDialog()) case .final: rootView = AnyView(endOfJourneyDialog(delegate: delegate)) } @@ -70,10 +75,23 @@ final class ExperimentContextualDaxDialogsFactory: ContextualDaxDialogsFactory { private func afterSearchDialog(shouldFollowUpToWebsiteSearch: Bool, delegate: ContextualOnboardingDelegate) -> some View { let viewModel = OnboardingSiteSuggestionsViewModel(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 { {} } else { { [weak delegate] in delegate?.didTapDismissContextualOnboardingAction() } } + let gotItAction: () -> Void = if shouldFollowUpToWebsiteSearch { + { [weak delegate] in + delegate?.didAcknowledgeContextualOnboardingSearch() + } + } else { + { [weak delegate] in + delegate?.didTapDismissContextualOnboardingAction() + } + } return OnboardingFirstSearchDoneDialog(shouldFollowUp: shouldFollowUpToWebsiteSearch, viewModel: viewModel, gotItAction: gotItAction) } + private func tryVisitingSiteDialog(delegate: ContextualOnboardingDelegate) -> some View { + let viewModel = OnboardingSiteSuggestionsViewModel(delegate: delegate) + return OnboardingTryVisitingSiteDialog(logoPosition: .left, viewModel: viewModel) + } + private func withTrackersDialog(for spec: DaxDialogs.BrowsingSpec, shouldFollowUpToFireDialog: Bool, delegate: ContextualOnboardingDelegate) -> some View { let attributedMessage = spec.message.attributedStringFromMarkdown(color: ThemeManager.shared.currentTheme.daxDialogTextColor) return OnboardingTrackersDoneDialog(shouldFollowUp: shouldFollowUpToFireDialog, message: attributedMessage, blockedTrackersCTAAction: { [weak self, weak delegate] in diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index ca4f4b1709..14629510be 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -1382,8 +1382,8 @@ 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. @@ -1424,7 +1424,7 @@ extension TabViewController: WKNavigationDelegate { } } } - + private func scheduleTrackerNetworksAnimation(collapsing: Bool) { let trackersWorkItem = DispatchWorkItem { guard let privacyInfo = self.privacyInfo else { return } @@ -2844,6 +2844,10 @@ extension TabViewController: OnboardingNavigationDelegate { 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() diff --git a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift index 1098cd1556..28fb87d858 100644 --- a/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift +++ b/DuckDuckGoTests/ContextualDaxDialogsFactoryTests.swift @@ -68,6 +68,7 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // THEN XCTAssertTrue(delegate.didCallDidTapDismissContextualOnboardingAction) + XCTAssertFalse(delegate.didCallDidAcknowledgeContextualOnboardingSearch) } func test_WhenMakeViewForAfterSearchSpec_AndActionIsTapped_AndTrackersDialogHasNotShown_ThenDidTapDismissContextualOnboardingActionIsCalledOnDelegate() throws { @@ -83,6 +84,26 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { // 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) + 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 @@ -133,6 +154,20 @@ final class ContextualDaxDialogsFactoryTests: XCTestCase { } } + // 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) + + // THEN + let view = try XCTUnwrap(find(OnboardingFireDialog.self, in: result)) + XCTAssertNotNil(view) + } + + // MARK: - Final func test_WhenMakeViewForFinalSpec_ThenReturnViewOnboardingFinalDialog() throws { @@ -174,6 +209,8 @@ final class ContextualOnboardingDelegateMock: ContextualOnboardingDelegate { 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 @@ -193,6 +230,11 @@ final class ContextualOnboardingDelegateMock: ContextualOnboardingDelegate { func navigateTo(url: URL) { didCallNavigateToURL = true + urlToNavigateTo = url + } + + func didAcknowledgeContextualOnboardingSearch() { + didCallDidAcknowledgeContextualOnboardingSearch = true } } diff --git a/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift index b71bd732ad..74ef26dd3b 100644 --- a/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift +++ b/DuckDuckGoTests/ContextualOnboardingPresenterTests.swift @@ -64,6 +64,43 @@ final class ContextualOnboardingPresenterTests: XCTestCase { 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) @@ -115,6 +152,7 @@ final class ContextualOnboardingPresenterTests: XCTestCase { } final class TabViewControllerMock: UIViewController, TabViewOnboardingDelegate { + var daxDialogsStackView: UIStackView = UIStackView() var webViewContainerView: UIView = UIView() var daxContextualOnboardingController: UIViewController? @@ -168,6 +206,10 @@ final class TabViewControllerMock: UIViewController, TabViewOnboardingDelegate { capturedURL = url } + func didAcknowledgeContextualOnboardingSearch() { + + } + } final class DaxContextualOnboardingControllerMock: UIViewController { diff --git a/DuckDuckGoTests/DaxDialogTests.swift b/DuckDuckGoTests/DaxDialogTests.swift index b2112efc89..a5fddd6f6a 100644 --- a/DuckDuckGoTests/DaxDialogTests.swift +++ b/DuckDuckGoTests/DaxDialogTests.swift @@ -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) @@ -97,7 +99,6 @@ final class DaxDialog: XCTestCase { } func testWhenEachVersionOfTrackersMessageIsShownThenFormattedCorrectlyAndNotShownAgain() { - mockVariantManager.isSupportedReturns = false 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), @@ -235,7 +236,6 @@ final class DaxDialog: XCTestCase { } func testWhenFirstTimeOnSearchPageThenShowSearchPageMessage() { - mockVariantManager.isSupportedReturns = false XCTAssertFalse(onboarding.shouldShowFireButtonPulse) XCTAssertEqual(DaxDialogs.BrowsingSpec.afterSearch, onboarding.nextBrowsingMessageIfShouldShow(for: makePrivacyInfo(url: URLs.ddg))) } @@ -570,6 +570,206 @@ final class DaxDialog: XCTestCase { 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.fireButtonEducationShownOrExpired = 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_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, .final) + } + private func detectedTrackerFrom(_ url: URL, pageUrl: String) -> DetectedRequest { let entity = entityProvider.entity(forHost: url.host!) return DetectedRequest(url: url.absoluteString, diff --git a/DuckDuckGoTests/DaxDialogsNewTabTests.swift b/DuckDuckGoTests/DaxDialogsNewTabTests.swift index a3c220b6bb..9127cd0c0c 100644 --- a/DuckDuckGoTests/DaxDialogsNewTabTests.swift +++ b/DuckDuckGoTests/DaxDialogsNewTabTests.swift @@ -164,6 +164,10 @@ final class DaxDialogsNewTabTests: XCTestCase { } class MockDaxDialogsSettings: DaxDialogsSettings { + var lastVisitedOnboardingWebsiteURLPath: String? + + var lastShownContextualOnboardingDialogType: String? + var isDismissed: Bool = false var homeScreenMessagesSeen: Int = 0 diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index dc512310b3..9b3b836ecb 100644 --- a/DuckDuckGoTests/MockTabDelegate.swift +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -32,7 +32,8 @@ final class MockTabDelegate: TabDelegate { private(set) var didRequestLoadURLCalled = false private(set) var capturedURL: URL? private(set) var didRequestFireButtonPulseCalled = false - private(set) var didRequestPrivacyDashboardButtonPulseCalled = false + private(set) var tabDidRequestPrivacyDashboardButtonPulseCalled = false + private(set) var privacyDashboardAnimated: Bool? func tabWillRequestNewTab(_ tab: DuckDuckGo.TabViewController) -> UIKeyModifierFlags? { nil } @@ -82,7 +83,8 @@ final class MockTabDelegate: TabDelegate { } func tabDidRequestPrivacyDashboardButtonPulse(tab: DuckDuckGo.TabViewController, animated: Bool) { - didRequestPrivacyDashboardButtonPulseCalled = true + tabDidRequestPrivacyDashboardButtonPulseCalled = true + privacyDashboardAnimated = animated } func tabDidRequestSearchBarRect(tab: DuckDuckGo.TabViewController) -> CGRect { .zero } diff --git a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift index 6359599c93..c20cbda965 100644 --- a/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift +++ b/DuckDuckGoTests/TabViewControllerDaxDialogTests.swift @@ -74,13 +74,13 @@ final class TabViewControllerDaxDialogTests: XCTestCase { func testWhenDidShowTrackersDialogIsCalledThenTabDidRequestPrivacyDashboardButtonPulseIsCalledOnDelegate() { // GIVEN - XCTAssertFalse(delegateMock.didRequestPrivacyDashboardButtonPulseCalled) + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) // WHEN sut.didShowContextualOnboardingTrackersDialog() // THEN - XCTAssertTrue(delegateMock.didRequestPrivacyDashboardButtonPulseCalled) + XCTAssertTrue(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) } func testWhenDidAcknowledgeTrackersDialogIsCalledThenTabDidRequestFireButtonPulseIsCalledOnDelegate() { @@ -116,12 +116,51 @@ final class TabViewControllerDaxDialogTests: XCTestCase { XCTAssertTrue(onboardingLogicMock.didCallSetFireEducationMessageSeen) } + func testWhenDidAcknowledgeContextualOnboardingSearchIsCalledThenSetSearchMessageSeenOnLogic() { + // GIVEN + XCTAssertFalse(onboardingLogicMock.didCallsetsetSearchMessageSeen) + + // WHEN + sut.didAcknowledgeContextualOnboardingSearch() + + // THEN + XCTAssertTrue(onboardingLogicMock.didCallsetsetSearchMessageSeen) + } + + func testWhenDidShowContextualOnboardingTrackersDialog_ShieldIconAnimationActivated() { + // GIVEN + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + XCTAssertNil(delegateMock.privacyDashboardAnimated) + + // WHEN + sut.didShowContextualOnboardingTrackersDialog() + + // THEN + XCTAssertTrue(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + XCTAssertTrue(delegateMock.privacyDashboardAnimated ?? false) + } + + func testOnPrivacyDashboardShown_ShieldIconAnimationRemoved() { + // GIVEN + XCTAssertFalse(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + XCTAssertNil(delegateMock.privacyDashboardAnimated) + + // WHEN + sut.showPrivacyDashboard() + + // THEN + XCTAssertTrue(delegateMock.tabDidRequestPrivacyDashboardButtonPulseCalled) + XCTAssertFalse(delegateMock.privacyDashboardAnimated ?? true) + } + } final class ContextualOnboardingLogicMock: ContextualOnboardingLogic { + var expectation: XCTestExpectation? private(set) var didCallSetFireEducationMessageSeen = false private(set) var didCallsetFinalOnboardingDialogSeen = false + private(set) var didCallsetsetSearchMessageSeen = false func setFireEducationMessageSeen() { didCallSetFireEducationMessageSeen = true @@ -132,4 +171,8 @@ final class ContextualOnboardingLogicMock: ContextualOnboardingLogic { expectation?.fulfill() } + func setSearchMessageSeen() { + didCallsetsetSearchMessageSeen = true + } + }