diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 82447c8abe..439d8e79ff 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -14498,7 +14498,7 @@ repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit"; requirement = { kind = exactVersion; - version = 200.2.1; + version = 200.3.0; }; }; 9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = { diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 95b2c0b4f6..4619f0d1e4 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,8 +32,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/BrowserServicesKit", "state" : { - "revision" : "fdf6f75d570a5ef6058efa881e11f9467627fbf4", - "version" : "200.2.1" + "revision" : "44d747d56bc73cb74de0e9d7127314ea30eca371", + "version" : "200.3.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "1ed569676555d493c9c5575eaed22aa02569aac9", - "version" : "6.19.0" + "revision" : "b74549bd869fdecc16fad851f2f608b1724764df", + "version" : "6.25.0" } }, { @@ -75,7 +75,7 @@ { "identity" : "lottie-spm", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", + "location" : "https://github.com/airbnb/lottie-spm", "state" : { "revision" : "1d29eccc24cc8b75bff9f6804155112c0ffc9605", "version" : "4.4.3" diff --git a/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Contents.json new file mode 100644 index 0000000000..e1afd964fa --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Success-96x96.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Success-96x96.svg b/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Success-96x96.svg new file mode 100644 index 0000000000..9c4fbc90c9 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/Success-96.imageset/Success-96x96.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 4388d6a568..d9ec142e79 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -472,10 +472,12 @@ struct UserText { static let importPasswords = NSLocalizedString("import.browser.data.passwords", value: "Import Passwords…", comment: "Opens Import Browser Data dialog") static let importDataTitle = NSLocalizedString("import.browser.data", value: "Import to DuckDuckGo", comment: "Import Browser Data dialog title") + static let importDataTitleOnboarding = NSLocalizedString("import.browser.data.onboarding", value: "Great, let’s keep this simple!", comment: "Import Browser Data dialog title") static let importDataShortcutsTitle = NSLocalizedString("import.browser.data.shortcuts", value: "Almost done!", comment: "Import Browser Data dialog title for final stage when choosing shortcuts to enable") static let importDataShortcutsSubtitle = NSLocalizedString("import.browser.data.shortcuts.subtitle", value: "You can always right-click on the browser toolbar to find more shortcuts like these.", comment: "Subtitle explaining how users can find toolbar shortcuts.") - static let importDataSourceTitle = NSLocalizedString("import.browser.data.source.title", value: "Import From", comment: "Import Browser Data title for option to choose source browser to import from") + static let importDataSourceTitle = NSLocalizedString("import.browser.data.source.title", value: "Where do you want to import from?", comment: "Import Browser Data title for option to choose source browser to import from") static let importDataSubtitle = NSLocalizedString("import.browser.data.source.subtitle", value: "Access and manage your passwords in DuckDuckGo Settings > Passwords & Autofill.", comment: "Subtitle explaining where users can find imported passwords.") + static let importDataSuccessTitle = NSLocalizedString("import.browser.data.success.title", value: "Import complete!", comment: "message about Passwords and or bookmarks Data Import completion") static let exportLogins = NSLocalizedString("export.logins.data", value: "Export Passwords…", comment: "Opens Export Logins Data dialog") static let exportBookmarks = NSLocalizedString("export.bookmarks.menu.item", value: "Export Bookmarks…", comment: "Export bookmarks menu item") diff --git a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift index c11c0868b0..3660f02777 100644 --- a/DuckDuckGo/DataImport/Model/DataImportViewModel.swift +++ b/DuckDuckGo/DataImport/Model/DataImportViewModel.swift @@ -540,6 +540,10 @@ extension DataImportViewModel { return DataImportViewSummarizedError(errors: errors) } + var hasAnySummaryError: Bool { + !summary.allSatisfy { $0.result.isSuccess } + } + private static func requestPrimaryPasswordCallback(_ source: DataImport.Source) -> String? { let alert = NSAlert.passwordRequiredAlert(source: source) let response = alert.runModal() diff --git a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift index 7dbb85d3c5..118d2ba3d9 100644 --- a/DuckDuckGo/DataImport/View/DataImportTypePicker.swift +++ b/DuckDuckGo/DataImport/View/DataImportTypePicker.swift @@ -28,7 +28,7 @@ struct DataImportTypePicker: View { var body: some View { VStack(alignment: .leading) { - Text("Select Data to Import:", + Text("What do you want to import?", comment: "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.") ForEach(DataImport.DataType.allCases, id: \.self) { dataType in diff --git a/DuckDuckGo/DataImport/View/DataImportView.swift b/DuckDuckGo/DataImport/View/DataImportView.swift index dee9ecc021..c3757d7a41 100644 --- a/DuckDuckGo/DataImport/View/DataImportView.swift +++ b/DuckDuckGo/DataImport/View/DataImportView.swift @@ -24,7 +24,13 @@ struct DataImportView: ModalView { @Environment(\.dismiss) private var dismiss - @State var model = DataImportViewModel() + @State var model: DataImportViewModel + let title: String + + init(model: DataImportViewModel = DataImportViewModel(), title: String = UserText.importDataTitle) { + self._model = State(initialValue: model) + self.title = title + } struct ProgressState { let text: String? @@ -76,14 +82,27 @@ struct DataImportView: ModalView { } private func viewHeader() -> some View { - VStack(alignment: .leading, spacing: 0) { - if case .shortcuts = model.screen { + return VStack(alignment: .leading, spacing: 0) { + // If there are no errors show summary success header + if case .summary = model.screen, !model.hasAnySummaryError { + VStack(alignment: .leading) { + Image(.success96) + Text(UserText.importDataSuccessTitle) + .foregroundColor(.primary) + .font(.system(size: 17, weight: .bold)) + } + .padding(.bottom, 16) + } else if case .shortcuts = model.screen { Text(UserText.importDataShortcutsTitle) .font(.title2.weight(.semibold)) .padding(.bottom, 24) } else { - Text(UserText.importDataTitle) + // If screen is not the first screen where the user choose the type of import they want to do show the generic title. + // Otherwise show the injected title. + let title = model.screen == .profileAndDataTypesPicker ? self.title : UserText.importDataTitle + + Text(title) .font(.title2.weight(.semibold)) .padding(.bottom, 24) diff --git a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift index 11fa3c64d5..a14221a965 100644 --- a/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift +++ b/DuckDuckGo/FeatureFlagging/Model/FeatureFlag.swift @@ -32,7 +32,7 @@ public enum FeatureFlag: String { // https://app.asana.com/0/1206488453854252/1207136666798700/f case freemiumPIR - case highlightsOnboarding + case contextualOnboarding // https://app.asana.com/0/1201462886803403/1208030658792310/f case unknownUsernameCategorization @@ -60,8 +60,8 @@ extension FeatureFlag: FeatureFlagSourceProviding { return .remoteReleasable(.subfeature(PhishingDetectionSubfeature.allowErrorPage)) case .phishingDetectionPreferences: return .remoteReleasable(.subfeature(PhishingDetectionSubfeature.allowPreferencesToggle)) - case .highlightsOnboarding: - return .internalOnly + case .contextualOnboarding: + return .remoteReleasable(.feature(.contextualOnboarding)) case .credentialsImportPromotionForExistingUsers: return .remoteReleasable(.subfeature(AutofillSubfeature.credentialsImportPromotionForExistingUsers)) case .networkProtectionUserTips: diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 9ac1b9af4d..918c1bea94 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -28231,6 +28231,66 @@ } } }, + "import.browser.data.onboarding" : { + "comment" : "Import Browser Data dialog title", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Super, halten wir es unkompliziert!" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Great, let’s keep this simple!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Estupendo, hagámoslo de manera sencilla!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Super, restons simples !" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ottimo, semplifichiamo le cose!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geweldig, laten we dit eenvoudig houden!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Świetnie, to proste!" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ótimo, vamos manter isto simples!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отлично! Зачем усложнять?" + } + } + } + }, "import.browser.data.passwords" : { "comment" : "Opens Import Browser Data dialog", "extractionState" : "extracted_with_value", @@ -28478,55 +28538,115 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Importieren aus" + "value" : "Woher möchtest du importieren?" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Where do you want to import from?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿De dónde quieres importar?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "D'où voulez-vous importer ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Da dove vuoi importare?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vanwaar wil je importeren?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skąd chcesz importować?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "De onde queres importar?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откуда импортировать данные?" + } + } + } + }, + "import.browser.data.success.title" : { + "comment" : "message about Passwords and or bookmarks Data Import completion", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import abgeschlossen!" } }, "en" : { "stringUnit" : { "state" : "new", - "value" : "Import From" + "value" : "Import complete!" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Importar desde" + "value" : "¡Importación completada!" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Importer depuis" + "value" : "Importation terminée !" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Importa da" + "value" : "Importazione completata!" } }, "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Importeren vanuit" + "value" : "Importeren voltooid!" } }, "pl" : { "stringUnit" : { "state" : "translated", - "value" : "Importuj z" + "value" : "Import zakończony!" } }, "pt" : { "stringUnit" : { "state" : "translated", - "value" : "Importar de" + "value" : "Importação concluída!" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Импорт из" + "value" : "Импортирование завершено." } } } @@ -55563,6 +55683,7 @@ }, "Select Data to Import:" : { "comment" : "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.", + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -62370,6 +62491,59 @@ } } }, + "What do you want to import?" : { + "comment" : "Data Import section title for checkboxes of data type to import: Passwords or Bookmarks.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was möchtest du importieren?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Qué quieres importar?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Que voulez-vous importer ?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cosa vuoi importare?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wat wil je importeren?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Co chcesz importować?" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "O que queres importar?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что будем импортировать?" + } + } + } + }, "whats.new.menu.item" : { "comment" : "Title of the dialog menu item that opens the 'What's New' page", "extractionState" : "extracted_with_value", diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift index e2f79ae7d4..71bd9dc48a 100644 --- a/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachine.swift @@ -28,6 +28,7 @@ protocol ContextualOnboardingStateUpdater: AnyObject { func updateStateFor(tab: Tab) func gotItPressed() func fireButtonUsed() + func turnOffFeature() } protocol FireButtonInfoStateProviding { @@ -381,4 +382,8 @@ final class ContextualOnboardingStateMachine: ContextualOnboardingDialogTypeProv break } } + + func turnOffFeature() { + state = .onboardingCompleted + } } diff --git a/DuckDuckGo/Onboarding/ContextualOnboarding/ViewHighlighter/ContextualOnboardingViewHighlighter.swift b/DuckDuckGo/Onboarding/ContextualOnboarding/ViewHighlighter/ContextualOnboardingViewHighlighter.swift new file mode 100644 index 0000000000..1ce885facc --- /dev/null +++ b/DuckDuckGo/Onboarding/ContextualOnboarding/ViewHighlighter/ContextualOnboardingViewHighlighter.swift @@ -0,0 +1,81 @@ +// +// ContextualOnboardingViewHighlighter.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Lottie + +enum ContextualOnboardingViewHighlighter { + + private static let identifier = "lottie_pulse_animation_view" + + static func highlight(view: NSView, inParent parent: NSView) { + // Avoid duplicate animations + guard !isViewHighlighted(view) else { return } + + let animationView = LottieAnimationView.makePulseAnimationView() + animationView.identifier = NSUserInterfaceItemIdentifier(identifier) + let multiplier = 2.5 + parent.addSubview(animationView) + + NSLayoutConstraint.activate([ + animationView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + animationView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + animationView.widthAnchor.constraint(equalTo: view.widthAnchor, multiplier: multiplier), + animationView.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: multiplier), + ]) + } + + static func stopHighlighting(view: NSView) { + guard let view = findViewWithIdentifier(in: view, identifier: NSUserInterfaceItemIdentifier(identifier)) else { return } + view.removeFromSuperview() + } + + static func isViewHighlighted(_ view: NSView) -> Bool { + findViewWithIdentifier(in: view, identifier: NSUserInterfaceItemIdentifier(identifier)) != nil + } + + private static func findViewWithIdentifier(in view: NSView, identifier: NSUserInterfaceItemIdentifier) -> NSView? { + // Check the current view's superview + // If no matching view is found, return nil + guard let superview = view.superview else { return nil } + + // Check all subviews of the superview + for subview in superview.subviews where subview.identifier == identifier { + return subview + } + + // Recursively check the superview's superview + return findViewWithIdentifier(in: superview, identifier: identifier) + + } + +} + +extension LottieAnimationView { + + static func makePulseAnimationView() -> LottieAnimationView { + let animation = LottieAnimation.named("view_highlight") + let animationView = LottieAnimationView(animation: animation) + animationView.contentMode = .scaleToFill + animationView.loopMode = .loop + animationView.play() + animationView.translatesAutoresizingMaskIntoConstraints = false + return animationView + } + +} diff --git a/DuckDuckGo/Onboarding/OnboardingActionsManager.swift b/DuckDuckGo/Onboarding/OnboardingActionsManager.swift index 22321ea4cc..1ba94165b9 100644 --- a/DuckDuckGo/Onboarding/OnboardingActionsManager.swift +++ b/DuckDuckGo/Onboarding/OnboardingActionsManager.swift @@ -23,12 +23,11 @@ import Common import os.log enum OnboardingSteps: String, CaseIterable { - case summary case welcome case getStarted - case privateByDefault - case cleanerBrowsing + case makeDefaultSingle case systemSettings + case duckPlayerSingle case customize } @@ -91,20 +90,23 @@ final class OnboardingActionsManager: OnboardingActionsManaging { let configuration: OnboardingConfiguration = { var systemSettings: SystemSettings + var order = "v3" + let platform = OnboardingPlatform(name: "macos") #if APPSTORE - systemSettings = SystemSettings(rows: ["import", "default-browser"]) + systemSettings = SystemSettings(rows: ["import"]) #else - systemSettings = SystemSettings(rows: ["dock", "import", "default-browser"]) + systemSettings = SystemSettings(rows: ["dock", "import"]) #endif let stepDefinitions = StepDefinitions(systemSettings: systemSettings) let preferredLocale = Bundle.main.preferredLocalizations.first ?? "en" var env: String -#if DEBUG +#if DEBUG || REVIEW env = "development" #else env = "production" #endif - return OnboardingConfiguration(stepDefinitions: stepDefinitions, env: env, locale: preferredLocale) + + return OnboardingConfiguration(stepDefinitions: stepDefinitions, exclude: [], order: order, env: env, locale: preferredLocale, platform: platform) }() init(navigationDelegate: OnboardingNavigating, dockCustomization: DockCustomization, defaultBrowserProvider: DefaultBrowserProvider, appearancePreferences: AppearancePreferences, startupPreferences: StartupPreferences) { @@ -121,6 +123,7 @@ final class OnboardingActionsManager: OnboardingActionsManaging { @MainActor func goToAddressBar() { + PixelKit.fire(GeneralPixel.onboardingStepCompleteCustomize, frequency: .legacyDaily) onboardingHasFinished() let tab = Tab(content: .url(URL.duckDuckGo, source: .ui)) navigation.replaceTabWith(tab) @@ -178,18 +181,16 @@ final class OnboardingActionsManager: OnboardingActionsManaging { func stepCompleted(step: OnboardingSteps) { switch step { - case .summary: - break case .welcome: PixelKit.fire(GeneralPixel.onboardingStepCompleteWelcome, frequency: .legacyDaily) case .getStarted: PixelKit.fire(GeneralPixel.onboardingStepCompleteGetStarted, frequency: .legacyDaily) - case .privateByDefault: + case .makeDefaultSingle: PixelKit.fire(GeneralPixel.onboardingStepCompletePrivateByDefault, frequency: .legacyDaily) - case .cleanerBrowsing: - PixelKit.fire(GeneralPixel.onboardingStepCompleteCleanerBrowsing, frequency: .legacyDaily) case .systemSettings: PixelKit.fire(GeneralPixel.onboardingStepCompleteSystemSettings, frequency: .legacyDaily) + case .duckPlayerSingle: + PixelKit.fire(GeneralPixel.onboardingStepCompleteCleanerBrowsing, frequency: .legacyDaily) case .customize: PixelKit.fire(GeneralPixel.onboardingStepCompleteCustomize, frequency: .legacyDaily) } diff --git a/DuckDuckGo/Onboarding/OnboardingConfiguration.swift b/DuckDuckGo/Onboarding/OnboardingConfiguration.swift index 92019506fc..9477fe183c 100644 --- a/DuckDuckGo/Onboarding/OnboardingConfiguration.swift +++ b/DuckDuckGo/Onboarding/OnboardingConfiguration.swift @@ -21,8 +21,11 @@ import Foundation /// Configuration needed to set up the FE onboarding struct OnboardingConfiguration: Codable, Equatable { var stepDefinitions: StepDefinitions + var exclude: [String] + var order: String var env: String var locale: String + var platform: OnboardingPlatform } /// Defines the onboarding steps desired @@ -33,3 +36,7 @@ struct StepDefinitions: Codable, Equatable { struct SystemSettings: Codable, Equatable { var rows: [String] } + +struct OnboardingPlatform: Codable, Equatable { + var name: String +} diff --git a/DuckDuckGo/Statistics/Experiment/PixelExperiment.swift b/DuckDuckGo/Statistics/Experiment/PixelExperiment.swift index 04465aa90f..b23bc40d9b 100644 --- a/DuckDuckGo/Statistics/Experiment/PixelExperiment.swift +++ b/DuckDuckGo/Statistics/Experiment/PixelExperiment.swift @@ -60,8 +60,8 @@ enum PixelExperiment: String, CaseIterable { // These are the variants. Rename or add/remove them as needed. If you change the string value // remember to keep it clear for privacy triage. - case control = "oa" - case newOnboarding = "ob" + case control = "oc" + case newOnboarding = "od" } // These functions contain the business logic for determining if the pixel should be fired or not. diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index 4f3867b84e..af5691c907 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -776,6 +776,7 @@ protocol NewWindowPolicyDecisionMaker { } } + @MainActor func startOnboarding() { userInteractionDialog = nil @@ -787,6 +788,7 @@ protocol NewWindowPolicyDecisionMaker { #endif if PixelExperiment.cohort == .newOnboarding { + Application.appDelegate.onboardingStateMachine.state = .notStarted setContent(.onboarding) } else { setContent(.onboardingDeprecated) diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 7b4084ecaa..a400a683ce 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -407,6 +407,10 @@ final class BrowserTabViewController: NSViewController { } private func updateStateAndPresentContextualOnboarding() { + guard featureFlagger.isFeatureOn(.contextualOnboarding) else { + onboardingDialogTypeProvider.turnOffFeature() + return + } guard let tab = tabViewModel?.tab else { return } onboardingDialogTypeProvider.updateStateFor(tab: tab) presentContextualOnboarding() @@ -425,7 +429,10 @@ final class BrowserTabViewController: NSViewController { // Remove any existing higlights animation delegate?.dismissViewHighlight() - guard featureFlagger.isFeatureOn(.highlightsOnboarding) else { return } + guard featureFlagger.isFeatureOn(.contextualOnboarding) else { + onboardingDialogTypeProvider.turnOffFeature() + return + } guard let tab = tabViewModel?.tab else { return } guard let dialogType = onboardingDialogTypeProvider.dialogTypeForTab(tab, privacyInfo: tab.privacyInfo) else { diff --git a/DuckDuckGo/Windows/View/WindowControllersManager.swift b/DuckDuckGo/Windows/View/WindowControllersManager.swift index 3bfaa6dfcd..90e8f59ec7 100644 --- a/DuckDuckGo/Windows/View/WindowControllersManager.swift +++ b/DuckDuckGo/Windows/View/WindowControllersManager.swift @@ -354,7 +354,7 @@ extension WindowControllersManager: OnboardingNavigating { @MainActor func showImportDataView() { - DataImportView().show() + DataImportView(title: UserText.importDataTitleOnboarding).show() } @MainActor diff --git a/LocalPackages/DataBrokerProtection/Package.swift b/LocalPackages/DataBrokerProtection/Package.swift index bbd5ac6cbc..c640e98947 100644 --- a/LocalPackages/DataBrokerProtection/Package.swift +++ b/LocalPackages/DataBrokerProtection/Package.swift @@ -29,7 +29,7 @@ let package = Package( targets: ["DataBrokerProtection"]) ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.2.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.3.0"), .package(path: "../SwiftUIExtensions"), .package(path: "../XPCHelper"), ], diff --git a/LocalPackages/NetworkProtectionMac/Package.swift b/LocalPackages/NetworkProtectionMac/Package.swift index 033d1ff14a..a187eb6657 100644 --- a/LocalPackages/NetworkProtectionMac/Package.swift +++ b/LocalPackages/NetworkProtectionMac/Package.swift @@ -32,7 +32,7 @@ let package = Package( .library(name: "VPNAppLauncher", targets: ["VPNAppLauncher"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.2.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.3.0"), .package(url: "https://github.com/airbnb/lottie-spm", exact: "4.4.3"), .package(path: "../AppLauncher"), .package(path: "../UDSHelper"), diff --git a/LocalPackages/SubscriptionUI/Package.swift b/LocalPackages/SubscriptionUI/Package.swift index 72475dd736..6d8d57bc35 100644 --- a/LocalPackages/SubscriptionUI/Package.swift +++ b/LocalPackages/SubscriptionUI/Package.swift @@ -12,7 +12,7 @@ let package = Package( targets: ["SubscriptionUI"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.2.1"), + .package(url: "https://github.com/duckduckgo/BrowserServicesKit", exact: "200.3.0"), .package(path: "../SwiftUIExtensions") ], targets: [ diff --git a/UnitTests/DataImport/DataImportViewModelTests.swift b/UnitTests/DataImport/DataImportViewModelTests.swift index 37ff6db51a..0e245b9015 100644 --- a/UnitTests/DataImport/DataImportViewModelTests.swift +++ b/UnitTests/DataImport/DataImportViewModelTests.swift @@ -103,6 +103,46 @@ final class DataImportViewModelTests: XCTestCase { await fulfillment(of: [e2, eDismissed], timeout: 0) } + func testWhenImportSummaryCompletesWithErrorsThenHasSummaryErrorsShouldReturnTrue() async throws { + // GIVEN + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + // GIVEN + let browser = try XCTUnwrap(ThirdPartyBrowser.browser(for: source)) + for dataType in DataType.allCases { + setupModel(with: source, profiles: [BrowserProfile.test]) + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + dataType: .failure(Failure(.bookmarks, DataImport.ErrorType.dataCorrupted)) + ]) + + // WHEN + let result = model.hasAnySummaryError + + // THEN + XCTAssertTrue(result) + } + } + } + + func testWhenImportSummaryCompletesWithoutErrorsThenHasSummaryErrorsShouldReturnFalse() async throws { + // GIVEN + for source in Source.allCases where source.initialScreen == .profileAndDataTypesPicker { + // GIVEN + let browser = try XCTUnwrap(ThirdPartyBrowser.browser(for: source)) + for dataType in DataType.allCases { + setupModel(with: source, profiles: [BrowserProfile.test, BrowserProfile.default, BrowserProfile.test2]) + try await initiateImport(of: source.supportedDataTypes, from: .test(for: browser), resultingWith: [ + dataType: .success(.init(successful: 10, duplicate: 0, failed: 1)) + ]) + + // WHEN + let result = model.hasAnySummaryError + + // THEN + XCTAssertFalse(result) + } + } + } + // MARK: - Browser profiles func testWhenNoProfilesAreLoaded_selectedProfileIsNil() { diff --git a/UnitTests/Fire/Model/FirePopoverViewModelTests.swift b/UnitTests/Fire/Model/FirePopoverViewModelTests.swift index 281be427b8..79a155ba0d 100644 --- a/UnitTests/Fire/Model/FirePopoverViewModelTests.swift +++ b/UnitTests/Fire/Model/FirePopoverViewModelTests.swift @@ -64,6 +64,7 @@ final class FirePopoverViewModelTests: XCTestCase { } class CapturingContextualOnboardingStateUpdater: ContextualOnboardingStateUpdater { + var state: ContextualOnboardingState = .onboardingCompleted var updatedForTab: Tab? @@ -82,4 +83,6 @@ class CapturingContextualOnboardingStateUpdater: ContextualOnboardingStateUpdate fireButtonUsedCalled = true } + func turnOffFeature() {} + } diff --git a/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift b/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift index c066e635b8..e94c5879dd 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/BrowserTabViewControllerOnboardingTests.swift @@ -29,20 +29,23 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { var viewController: BrowserTabViewController! var dialogProvider: MockDialogsProvider! var factory: CapturingDialogFactory! + var featureFlagger: MockFeatureFlagger! var tab: Tab! var cancellables: Set = [] var expectation: XCTestExpectation! + var dialogTypeForTabExpectation: XCTestExpectation! @MainActor override func setUpWithError() throws { try super.setUpWithError() let tabCollectionViewModel = TabCollectionViewModel() + featureFlagger = MockFeatureFlagger() dialogProvider = MockDialogsProvider() expectation = .init() factory = CapturingDialogFactory(expectation: expectation) tab = Tab() tab.setContent(.url(URL.duckDuckGo, credential: nil, source: .appOpenUrl)) let tabViewModel = TabViewModel(tab: tab) - viewController = BrowserTabViewController(tabCollectionViewModel: tabCollectionViewModel, onboardingDialogTypeProvider: dialogProvider, onboardingDialogFactory: factory, featureFlagger: MockFeatureFlagger()) + viewController = BrowserTabViewController(tabCollectionViewModel: tabCollectionViewModel, onboardingDialogTypeProvider: dialogProvider, onboardingDialogFactory: factory, featureFlagger: featureFlagger) viewController.tabViewModel = tabViewModel let window = NSWindow() window.contentViewController = viewController @@ -56,9 +59,20 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { viewController = nil cancellables = [] expectation = nil + featureFlagger = nil try super.tearDownWithError() } + func testWhenNavigationCompletedAndFeatureIsOffThenTurnOffFeature() throws { + featureFlagger.isFeatureOn = false + let expectation = self.expectation(description: "Wait for turnOffFeatureCalled to be called") + dialogProvider.turnOffFeatureCalledExpectation = expectation + + tab.navigateTo(url: URL(string: "some.url")!) + + wait(for: [expectation], timeout: 3.0) + } + func testWhenNavigationCompletedAndNoDialogTypeThenOnlyWebViewVisible() throws { let expectation = self.expectation(description: "Wait for webViewDidFinishNavigationPublisher to emit") tab.navigateTo(url: URL(string: "some.url")!) @@ -251,7 +265,9 @@ final class BrowserTabViewControllerOnboardingTests: XCTestCase { } class MockDialogsProvider: ContextualOnboardingDialogTypeProviding, ContextualOnboardingStateUpdater { + var state: ContextualOnboardingState = .onboardingCompleted + var turnOffFeatureCalledExpectation: XCTestExpectation? func updateStateFor(tab: DuckDuckGo_Privacy_Browser.Tab) {} @@ -267,6 +283,10 @@ class MockDialogsProvider: ContextualOnboardingDialogTypeProviding, ContextualOn func gotItPressed() {} func fireButtonUsed() {} + + func turnOffFeature() { + turnOffFeatureCalledExpectation?.fulfill() + } } class CapturingDialogFactory: ContextualDaxDialogsFactory { diff --git a/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift index 178782543a..82cce0838c 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingStateMachineTests.swift @@ -673,6 +673,17 @@ class ContextualOnboardingStateMachineTests: XCTestCase { // Then XCTAssertEqual(stateMachine.state, .showBlockedTrackers) } + + func test_OnTurnOffFeature_StateBecomesOnboardingCompleted() { + // Given + stateMachine.state = .showTryASearch + + // When + stateMachine.turnOffFeature() + + // Then + XCTAssertEqual(stateMachine.state, .onboardingCompleted) + } } class MockTrackerMessageProvider: TrackerMessageProviding { diff --git a/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingViewHighlighterTests.swift b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingViewHighlighterTests.swift new file mode 100644 index 0000000000..79778aee4b --- /dev/null +++ b/UnitTests/Onboarding/ContextualOnboarding/ContextualOnboardingViewHighlighterTests.swift @@ -0,0 +1,105 @@ +// +// ContextualOnboardingViewHighlighterTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class ContextualOnboardingViewHighlighterTests: XCTestCase { + + func testWhenHighlightViewIsCalledThenViewShouldContainAnimationView() { + // GIVEN + let (parent, child) = makeDummyViews() + + // WHEN + ContextualOnboardingViewHighlighter.highlight(view: child, inParent: parent) + + // THEN + XCTAssertNotNil(findFirstAnimationView(in: parent)) + } + + func testWhenIsViewIsHighligthedIsCalledAndViewIsHighlightedThenReturnTrue() { + // GIVEN + let (parent, child) = makeDummyViews() + XCTAssertFalse(ContextualOnboardingViewHighlighter.isViewHighlighted(child)) + ContextualOnboardingViewHighlighter.highlight(view: child, inParent: parent) + + // WHEN + let result = ContextualOnboardingViewHighlighter.isViewHighlighted(child) + + // THEN + XCTAssertTrue(result) + } + + func testWhenIsViewIsNotHighligthedIsCalledAndViewIsHighlightedThenReturnFalse() { + // GIVEN + let (_, child) = makeDummyViews() + XCTAssertFalse(ContextualOnboardingViewHighlighter.isViewHighlighted(child)) + + // WHEN + let result = ContextualOnboardingViewHighlighter.isViewHighlighted(child) + + // THEN + XCTAssertFalse(result) + } + + func testWhenStopHighlightingViewIsCalledThenAnimationViewIsRemoved() { + // GIVEN + let (parent, child) = makeDummyViews() + ContextualOnboardingViewHighlighter.highlight(view: child, inParent: parent) + XCTAssertNotNil(findAllAnimationView(in: parent)) + + // WHEN + ContextualOnboardingViewHighlighter.stopHighlighting(view: child) + + // THEN + XCTAssertNil(findFirstAnimationView(in: parent)) + } + + func testWhenViewIsHighlightedAndHighlightViewIsCalledThenNothingHappens() { + // GIVEN + let (parent, child) = makeDummyViews() + ContextualOnboardingViewHighlighter.highlight(view: child, inParent: parent) + XCTAssertEqual(findAllAnimationView(in: parent).count, 1) + + // WHEN + ContextualOnboardingViewHighlighter.highlight(view: child, inParent: parent) + + // THEN + XCTAssertEqual(findAllAnimationView(in: parent).count, 1) + } + +} + +private extension ContextualOnboardingViewHighlighterTests { + + func makeDummyViews() -> (parent: NSView, child: NSView) { + let parent = NSView() + let child = NSView() + parent.addSubview(child) + return (parent, child) + } + + func findAllAnimationView(in view: NSView) -> [NSView] { + view.subviews.filter { $0.identifier == NSUserInterfaceItemIdentifier("lottie_pulse_animation_view") } + } + + func findFirstAnimationView(in view: NSView) -> NSView? { + findAllAnimationView(in: view).first + } + +} diff --git a/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift b/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift index 86465a723c..9df50c7f24 100644 --- a/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift +++ b/UnitTests/Onboarding/ContextualOnboarding/OnboardingPixelReporterTests.swift @@ -149,6 +149,7 @@ final class OnboardingPixelReporterTests: XCTestCase { } class MockContextualOnboardingState: ContextualOnboardingStateUpdater { + var state: ContextualOnboardingState = .onboardingCompleted func updateStateFor(tab: Tab) { @@ -160,4 +161,6 @@ class MockContextualOnboardingState: ContextualOnboardingStateUpdater { func fireButtonUsed() { } + func turnOffFeature() {} + } diff --git a/UnitTests/Onboarding/Mocks/CapturingOnboardingActionsManager.swift b/UnitTests/Onboarding/Mocks/CapturingOnboardingActionsManager.swift index 78eaf12eba..afe4a93bb6 100644 --- a/UnitTests/Onboarding/Mocks/CapturingOnboardingActionsManager.swift +++ b/UnitTests/Onboarding/Mocks/CapturingOnboardingActionsManager.swift @@ -21,7 +21,14 @@ import Foundation class CapturingOnboardingActionsManager: OnboardingActionsManaging { - var configuration: OnboardingConfiguration = OnboardingConfiguration(stepDefinitions: StepDefinitions(systemSettings: SystemSettings(rows: [])), env: "environment", locale: "en") + var configuration: OnboardingConfiguration = OnboardingConfiguration( + stepDefinitions: StepDefinitions(systemSettings: SystemSettings(rows: [])), + exclude: [], + order: "", + env: "environment", + locale: "en", + platform: .init(name: "") + ) var goToAddressBarCalled = false var goToSettingsCalled = false diff --git a/UnitTests/Onboarding/OnboardingManagerTests.swift b/UnitTests/Onboarding/OnboardingManagerTests.swift index 785588ce7d..0d5bdad199 100644 --- a/UnitTests/Onboarding/OnboardingManagerTests.swift +++ b/UnitTests/Onboarding/OnboardingManagerTests.swift @@ -57,12 +57,19 @@ class OnboardingManagerTests: XCTestCase { // Given var systemSettings: SystemSettings #if APPSTORE - systemSettings = SystemSettings(rows: ["import", "default-browser"]) + systemSettings = SystemSettings(rows: ["import"]) #else - systemSettings = SystemSettings(rows: ["dock", "import", "default-browser"]) + systemSettings = SystemSettings(rows: ["dock", "import"]) #endif let stepDefinitions = StepDefinitions(systemSettings: systemSettings) - let expectedConfig = OnboardingConfiguration(stepDefinitions: stepDefinitions, env: "development", locale: "en") + let expectedConfig = OnboardingConfiguration( + stepDefinitions: stepDefinitions, + exclude: [], + order: "v3", + env: "development", + locale: "en", + platform: .init(name: "macos") + ) // Then XCTAssertEqual(manager.configuration, expectedConfig) diff --git a/UnitTests/TabExtensionsTests/ErrorPageTabExtensionTest.swift b/UnitTests/TabExtensionsTests/ErrorPageTabExtensionTest.swift index e4fad9262e..ffa3b7d9c6 100644 --- a/UnitTests/TabExtensionsTests/ErrorPageTabExtensionTest.swift +++ b/UnitTests/TabExtensionsTests/ErrorPageTabExtensionTest.swift @@ -480,7 +480,8 @@ class ChallangeSender: URLAuthenticationChallengeSender { } class MockFeatureFlagger: FeatureFlagger { + var isFeatureOn = true func isFeatureOn(forProvider: F) -> Bool where F: BrowserServicesKit.FeatureFlagSourceProviding { - return true + return isFeatureOn } }