From f21203d1b472d0e7fe23f1a9afcda572d988c2cc Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 4 Nov 2024 13:58:12 +0000 Subject: [PATCH] Add AI Chat toolbar (#3470) Task/Issue URL: https://app.asana.com/0/1204167627774280/1208607376873996/f **Description**: Add AI Chat shortcuts --- DuckDuckGo.xcodeproj/project.pbxproj | 94 ++++++++ DuckDuckGo/AIChat/AIChatDebugMenu.swift | 45 ++++ .../AIChatMenuVisibilityConfigurable.swift | 68 +++++- .../AIChat/AIChatPreferencesStorage.swift | 60 +++-- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 99 +++++++++ DuckDuckGo/AIChat/AIChatTabOpener.swift | 2 +- .../Onboarding/AIChatOnboardingPopover.swift | 44 ++++ .../AIChatToolBarPopUpOnboardingView.swift | 76 +++++++ ...ToolBarPopUpOnboardingViewController.swift | 38 ++++ ...IChatToolBarPopUpOnboardingViewModel.swift | 42 ++++ .../AutofillToolbarOnboardingPopover.swift | 44 ++++ .../AutofillToolbarOnboardingView.swift | 68 ++++++ ...ofillToolbarOnboardingViewController.swift | 38 ++++ .../AutofillToolbarOnboardingViewModel.swift | 33 +++ .../Prompt/BookmarksBarPromptPopover.swift | 17 +- DuckDuckGo/Common/Localizables/UserText.swift | 20 +- DuckDuckGo/Menus/MainMenu.swift | 17 +- DuckDuckGo/Menus/MainMenuActions.swift | 1 + .../NavigationBar/View/MoreOptionsMenu.swift | 1 + .../View/NavigationBarPopovers.swift | 50 +++++ .../View/NavigationBarViewController.swift | 96 +++++--- .../Preferences/Model/AIChatPreferences.swift | 4 + .../Model/PreferencesSection.swift | 9 +- .../Model/PreferencesSidebarModel.swift | 24 +- .../Preferences/View/PreferencesAIChat.swift | 18 +- .../View/PreferencesViewController.swift | 6 +- DuckDuckGo/Statistics/GeneralPixel.swift | 21 ++ DuckDuckGo/Tab/Model/Tab+Navigation.swift | 3 + .../AIChatOnboardingTabExtension.swift | 104 +++++++++ .../Tab/TabExtensions/TabExtensions.swift | 6 + .../AIChat/AIChatMenuConfigurationTests.swift | 135 ++++++++++- .../AIChat/AIChatRemoteSettingsTests.swift | 209 ++++++++++++++++++ UnitTests/Menus/MainMenuTests.swift | 8 +- .../PreferencesSidebarModelTests.swift | 2 +- .../AIChatOnboardingTabExtensionTests.swift | 165 ++++++++++++++ 35 files changed, 1580 insertions(+), 87 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatDebugMenu.swift create mode 100644 DuckDuckGo/AIChat/AIChatRemoteSettings.swift create mode 100644 DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift create mode 100644 DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift create mode 100644 DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift create mode 100644 DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift create mode 100644 DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingPopover.swift create mode 100644 DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingView.swift create mode 100644 DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewController.swift create mode 100644 DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewModel.swift create mode 100644 DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift create mode 100644 UnitTests/AIChat/AIChatRemoteSettingsTests.swift create mode 100644 UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 53d7bd4185..731efbd4e7 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -220,12 +220,26 @@ 1EEB2D7B2C986A40000D908B /* SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EEB2D792C986A40000D908B /* SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift */; }; 1EFA1A072C7C7F0E0099F508 /* PrivacyProPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F188267F2BBEB58100D9AC4F /* PrivacyProPixel.swift */; }; 1EFA1A082C7C7F0F0099F508 /* PrivacyProPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F188267F2BBEB58100D9AC4F /* PrivacyProPixel.swift */; }; + 31031EB72CC94C6E00684340 /* AIChatRemoteSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB62CC94C6C00684340 /* AIChatRemoteSettingsTests.swift */; }; + 31031EB82CC94C6E00684340 /* AIChatRemoteSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB62CC94C6C00684340 /* AIChatRemoteSettingsTests.swift */; }; + 31031EBA2CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB92CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift */; }; + 31031EBB2CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB92CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift */; }; 310E79BF294A19A8007C49E8 /* FireproofingReferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E79BE294A19A8007C49E8 /* FireproofingReferenceTests.swift */; }; 311B262728E73E0A00FD181A /* TabShadowConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311B262628E73E0A00FD181A /* TabShadowConfig.swift */; }; 31267C692B640C4200FEF811 /* DataBrokerProtectionFeatureGatekeeper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C5FFB82AF64D120008A79F /* DataBrokerProtectionFeatureGatekeeper.swift */; }; 31267C6A2B640C4B00FEF811 /* DataBrokerProtectionFeatureDisabler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199C6F82AF94F5B002A7BA1 /* DataBrokerProtectionFeatureDisabler.swift */; }; 31267C6B2B640C5200FEF811 /* DataBrokerProtectionAppEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199C6FC2AF97367002A7BA1 /* DataBrokerProtectionAppEvents.swift */; }; 3129788A2B64131200B67619 /* DataBrokerProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 312978892B64131200B67619 /* DataBrokerProtection */; }; + 3148723A2CC64A5F00EEF89B /* AIChatToolBarPopUpOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */; }; + 3148723B2CC64A5F00EEF89B /* AIChatToolBarPopUpOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */; }; + 314872742CC653D500EEF89B /* AIChatOnboardingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872732CC653C700EEF89B /* AIChatOnboardingPopover.swift */; }; + 314872752CC653D500EEF89B /* AIChatOnboardingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872732CC653C700EEF89B /* AIChatOnboardingPopover.swift */; }; + 314872782CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */; }; + 314872792CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */; }; + 3148727B2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */; }; + 3148727C2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */; }; + 3148727E2CC68F6900EEF89B /* AIChatOnboardingTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148727D2CC68F6200EEF89B /* AIChatOnboardingTabExtension.swift */; }; + 3148727F2CC68F6900EEF89B /* AIChatOnboardingTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3148727D2CC68F6200EEF89B /* AIChatOnboardingTabExtension.swift */; }; 31521AC02CC013AD00248E6F /* AIChatMenuVisibilityConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */; }; 31521AC12CC013AD00248E6F /* AIChatMenuVisibilityConfigurable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */; }; 31521AC32CC01BC700248E6F /* AIChatTabOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */; }; @@ -257,6 +271,14 @@ 317295D32AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317295D02AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift */; }; 317295D42AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317295D12AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift */; }; 317295D52AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317295D12AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift */; }; + 317307262CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317307252CD248DA00C492AB /* AutofillToolbarOnboardingPopover.swift */; }; + 317307272CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317307252CD248DA00C492AB /* AutofillToolbarOnboardingPopover.swift */; }; + 317307292CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317307282CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift */; }; + 3173072A2CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317307282CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift */; }; + 3173072C2CD2490700C492AB /* AutofillToolbarOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3173072B2CD2490300C492AB /* AutofillToolbarOnboardingView.swift */; }; + 3173072D2CD2490700C492AB /* AutofillToolbarOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3173072B2CD2490300C492AB /* AutofillToolbarOnboardingView.swift */; }; + 3173072F2CD2493900C492AB /* AutofillToolbarOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3173072E2CD2493700C492AB /* AutofillToolbarOnboardingViewModel.swift */; }; + 317307302CD2493900C492AB /* AutofillToolbarOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3173072E2CD2493700C492AB /* AutofillToolbarOnboardingViewModel.swift */; }; 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3184AC6C288F29D800C35E4B /* BadgeNotificationAnimationModel.swift */; }; 3184AC6F288F2A1100C35E4B /* CookieNotificationAnimationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3184AC6E288F2A1100C35E4B /* CookieNotificationAnimationModel.swift */; }; 3199AF6F2C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF632C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift */; }; @@ -277,6 +299,10 @@ 3199AF802C80734A003AEBDC /* TabModalManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6D2C80734A003AEBDC /* TabModalManageable.swift */; }; 3199AF832C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */; }; 3199AF842C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */; }; + 319FCFF22CC81D54004F9288 /* AIChatRemoteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */; }; + 319FCFF32CC81D54004F9288 /* AIChatRemoteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */; }; + 319FCFF52CC83007004F9288 /* AIChatDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */; }; + 319FCFF62CC83007004F9288 /* AIChatDebugMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */; }; 31A2FD172BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */; }; 31A2FD182BAB43BA00D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */; }; 31A3A4E32B0C115F0021063C /* DataBrokerProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 31A3A4E22B0C115F0021063C /* DataBrokerProtection */; }; @@ -3391,9 +3417,16 @@ 1E862A882A9FC01200F84D4B /* SubscriptionUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = SubscriptionUI; sourceTree = ""; }; 1ED910D42B63BFB300936947 /* IdentityTheftRestorationPagesUserScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityTheftRestorationPagesUserScript.swift; sourceTree = ""; }; 1EEB2D792C986A40000D908B /* SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift; sourceTree = ""; }; + 31031EB62CC94C6C00684340 /* AIChatRemoteSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettingsTests.swift; sourceTree = ""; }; + 31031EB92CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatOnboardingTabExtensionTests.swift; sourceTree = ""; }; 310E79BE294A19A8007C49E8 /* FireproofingReferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireproofingReferenceTests.swift; sourceTree = ""; }; 311B262628E73E0A00FD181A /* TabShadowConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabShadowConfig.swift; sourceTree = ""; }; 3139A1512AA4B3C000969C7D /* DataBrokerProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionManager.swift; sourceTree = ""; }; + 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatToolBarPopUpOnboardingView.swift; sourceTree = ""; }; + 314872732CC653C700EEF89B /* AIChatOnboardingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatOnboardingPopover.swift; sourceTree = ""; }; + 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatToolBarPopUpOnboardingViewController.swift; sourceTree = ""; }; + 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatToolBarPopUpOnboardingViewModel.swift; sourceTree = ""; }; + 3148727D2CC68F6200EEF89B /* AIChatOnboardingTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatOnboardingTabExtension.swift; sourceTree = ""; }; 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatMenuVisibilityConfigurable.swift; sourceTree = ""; }; 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatTabOpener.swift; sourceTree = ""; }; 3154FD1328E6011A00909769 /* TabShadowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabShadowView.swift; sourceTree = ""; }; @@ -3409,6 +3442,10 @@ 3171D6DA2889B64D0068632A /* CookieManagedNotificationContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieManagedNotificationContainerView.swift; sourceTree = ""; }; 317295D02AF058D3002C3206 /* MockWaitlistTermsAndConditionsActionHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockWaitlistTermsAndConditionsActionHandler.swift; sourceTree = ""; }; 317295D12AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockWaitlistFeatureSetupHandler.swift; sourceTree = ""; }; + 317307252CD248DA00C492AB /* AutofillToolbarOnboardingPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillToolbarOnboardingPopover.swift; sourceTree = ""; }; + 317307282CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillToolbarOnboardingViewController.swift; sourceTree = ""; }; + 3173072B2CD2490300C492AB /* AutofillToolbarOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillToolbarOnboardingView.swift; sourceTree = ""; }; + 3173072E2CD2493700C492AB /* AutofillToolbarOnboardingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillToolbarOnboardingViewModel.swift; sourceTree = ""; }; 3184AC6C288F29D800C35E4B /* BadgeNotificationAnimationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeNotificationAnimationModel.swift; sourceTree = ""; }; 3184AC6E288F2A1100C35E4B /* CookieNotificationAnimationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieNotificationAnimationModel.swift; sourceTree = ""; }; 3192A2702A4C4E330084EA89 /* DataBrokerProtection */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DataBrokerProtection; sourceTree = ""; }; @@ -3424,6 +3461,8 @@ 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingLocationValidatorTests.swift; sourceTree = ""; }; 3199C6F82AF94F5B002A7BA1 /* DataBrokerProtectionFeatureDisabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionFeatureDisabler.swift; sourceTree = ""; }; 3199C6FC2AF97367002A7BA1 /* DataBrokerProtectionAppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionAppEvents.swift; sourceTree = ""; }; + 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettings.swift; sourceTree = ""; }; + 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatDebugMenu.swift; sourceTree = ""; }; 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionFeatureGatekeeperTests.swift; sourceTree = ""; }; 31A83FB42BE28D7D00F74E67 /* UserText+DBP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserText+DBP.swift"; sourceTree = ""; }; 31AA6B962B960B870025014E /* DataBrokerProtectionLoginItemPixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionLoginItemPixels.swift; sourceTree = ""; }; @@ -5314,9 +5353,23 @@ path = Services; sourceTree = ""; }; + 31031EAC2CC8FFCB00684340 /* Onboarding */ = { + isa = PBXGroup; + children = ( + 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */, + 314872732CC653C700EEF89B /* AIChatOnboardingPopover.swift */, + 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */, + 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 31521ABE2CC0139C00248E6F /* AIChat */ = { isa = PBXGroup; children = ( + 31031EAC2CC8FFCB00684340 /* Onboarding */, + 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */, + 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */, 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */, @@ -5342,6 +5395,17 @@ path = CookieManaged; sourceTree = ""; }; + 317307242CD2489900C492AB /* Onboarding */ = { + isa = PBXGroup; + children = ( + 3173072E2CD2493700C492AB /* AutofillToolbarOnboardingViewModel.swift */, + 3173072B2CD2490300C492AB /* AutofillToolbarOnboardingView.swift */, + 317307282CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift */, + 317307252CD248DA00C492AB /* AutofillToolbarOnboardingPopover.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 3184AC6B288F29C600C35E4B /* BadgeAnimationContainer */ = { isa = PBXGroup; children = ( @@ -5432,6 +5496,7 @@ 31F25EFD2CC3C9F9002F9084 /* AIChat */ = { isa = PBXGroup; children = ( + 31031EB62CC94C6C00684340 /* AIChatRemoteSettingsTests.swift */, 31F25EFE2CC3CA00002F9084 /* AIChatMenuConfigurationTests.swift */, ); path = AIChat; @@ -6783,6 +6848,7 @@ 7B1E819A27C8874900FF0E60 /* Autofill */ = { isa = PBXGroup; children = ( + 317307242CD2489900C492AB /* Onboarding */, C10529482C9F45720041E502 /* Debug */, 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */, 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */, @@ -8780,6 +8846,7 @@ B647EFB32922539400BA628D /* TabExtensions */ = { isa = PBXGroup; children = ( + 3148727D2CC68F6200EEF89B /* AIChatOnboardingTabExtension.swift */, B647EFBA2922584B00BA628D /* AdClickAttributionTabExtension.swift */, B6C00ECA292F839D009C73A6 /* AutofillTabExtension.swift */, B6F1B0212BCE5658005E863C /* BrokenSiteInfoTabExtension.swift */, @@ -9058,6 +9125,7 @@ B6CA4822298CDC0B0067ECCE /* TabExtensionsTests */ = { isa = PBXGroup; children = ( + 31031EB92CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift */, B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */, B626A7632992506A00053070 /* SerpHeadersNavigationResponderTests.swift */, 1D1C36E529FB019C001FA40C /* HistoryTabExtensionTests.swift */, @@ -11003,6 +11071,7 @@ C10529442C9CC18B0041E502 /* AutofillCredentialsDebugView.swift in Sources */, 3706FAF8293F65D500E42796 /* URLEventHandler.swift in Sources */, 9FBD84742BB3E15D00220859 /* InstallationAttributionPixelHandler.swift in Sources */, + 3148727B2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift in Sources */, BBFB72802C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, 37197EA72942443D00394917 /* AuthenticationAlert.swift in Sources */, 3706FEC3293F6F0600E42796 /* BWCommunicator.swift in Sources */, @@ -11061,6 +11130,7 @@ 3706FB20293F65D500E42796 /* LocalUnprotectedDomains.swift in Sources */, 3707C719294B5D0F00682A9F /* HoveredLinkTabExtension.swift in Sources */, 3706FB21293F65D500E42796 /* NavigationBarBadgeAnimator.swift in Sources */, + 319FCFF22CC81D54004F9288 /* AIChatRemoteSettings.swift in Sources */, 3706FB22293F65D500E42796 /* NSTextViewExtension.swift in Sources */, 3706FB23293F65D500E42796 /* DownloadsCellView.swift in Sources */, 3706FB25293F65D500E42796 /* PublishedAfter.swift in Sources */, @@ -11081,6 +11151,7 @@ 31EF1E812B63FFB800E6DB17 /* DataBrokerProtectionManager.swift in Sources */, 3706FEBA293F6EFF00E42796 /* BWStatus.swift in Sources */, 3768D8392C24BFF5004120AE /* RemoteMessageView.swift in Sources */, + 314872752CC653D500EEF89B /* AIChatOnboardingPopover.swift in Sources */, 3706FB30293F65D500E42796 /* NavigationBarPopovers.swift in Sources */, 3706FB31293F65D500E42796 /* PinnedTabsHostingView.swift in Sources */, B6AFE6BC29A5D3F8002FF962 /* PrivacyDashboardTabExtension.swift in Sources */, @@ -11123,6 +11194,7 @@ B6B71C592B23379600487131 /* NSLayoutConstraintExtension.swift in Sources */, 848648A22C76F4B20082282D /* BookmarksBarMenuViewController.swift in Sources */, 3706FB4A293F65D500E42796 /* PasswordManagementNoteModel.swift in Sources */, + 319FCFF62CC83007004F9288 /* AIChatDebugMenu.swift in Sources */, 3706FB4B293F65D500E42796 /* CookieNotificationAnimationModel.swift in Sources */, 3706FB4C293F65D500E42796 /* SharingMenu.swift in Sources */, 3706FB4D293F65D500E42796 /* GrammarFeaturesManager.swift in Sources */, @@ -11245,6 +11317,7 @@ 4B4D60C32A0C849100BCD287 /* EventMapping+NetworkProtectionError.swift in Sources */, 3706FB8A293F65D500E42796 /* BrowserTabSelectionDelegate.swift in Sources */, 3706FB8B293F65D500E42796 /* PasswordManagementListSection.swift in Sources */, + 3148723A2CC64A5F00EEF89B /* AIChatToolBarPopUpOnboardingView.swift in Sources */, 3706FB8C293F65D500E42796 /* FaviconReferenceCache.swift in Sources */, 3706FB8D293F65D500E42796 /* BookmarkTreeController.swift in Sources */, B66260E129AC6EBD00E9E3EE /* HistoryTabExtension.swift in Sources */, @@ -11372,6 +11445,7 @@ 3706FBD4293F65D500E42796 /* WindowManager+StateRestoration.swift in Sources */, 7B430EA22A71411A00BAC4A1 /* NetworkProtectionSimulateFailureMenu.swift in Sources */, 3706FBD5293F65D500E42796 /* TabCollection+NSSecureCoding.swift in Sources */, + 314872782CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift in Sources */, 3706FBD6293F65D500E42796 /* Instruments.swift in Sources */, B6ABD0CF2BC042CE0000EB69 /* NSURL+sandboxExtensionRetainCount.m in Sources */, B62B483F2ADE48DE000DECE5 /* ArrayBuilder.swift in Sources */, @@ -11403,6 +11477,7 @@ 3706FBE1293F65D500E42796 /* AppStateRestorationManager.swift in Sources */, 9FA173EC2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */, 3706FBE2293F65D500E42796 /* ClickToLoadUserScript.swift in Sources */, + 3173072C2CD2490700C492AB /* AutofillToolbarOnboardingView.swift in Sources */, EED4D3E02C8A298D00C79EEA /* AutofillPixelEvent.swift in Sources */, 3706FBE3293F65D500E42796 /* WindowControllersManager.swift in Sources */, 37197EAA2942443D00394917 /* ModalSheetCancellable.swift in Sources */, @@ -11413,6 +11488,7 @@ 3706FBE7293F65D500E42796 /* PasswordManagementItemListModel.swift in Sources */, 3706FBE8293F65D500E42796 /* SuggestionTableCellView.swift in Sources */, 3706FBE9293F65D500E42796 /* FireViewModel.swift in Sources */, + 317307292CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift in Sources */, B68D21D02ACBC9FD002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, BD7090D02C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */, 3199AF742C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift in Sources */, @@ -11536,6 +11612,7 @@ 31A83FB62BE28D7D00F74E67 /* UserText+DBP.swift in Sources */, C18194602C7CDD0E00381092 /* PromotionViewModel.swift in Sources */, 3706FC31293F65D500E42796 /* PermissionButton.swift in Sources */, + 317307262CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */, 9F6434622BEC82B700D2D8A0 /* AttributionPixelHandler.swift in Sources */, 843AD3DC2CD389CC00163067 /* XMLNodeExtension.swift in Sources */, 3706FC32293F65D500E42796 /* MoreOptionsMenu.swift in Sources */, @@ -11601,6 +11678,7 @@ B6B4D1D02B0E0DD000C26286 /* DataImportNoDataView.swift in Sources */, 3707EC4B2C47E36A00B67CBE /* CloseButton.swift in Sources */, 3706FC57293F65D500E42796 /* TabPreviewWindowController.swift in Sources */, + 3148727E2CC68F6900EEF89B /* AIChatOnboardingTabExtension.swift in Sources */, 1D9A37682BD8EA8800EBC58D /* DockPositionProvider.swift in Sources */, 3706FC58293F65D500E42796 /* NSSizeExtension.swift in Sources */, 3706FC59293F65D500E42796 /* Fire.swift in Sources */, @@ -11650,6 +11728,7 @@ 9FDA6C222B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */, C1372EF52BBC5BAD003F8793 /* SecureTextField.swift in Sources */, 316913242BD2B6250051B46D /* DataBrokerProtectionPixelsHandler.swift in Sources */, + 317307302CD2493900C492AB /* AutofillToolbarOnboardingViewModel.swift in Sources */, 843D73BC2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */, 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, 4BF0E5132AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, @@ -11778,6 +11857,7 @@ 3706FDF6293F661700E42796 /* DuckPlayerTests.swift in Sources */, 3706FDF7293F661700E42796 /* WebViewExtensionTests.swift in Sources */, 9FA5A0AA2BC900FC00153786 /* BookmarkAllTabsDialogViewModelTests.swift in Sources */, + 31031EBB2CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift in Sources */, 56534DEE29DF252C00121467 /* CapturingDefaultBrowserProvider.swift in Sources */, 561D29C32BDA745B007B91D0 /* MockSyncPausedStateManaging.swift in Sources */, 1EEB2D7B2C986A40000D908B /* SubscriptionPagesUseSubscriptionFeatureTestsForStripe.swift in Sources */, @@ -11895,6 +11975,7 @@ 1D8C2FEB2B70F5A7005E4BBD /* MockWebViewSnapshotRenderer.swift in Sources */, 56A0540E2C1C375E007D8FAB /* MockWindow.swift in Sources */, 3706FE2A293F661700E42796 /* SafariVersionReaderTests.swift in Sources */, + 31031EB82CC94C6E00684340 /* AIChatRemoteSettingsTests.swift in Sources */, 3706FE2B293F661700E42796 /* AtbParserTests.swift in Sources */, 3706FE2C293F661700E42796 /* PermissionStoreMock.swift in Sources */, 374286262CC5940100E66323 /* HomePageSettingsVisibilityModelTests.swift in Sources */, @@ -12554,6 +12635,7 @@ B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, B69B503B2726A12500758A2B /* Atb.swift in Sources */, 37A6A8F12AFCC988008580A3 /* FaviconsFetcherOnboarding.swift in Sources */, + 314872792CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift in Sources */, 7BEC20452B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift in Sources */, B6C0BB6A29AF1C7000AE8E3C /* BrowserTabView.swift in Sources */, B6B1E88026D5DA9B0062C350 /* DownloadsViewController.swift in Sources */, @@ -12604,6 +12686,7 @@ 9826B0A02747DF3D0092F683 /* ContentBlocking.swift in Sources */, 4B379C2227BDBA29008A968E /* LocalAuthenticationService.swift in Sources */, 37CEFCA92A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, + 317307272CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */, 4BB99D0326FE191E001E4761 /* SafariBookmarksReader.swift in Sources */, 316913292BD2C7570051B46D /* DataBrokerProtectionErrorViewController.swift in Sources */, 1DA6D0FD2A1FF9A100540406 /* HTTPCookie.swift in Sources */, @@ -12620,6 +12703,7 @@ C1B51EB42C945A6700315ED8 /* FreemiumDBPPixelExperimentManaging.swift in Sources */, 4B9DB0232A983B24000927DB /* WaitlistRequest.swift in Sources */, 4BE3A6C12C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */, + 3148727F2CC68F6900EEF89B /* AIChatOnboardingTabExtension.swift in Sources */, B6B1E87E26D5DA0E0062C350 /* DownloadsPopover.swift in Sources */, 85774AFF2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift in Sources */, 4B9292A026670D2A00AD2C21 /* SpacerNode.swift in Sources */, @@ -12651,6 +12735,7 @@ 4B4032842AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, 1D220BFC2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */, AA512D1424D99D9800230283 /* FaviconManager.swift in Sources */, + 3148723B2CC64A5F00EEF89B /* AIChatToolBarPopUpOnboardingView.swift in Sources */, 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */, 7BB4BC632C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */, 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */, @@ -12663,6 +12748,7 @@ 4B5A4F4C27F3A5AA008FBD88 /* NSNotificationName+DataImport.swift in Sources */, B64C853826944B880048FEBE /* StoredPermission.swift in Sources */, AAE246F8270A406200BEEAEE /* FirePopoverCollectionViewHeader.swift in Sources */, + 314872742CC653D500EEF89B /* AIChatOnboardingPopover.swift in Sources */, AAB7320926DD0CD9002FACF9 /* FireViewController.swift in Sources */, 31C26A0D2CBE9DFE00FFF462 /* AIChatPreferences.swift in Sources */, 4B92928C26670D1700AD2C21 /* OutlineSeparatorViewCell.swift in Sources */, @@ -12677,6 +12763,7 @@ B6F1C80B2761C45400334924 /* LocalUnprotectedDomains.swift in Sources */, 1E25A4FE2CC937120080EFD4 /* SubscriptionCookieManageEventPixelMapping.swift in Sources */, B69A14FA2B4D705D00B9417D /* BookmarkFolderPicker.swift in Sources */, + 3173072F2CD2493900C492AB /* AutofillToolbarOnboardingViewModel.swift in Sources */, 1D36E658298AA3BA00AA485D /* InternalUserDeciderStore.swift in Sources */, B634DBE5293C944700C3C99E /* NewWindowPolicy.swift in Sources */, 31CF3432288B0B1B0087244B /* NavigationBarBadgeAnimator.swift in Sources */, @@ -12712,6 +12799,7 @@ 4BB99CFE26FE191E001E4761 /* FirefoxBookmarksReader.swift in Sources */, F1DA518C2BF607D200CF29FA /* SubscriptionRedirectManager.swift in Sources */, 4BBC16A227C485BC00E00A38 /* DeviceIdleStateDetector.swift in Sources */, + 319FCFF32CC81D54004F9288 /* AIChatRemoteSettings.swift in Sources */, BDBA859C2C5D25A300BC54F5 /* VPNFeedbackFormViewModel.swift in Sources */, 4B379C2427BDE1B0008A968E /* FlatButton.swift in Sources */, 37054FC92873301700033B6F /* PinnedTabView.swift in Sources */, @@ -12797,6 +12885,7 @@ AA5FA69D275F945C00DCE9C9 /* FaviconStore.swift in Sources */, 4B9DB0352A983B24000927DB /* WaitlistTermsAndConditionsView.swift in Sources */, AAB8203C26B2DE0D00788AC3 /* SuggestionListCharacteristics.swift in Sources */, + 3173072A2CD248EA00C492AB /* AutofillToolbarOnboardingViewController.swift in Sources */, 4B6785472AA8DE68008A5004 /* VPNUninstaller.swift in Sources */, 4B9292D42667123700AD2C21 /* BookmarkListViewController.swift in Sources */, BD88A83E2C4F3E4300460A26 /* FeedbackCategoryProviding.swift in Sources */, @@ -13019,6 +13108,7 @@ B696AFFB2AC5924800C93203 /* FileLineError.swift in Sources */, 1D39E57A2C2C0F3700757339 /* ReleaseNotesUserScript.swift in Sources */, 85CC1D7B26A05ECF0062F04E /* PasswordManagementItemListModel.swift in Sources */, + 3148727C2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift in Sources */, AABEE6A924AB4B910043105B /* SuggestionTableCellView.swift in Sources */, AA6820F125503DA9005ED0D5 /* FireViewModel.swift in Sources */, 372BC2A12A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift in Sources */, @@ -13067,6 +13157,7 @@ D64A5FF82AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 37A6A8F62AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift in Sources */, 5677A9372C9812E800DA7B0A /* TrackerMessageProvider.swift in Sources */, + 319FCFF52CC83007004F9288 /* AIChatDebugMenu.swift in Sources */, EE098E772C8EDE2C009EBA7F /* AutofillCredentialsImportManager.swift in Sources */, 3199AF6F2C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, @@ -13317,6 +13408,7 @@ 4B4D60D32A0C84F700BCD287 /* UserText+NetworkProtection.swift in Sources */, 843D73BB2C786E5400E4F9DC /* BookmarkListPopover.swift in Sources */, B6B2400E28083B49001B8F3A /* WebViewContainerView.swift in Sources */, + 3173072D2CD2490700C492AB /* AutofillToolbarOnboardingView.swift in Sources */, AAC5E4D925D6A711007F5990 /* BookmarkStore.swift in Sources */, B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */, 37D2771527E870D4003365FD /* PreferencesAppearanceView.swift in Sources */, @@ -13470,6 +13562,7 @@ B6106BA026A7BE0B0013B453 /* PermissionManagerTests.swift in Sources */, 4B59CC8C290083240058F2F6 /* ConnectBitwardenViewModelTests.swift in Sources */, 37DB56F52C3B3C420093D4DC /* MockRemoteMessagingStore.swift in Sources */, + 31031EBA2CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift in Sources */, B68412202B6A30680092F66A /* StringExtensionTests.swift in Sources */, B6106BB526A809E60013B453 /* GeolocationProviderTests.swift in Sources */, BDA764912BC4E57200D0400C /* MockVPNLocationFormatter.swift in Sources */, @@ -13657,6 +13750,7 @@ 9F8D57322BCCCB9A00AEA660 /* UserDefaultsBookmarkFoldersStoreTests.swift in Sources */, 4B723E0826B0003E00E14D75 /* MockSecureVault.swift in Sources */, 37CD54B527F1AC1300F1F7B9 /* PreferencesSidebarModelTests.swift in Sources */, + 31031EB72CC94C6E00684340 /* AIChatRemoteSettingsTests.swift in Sources */, B6CA4824298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift in Sources */, AAEC74B22642C57200C2EFBC /* HistoryCoordinatingMock.swift in Sources */, 37D046A12C7DA9A200AEAA50 /* UserBackgroundImagesManagerTests.swift in Sources */, diff --git a/DuckDuckGo/AIChat/AIChatDebugMenu.swift b/DuckDuckGo/AIChat/AIChatDebugMenu.swift new file mode 100644 index 0000000000..df28d18371 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatDebugMenu.swift @@ -0,0 +1,45 @@ +// +// AIChatDebugMenu.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 + +final class AIChatDebugMenu: NSMenu { + private var storage = DefaultAIChatPreferencesStorage() + + init() { + super.init(title: "") + + buildItems { + NSMenuItem(title: "Reset toolbar onboarding", action: #selector(resetToolbarOnboarding), target: self) + NSMenuItem(title: "Show toolbar onboarding", action: #selector(showToolbarOnboarding), target: self) + } + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func resetToolbarOnboarding() { + storage.reset() + } + + @objc func showToolbarOnboarding() { + storage.didDisplayAIChatToolbarOnboarding = false + NotificationCenter.default.post(name: .AIChatOpenedForReturningUser, object: nil) + } +} diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 404a5246cd..a33d1fe372 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -17,16 +17,51 @@ // import Combine +import BrowserServicesKit protocol AIChatMenuVisibilityConfigurable { + + /// This property validates remote feature flags and user settings to determine if the shortcut + /// should be presented to the user. + /// + /// - Returns: `true` if the application menu shortcut should be displayed; otherwise, `false`. var shouldDisplayApplicationMenuShortcut: Bool { get } + + /// This property checks the relevant settings to decide if the toolbar shortcut is to be shown. + /// + /// - Returns: `true` if the toolbar shortcut should be displayed; otherwise, `false`. var shouldDisplayToolbarShortcut: Bool { get } + /// This property reflects the current state of the feature flag for the application menu shortcut. + /// + /// - Returns: `true` if the remote feature for the application menu shortcut is enabled; otherwise, `false`. var isFeatureEnabledForApplicationMenuShortcut: Bool { get } + + /// This property reflects the current state of the feature flag for the toolbar shortcut. + /// + /// - Returns: `true` if the remote feature for the toolbar shortcut is enabled; otherwise, `false`. var isFeatureEnabledForToolbarShortcut: Bool { get } - var shortcutURL: URL { get } + /// A publisher that emits a value when either the `shouldDisplayApplicationMenuShortcut` or + /// `shouldDisplayToolbarShortcut` settings, backed by storage, are changed. + /// + /// This allows subscribers to react to changes in the visibility settings of the application menu + /// and toolbar shortcuts. + /// + /// - Returns: A `PassthroughSubject` that emits `Void` when the values change. var valuesChangedPublisher: PassthroughSubject { get } + + /// A publisher that is triggered when it is validated that the onboarding should be displayed. + /// + /// This property listens to `AIChatOnboardingTabExtension` and triggers the publisher when a + /// notification `AIChatOpenedForReturningUser` is posted. + /// + /// - Returns: A `PassthroughSubject` that emits `Void` when the onboarding popover should be displayed. + var shouldDisplayToolbarOnboardingPopover: PassthroughSubject { get } + + /// Marks the toolbar onboarding popover as shown, preventing it from being displayed more than once. + /// This method should be called after the onboarding popover has been presented to the user. + func markToolbarOnboardingPopoverAsShown() } final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { @@ -37,8 +72,11 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { private var cancellables = Set() private var storage: AIChatPreferencesStorage + private let notificationCenter: NotificationCenter + private let remoteSettings: AIChatRemoteSettingsProvider var valuesChangedPublisher = PassthroughSubject() + var shouldDisplayToolbarOnboardingPopover = PassthroughSubject() var isFeatureEnabledForApplicationMenuShortcut: Bool { isFeatureEnabledFor(shortcutType: .applicationMenu) @@ -56,13 +94,29 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { return isFeatureEnabledForApplicationMenuShortcut && storage.showShortcutInApplicationMenu } - var shortcutURL: URL { - URL(string: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2")! + func markToolbarOnboardingPopoverAsShown() { + storage.didDisplayAIChatToolbarOnboarding = true } - init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage()) { + init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), + notificationCenter: NotificationCenter = .default, + remoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) { self.storage = storage + self.notificationCenter = notificationCenter + self.remoteSettings = remoteSettings + self.subscribeToValuesChanged() + self.subscribeToAIChatLoadedNotification() + } + + private func subscribeToAIChatLoadedNotification() { + notificationCenter.publisher(for: .AIChatOpenedForReturningUser) + .sink { [weak self] _ in + guard let self = self else { return } + if !self.storage.didDisplayAIChatToolbarOnboarding && !storage.shouldDisplayToolbarShortcut { + self.shouldDisplayToolbarOnboardingPopover.send() + } + }.store(in: &cancellables) } private func subscribeToValuesChanged() { @@ -82,11 +136,9 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { private func isFeatureEnabledFor(shortcutType: ShortcutType) -> Bool { switch shortcutType { case .applicationMenu: - // Use privacy config here - return true + return remoteSettings.isApplicationMenuShortcutEnabled case .toolbar: - // Use privacy config here - return true + return remoteSettings.isToolbarShortcutEnabled } } } diff --git a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift index ad917a3e10..4525cb6026 100644 --- a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift +++ b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift @@ -21,18 +21,25 @@ import Combine protocol AIChatPreferencesStorage { var showShortcutInApplicationMenu: Bool { get set } var shouldDisplayToolbarShortcut: Bool { get set } + var didDisplayAIChatToolbarOnboarding: Bool { get set } var showShortcutInApplicationMenuPublisher: AnyPublisher { get } var shouldDisplayToolbarShortcutPublisher: AnyPublisher { get } + + func reset() } struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { + private let userDefaults: UserDefaults + private let pinningManager: PinningManager + private let notificationCenter: NotificationCenter + var showShortcutInApplicationMenuPublisher: AnyPublisher { userDefaults.showAIChatShortcutInApplicationMenuPublisher } var shouldDisplayToolbarShortcutPublisher: AnyPublisher { - NotificationCenter.default.publisher(for: .PinnedViewsChanged) + notificationCenter.publisher(for: .PinnedViewsChanged) .compactMap { notification -> PinnableView? in guard let userInfo = notification.userInfo as? [String: Any], let viewType = userInfo[LocalPinningManager.pinnedViewChangedNotificationViewTypeKey] as? String, @@ -47,13 +54,12 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { .eraseToAnyPublisher() } - private let userDefaults: UserDefaults - private let pinningManager: PinningManager - init(userDefaults: UserDefaults = .standard, - pinningManager: PinningManager = LocalPinningManager.shared) { + pinningManager: PinningManager = LocalPinningManager.shared, + notificationCenter: NotificationCenter = .default) { self.userDefaults = userDefaults self.pinningManager = pinningManager + self.notificationCenter = notificationCenter } var shouldDisplayToolbarShortcut: Bool { @@ -71,31 +77,55 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { get { userDefaults.showAIChatShortcutInApplicationMenu } set { userDefaults.showAIChatShortcutInApplicationMenu = newValue } } + + var didDisplayAIChatToolbarOnboarding: Bool { + get { userDefaults.didDisplayAIChatToolbarOnboarding } + set { userDefaults.didDisplayAIChatToolbarOnboarding = newValue } + } + + func reset() { + userDefaults.showAIChatShortcutInApplicationMenu = UserDefaults.showAIChatShortcutInApplicationMenuDefaultValue + userDefaults.didDisplayAIChatToolbarOnboarding = UserDefaults.didDisplayAIChatToolbarOnboardingDefaultValue + pinningManager.unpin(.aiChat) + } } private extension UserDefaults { - private var showAIChatShortcutInApplicationMenuKey: String { - "aichat.showAIChatShortcutInApplicationMenu" + enum Keys { + static let showAIChatShortcutInApplicationMenuKey = "aichat.showAIChatShortcutInApplicationMenu" + static let didDisplayAIChatToolbarOnboardingKey = "aichat.didDisplayAIChatToolbarOnboarding" } - static let showAIChatShortcutInApplicationMenuDefaultValue = false + static let showAIChatShortcutInApplicationMenuDefaultValue = true + static let didDisplayAIChatToolbarOnboardingDefaultValue = false - @objc - dynamic var showAIChatShortcutInApplicationMenu: Bool { + @objc dynamic var showAIChatShortcutInApplicationMenu: Bool { get { - value(forKey: showAIChatShortcutInApplicationMenuKey) as? Bool ?? Self.showAIChatShortcutInApplicationMenuDefaultValue + value(forKey: Keys.showAIChatShortcutInApplicationMenuKey) as? Bool ?? Self.showAIChatShortcutInApplicationMenuDefaultValue } set { - guard newValue != showAIChatShortcutInApplicationMenu else { - return - } + guard newValue != showAIChatShortcutInApplicationMenu else { return } + set(newValue, forKey: Keys.showAIChatShortcutInApplicationMenuKey) + } + } + + @objc dynamic var didDisplayAIChatToolbarOnboarding: Bool { + get { + value(forKey: Keys.didDisplayAIChatToolbarOnboardingKey) as? Bool ?? Self.didDisplayAIChatToolbarOnboardingDefaultValue + } - set(newValue, forKey: showAIChatShortcutInApplicationMenuKey) + set { + guard newValue != didDisplayAIChatToolbarOnboarding else { return } + set(newValue, forKey: Keys.didDisplayAIChatToolbarOnboardingKey) } } var showAIChatShortcutInApplicationMenuPublisher: AnyPublisher { publisher(for: \.showAIChatShortcutInApplicationMenu).eraseToAnyPublisher() } + + var didDisplayAIChatToolbarOnboardingPublisher: AnyPublisher { + publisher(for: \.didDisplayAIChatToolbarOnboarding).eraseToAnyPublisher() + } } diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift new file mode 100644 index 0000000000..bd227a3d25 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -0,0 +1,99 @@ +// +// AIChatRemoteSettings.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 BrowserServicesKit +import PixelKit + +protocol AIChatRemoteSettingsProvider { + var onboardingCookieName: String { get } + var onboardingCookieDomain: String { get } + var aiChatURLIdentifiableQuery: String { get } + var aiChatURLIdentifiableQueryValue: String { get } + var aiChatURL: URL { get } + var isAIChatEnabled: Bool { get } + var isToolbarShortcutEnabled: Bool { get } + var isApplicationMenuShortcutEnabled: Bool { get } +} + +/// This struct serves as a wrapper for PrivacyConfigurationManaging, enabling the retrieval of data relevant to AIChat. +/// It also fire pixels when necessary data is missing. +struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { + enum SettingsValue: String { + case cookieName = "onboardingCookieName" + case cookieDomain = "onboardingCookieDomain" + case aiChatURL = "aiChatURL" + case aiChatURLIdentifiableQuery = "aiChatURLIdentifiableQuery" + case aiChatURLIdentifiableQueryValue = "aiChatURLIdentifiableQueryValue" + + var defaultValue: String { + switch self { + case .cookieName: return "dcm" + case .cookieDomain: return "duckduckgo.com" + case .aiChatURL: return "https://duck.ai" + case .aiChatURLIdentifiableQuery: return "ia" + case .aiChatURLIdentifiableQueryValue: return "chat" + } + } + } + + private let privacyConfigurationManager: PrivacyConfigurationManaging + private var settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + privacyConfigurationManager.privacyConfig.settings(for: .aiChat) + } + + init(privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager) { + self.privacyConfigurationManager = privacyConfigurationManager + } + + // MARK: - Public + + var onboardingCookieName: String { getSettingsData(.cookieName) } + var onboardingCookieDomain: String { getSettingsData(.cookieDomain) } + var aiChatURLIdentifiableQuery: String { getSettingsData(.aiChatURLIdentifiableQuery) } + var aiChatURLIdentifiableQueryValue: String { getSettingsData(.aiChatURLIdentifiableQueryValue) } + + var aiChatURL: URL { + guard let url = URL(string: getSettingsData(.aiChatURL)) else { + return URL(string: SettingsValue.aiChatURL.defaultValue)! + } + return url + } + + var isAIChatEnabled: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) + } + + var isToolbarShortcutEnabled: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.toolbarShortcut) + } + + var isApplicationMenuShortcutEnabled: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.applicationMenuShortcut) + } + + // MARK: - Private + + private func getSettingsData(_ value: SettingsValue) -> String { + if let value = settings[value.rawValue] as? String { + return value + } else { + PixelKit.fire(GeneralPixel.aichatNoRemoteSettingsFound(value), includeAppVersionParameter: true) + return value.defaultValue + } + } +} diff --git a/DuckDuckGo/AIChat/AIChatTabOpener.swift b/DuckDuckGo/AIChat/AIChatTabOpener.swift index 13f12a361e..c44ee86739 100644 --- a/DuckDuckGo/AIChat/AIChatTabOpener.swift +++ b/DuckDuckGo/AIChat/AIChatTabOpener.swift @@ -18,6 +18,6 @@ struct AIChatTabOpener { @MainActor static func openAIChatTab() { - WindowControllersManager.shared.showTab(with: .url(AIChatMenuConfiguration().shortcutURL, credential: nil, source: .ui)) + WindowControllersManager.shared.showTab(with: .url(AIChatRemoteSettings().aiChatURL, credential: nil, source: .ui)) } } diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift new file mode 100644 index 0000000000..a9a57d834c --- /dev/null +++ b/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift @@ -0,0 +1,44 @@ +// +// AIChatOnboardingPopover.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 SwiftUI + +final class AIChatOnboardingPopover: NSPopover { + let ctaCallback: (Bool) -> Void + + init(ctaCallback: @escaping (Bool) -> Void) { + self.ctaCallback = ctaCallback + + super.init() + + self.animates = false + self.behavior = .semitransient + + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("\(Self.self): Bad initializer") + } + + private func setupContentController() { + let controller = AIChatToolBarPopUpOnboardingViewController() + controller.ctaCallback = self.ctaCallback + contentViewController = controller + } +} diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift new file mode 100644 index 0000000000..57f4d52772 --- /dev/null +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift @@ -0,0 +1,76 @@ +// +// AIChatToolBarPopUpOnboardingView.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 SwiftUI +import SwiftUIExtensions + +struct AIChatToolBarPopUpOnboardingView: View { + @ObservedObject var viewModel: AIChatToolBarPopUpOnboardingViewModel + + enum Constants { + static let verticalSpacing: CGFloat = 16 + static let panelWidth: CGFloat = 310 + static let panelHeight: CGFloat = 148 + } + + var body: some View { + VStack(spacing: Constants.verticalSpacing) { + VStack(alignment: .leading, spacing: Constants.verticalSpacing) { + Text(UserText.aiChatOnboardingPopoverTitle) + .font(.headline) + + if #available(macOS 12, *) { + // Use Markdown for macOS 12 and newer + // .init is required for markdown to be correctly parsed from NSLocalizedString + Text(.init(UserText.aiChatOnboardingPopoverMessageMarkdown)) + } else { + // Fallback for earlier macOS versions + Text(UserText.aiChatOnboardingPopoverMessageFallback) + } + } + + HStack { + createButton(title: UserText.aiChatOnboardingPopoverCTAReject, + style: StandardButtonStyle(), + action: viewModel.rejectToolbarIcon) + + createButton(title: UserText.aiChatOnboardingPopoverCTAAccept, + style: DefaultActionButtonStyle(enabled: true), + action: viewModel.acceptToolbarIcon) + } + } + .padding() + .frame(width: Constants.panelWidth, height: Constants.panelHeight) + } + + private func createButton(title: String, style: some ButtonStyle, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 13)) + .fontWeight(.light) + .frame(maxWidth: .infinity) + .frame(height: 22) + } + .buttonStyle(style) + .padding(0) + } +} + +#Preview { + AIChatToolBarPopUpOnboardingView(viewModel: AIChatToolBarPopUpOnboardingViewModel()) +} diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift new file mode 100644 index 0000000000..147382d435 --- /dev/null +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift @@ -0,0 +1,38 @@ +// +// AIChatToolBarPopUpOnboardingViewController.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 SwiftUI + +final class AIChatToolBarPopUpOnboardingViewController: NSViewController { + var ctaCallback: ((Bool) -> Void)? + + private let viewModel = AIChatToolBarPopUpOnboardingViewModel() + private var hostingView: NSHostingView! + + override func loadView() { + let onboardingView = AIChatToolBarPopUpOnboardingView(viewModel: viewModel) + hostingView = NSHostingView(rootView: onboardingView) + self.view = hostingView + + self.setupViewModelCallbacks() + } + + private func setupViewModelCallbacks() { + viewModel.ctaCallback = ctaCallback + } +} diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift new file mode 100644 index 0000000000..719b5e62b4 --- /dev/null +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift @@ -0,0 +1,42 @@ +// +// AIChatToolBarPopUpOnboardingViewModel.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 PixelKit + +final class AIChatToolBarPopUpOnboardingViewModel: ObservableObject { + var aiChatStorage: AIChatPreferencesStorage + var ctaCallback: ((Bool) -> Void)? + + internal init(aiChatStorage: any AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), + ctaCallback: ((Bool) -> Void)? = nil) { + self.aiChatStorage = aiChatStorage + self.ctaCallback = ctaCallback + } + + func rejectToolbarIcon() { + aiChatStorage.shouldDisplayToolbarShortcut = false + ctaCallback?(false) + } + + func acceptToolbarIcon() { + PixelKit.fire(GeneralPixel.aichatToolbarOnboardingPopoverAccept, + includeAppVersionParameter: true) + aiChatStorage.shouldDisplayToolbarShortcut = true + ctaCallback?(true) + } +} diff --git a/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingPopover.swift b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingPopover.swift new file mode 100644 index 0000000000..71a63ba148 --- /dev/null +++ b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingPopover.swift @@ -0,0 +1,44 @@ +// +// AutofillToolbarOnboardingPopover.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 SwiftUI + +final class AutofillToolbarOnboardingPopover: NSPopover { + let ctaCallback: (Bool) -> Void + + init(ctaCallback: @escaping (Bool) -> Void) { + self.ctaCallback = ctaCallback + + super.init() + + self.animates = false + self.behavior = .semitransient + + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("\(Self.self): Bad initializer") + } + + private func setupContentController() { + let controller = AutofillToolbarOnboardingViewController() + controller.ctaCallback = self.ctaCallback + contentViewController = controller + } +} diff --git a/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingView.swift b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingView.swift new file mode 100644 index 0000000000..fcd26d3102 --- /dev/null +++ b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingView.swift @@ -0,0 +1,68 @@ +// +// AutofillToolbarOnboardingView.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 SwiftUI +import SwiftUIExtensions + +struct AutofillToolbarOnboardingView: View { + @ObservedObject var viewModel: AutofillToolbarOnboardingViewModel + + enum Constants { + static let verticalSpacing: CGFloat = 16 + static let panelWidth: CGFloat = 310 + static let panelHeight: CGFloat = 148 + } + + var body: some View { + VStack(spacing: Constants.verticalSpacing) { + VStack(alignment: .leading, spacing: Constants.verticalSpacing) { + Text(UserText.autofillOnboardingPopoverTitle) + .font(.headline) + Text(UserText.autofillOnboardingPopoverMessage) + } + + HStack { + createButton(title: UserText.autofillOnboardingPopoverCTAReject, + style: StandardButtonStyle(), + action: viewModel.rejectToolbarIcon) + + createButton(title: UserText.autofillOnboardingPopoverCTAAccept, + style: DefaultActionButtonStyle(enabled: true), + action: viewModel.acceptToolbarIcon) + } + } + .padding() + .frame(width: Constants.panelWidth, height: Constants.panelHeight) + } + + private func createButton(title: String, style: some ButtonStyle, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 13)) + .fontWeight(.light) + .frame(maxWidth: .infinity) + .frame(height: 22) + } + .buttonStyle(style) + .padding(0) + } +} + +#Preview { + AutofillToolbarOnboardingView(viewModel: AutofillToolbarOnboardingViewModel()) +} diff --git a/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewController.swift b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewController.swift new file mode 100644 index 0000000000..4e780de28c --- /dev/null +++ b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewController.swift @@ -0,0 +1,38 @@ +// +// AutofillToolbarOnboardingViewController.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 SwiftUI + +final class AutofillToolbarOnboardingViewController: NSViewController { + var ctaCallback: ((Bool) -> Void)? + + private let viewModel = AutofillToolbarOnboardingViewModel() + private var hostingView: NSHostingView! + + override func loadView() { + let onboardingView = AutofillToolbarOnboardingView(viewModel: viewModel) + hostingView = NSHostingView(rootView: onboardingView) + self.view = hostingView + + self.setupViewModelCallbacks() + } + + private func setupViewModelCallbacks() { + viewModel.ctaCallback = ctaCallback + } +} diff --git a/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewModel.swift b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewModel.swift new file mode 100644 index 0000000000..2edf3ed10d --- /dev/null +++ b/DuckDuckGo/Autofill/Onboarding/AutofillToolbarOnboardingViewModel.swift @@ -0,0 +1,33 @@ +// +// AutofillToolbarOnboardingViewModel.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. +// + +final class AutofillToolbarOnboardingViewModel: ObservableObject { + var ctaCallback: ((Bool) -> Void)? + + internal init(ctaCallback: ((Bool) -> Void)? = nil) { + self.ctaCallback = ctaCallback + } + + func rejectToolbarIcon() { + ctaCallback?(false) + } + + func acceptToolbarIcon() { + ctaCallback?(true) + } +} diff --git a/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift b/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift index b6dd6a5933..5c648cc399 100644 --- a/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift +++ b/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift @@ -40,7 +40,7 @@ final class BookmarksBarPromptPopover: NSPopover { private func setupContentController() { let controller = BookmarksBarPromptViewController.create() contentViewController = controller - contentViewController?.preferredContentSize = NSSize(width: 356, height: 272) + contentViewController?.preferredContentSize = NSSize(width: 356, height: 292) } } @@ -84,9 +84,18 @@ struct BookmarksBarPromptView: View { .font(.system(size: 15).weight(.semibold)) .padding(.bottom, 16) - Text(UserText.bookmarksBarPromptMessage) - .font(.system(size: 13)) - .padding(.bottom, 20) + if #available(macOS 12, *) { + // Use Markdown for macOS 12 and newer + // .init is required for markdown to be correctly parsed from NSLocalizedString + Text(.init(UserText.bookmarksBarPromptMessageMarkdown)) + .font(.system(size: 13)) + .padding(.bottom, 20) + } else { + // Fallback for earlier macOS versions + Text(UserText.bookmarksBarPromptMessageFallback) + .font(.system(size: 13)) + .padding(.bottom, 20) + } HStack { Button { diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 492357259b..e71f1e97ec 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -358,10 +358,21 @@ struct UserText { // Misc // AI Chat + static let aiChatOnboardingPopoverTitle = NSLocalizedString("ai-chat.onboarding.popover.title", value: "Launch AI Chat directly from your toolbar", comment: "AI Chat onboarding popover title") + static let aiChatOnboardingPopoverMessageMarkdown = NSLocalizedString("ai-chat.onboarding.popover.message-markdown", value: "You can adjust this and other AI Chat features in **Settings** > **AI Chat**.", comment: "AI Chat onboarding popover message, make sure to keep Settings and AI Chat inside ** **") + static let aiChatOnboardingPopoverMessageFallback = NSLocalizedString("ai-chat.onboarding.popover.message-fallback", value: "You can adjust this and other AI Chat features in Settings > AI Chat", comment: "AI Chat onboarding popover message continuation") + static let aiChatOnboardingPopoverCTAReject = NSLocalizedString("ai-chat.onboarding.popover.reject", value: "No Thanks", comment: "AI Chat onboarding CTA for rejection") + static let aiChatOnboardingPopoverCTAAccept = NSLocalizedString("ai-chat.onboarding.popover.accept", value: "Add Shortcut", comment: "AI Chat onboarding CTA for approval") + static let aiChatOnboardingPopoverConfirmation = NSLocalizedString("ai-chat.onboarding.popover.confirmation", value: "AI Chat shortcut added!", comment: "Confirmation for accepting the AI Chat onboarding popover") + static let aiChatShowInToolbarToggle = NSLocalizedString("ai-chat.show-in-toolbar.toggle", value: "Show AI Chat shortcut in browser toolbar", comment: "Show AI Chat in toolbar") static let aiChatShowInApplicationMenuToggle = NSLocalizedString("ai-chat.show-in-application-menu.toggle", value: "Show “New AI Chat” in File and application menus", comment: "Show AI Chat in application menus") + static let aiChatPreferencesCaptionWithLinkMarkdown = NSLocalizedString("ai-chat.preferences.caption.link.markdown", value: "AI Chat is an optional feature available at [duck.ai](https://duck.ai) that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models.", comment: "Ai Chat preferences explanation with a markdown link. Do not translate what's inside [] and ()") + + static let aiChatPreferencesCaptionWithLinkFallback = NSLocalizedString("ai-chat.preferences.caption.link.fallback", value: "AI Chat is an optional feature available at duck.ai that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models.", comment: "Ai Chat preferences explanation") + static let aiChatPreferencesCaption = NSLocalizedString("ai-chat.preferences.caption", value: "Launch AI Chat faster by adding shortcuts to your browser toolbar or menu", comment: "Ai Chat preferences explanation") static let aiChatPreferencesLearnMoreButton = NSLocalizedString("ai-chat.preferences.learn-more", value: "Learn More", comment: "AI Chat preferences button to learn more about it") @@ -398,6 +409,11 @@ struct UserText { static let gpcExplanation = NSLocalizedString("gpc.explanation", value: "Tells participating websites not to sell or share your data.", comment: "GPC explanation in settings") static let learnMore = NSLocalizedString("learnmore.link", value: "Learn More", comment: "Learn More link") + static let autofillOnboardingPopoverCTAReject = NSLocalizedString("autofill.onboarding.popover.reject", value: "No Thanks", comment: "Autofill onboarding CTA for rejection") + static let autofillOnboardingPopoverCTAAccept = NSLocalizedString("autofill.onboarding.popover.accept", value: "Add Shortcut", comment: "Autofill onboarding CTA for approval") + static let autofillOnboardingPopoverTitle = NSLocalizedString("autofill.onboarding.popover.title", value: "Add passwords shortcut?", comment: "Autofill onboarding popover title") + static let autofillOnboardingPopoverMessage = NSLocalizedString("autofill.onboarding.popover.message1", value: "You can manage your toolbar shortcuts at any time by right-clicking on the toolbar.", comment: "Autofill onboarding popover message") + static let autofillPasswordManager = NSLocalizedString("autofill.password-manager", value: "Password Manager", comment: "Autofill settings section title") static let autofillPasswordManagerDuckDuckGo = NSLocalizedString("autofill.password-manager.duckduckgo", value: "DuckDuckGo built-in password manager", comment: "Autofill password manager row title") static let autofillPasswordManagerBitwarden = NSLocalizedString("autofill.password-manager.bitwarden", value: "Bitwarden", comment: "Autofill password manager row title") @@ -1177,7 +1193,9 @@ struct UserText { // Bookmarks bar prompt static let bookmarksBarPromptTitle = NSLocalizedString("bookmarks.bar.prompt.title", value: "Show Bookmarks Bar?", comment: "Title for bookmarks bar prompt") - static let bookmarksBarPromptMessage = NSLocalizedString("bookmarks.bar.prompt.message", value: "Show the Bookmarks Bar for quick access to your new bookmarks.", comment: "Message show for bookmarks bar prompt") + static let bookmarksBarPromptMessageMarkdown = NSLocalizedString("bookmarks.bar.prompt.message1", value: "Show the Bookmarks Bar for quick access to your favorite bookmarks. You can adjust this later in **Settings** > **Appearance**.", comment: " message with markdown show for bookmarks bar prompt, make sure to keep the ** ** for the translated words Settings and Appearance") + static let bookmarksBarPromptMessageFallback = NSLocalizedString("bookmarks.bar.prompt.message1", value: "Show the Bookmarks Bar for quick access to your favorite bookmarks. You can adjust this later in Settings > Appearance.", comment: " message show for bookmarks bar prompt") + static let bookmarksBarPromptDismiss = NSLocalizedString("bookmarks.bar.prompt.dismiss", value: "Hide", comment: "Dismiss button label on bookmarks bar prompt") static let bookmarksBarPromptAccept = NSLocalizedString("bookmarks.bar.prompt.accept", value: "Show", comment: "Accept button label on bookmarks bar prompt") diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index bdf08d8e44..4b89758f67 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -165,12 +165,13 @@ final class MainMenu: NSMenu { func buildFileMenu() -> NSMenuItem { NSMenuItem(title: UserText.mainMenuFile) { + newTabMenuItem + newWindowMenuItem NSMenuItem(title: UserText.newBurnerWindowMenuItem, action: #selector(AppDelegate.newBurnerWindow), keyEquivalent: "N") aiChatMenu - newTabMenuItem openLocationMenuItem NSMenuItem.separator() @@ -278,9 +279,7 @@ final class MainMenu: NSMenu { toggleNetworkProtectionShortcutMenuItem - if aiChatMenuConfig.shouldDisplayToolbarShortcut { - toggleAIChatShortcutMenuItem - } + toggleAIChatShortcutMenuItem NSMenuItem.separator() @@ -429,6 +428,7 @@ final class MainMenu: NSMenu { // To be safe, hide the NetP shortcut menu item by default. toggleNetworkProtectionShortcutMenuItem.isHidden = true + toggleAIChatShortcutMenuItem.isHidden = true updateHomeButtonMenuItem() updateBookmarksBarMenuItem() @@ -585,6 +585,13 @@ final class MainMenu: NSMenu { toggleBookmarksShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .bookmarks) toggleDownloadsShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .downloads) + if AIChatRemoteSettings().isApplicationMenuShortcutEnabled { + toggleAIChatShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .aiChat) + toggleAIChatShortcutMenuItem.isHidden = false + } else { + toggleAIChatShortcutMenuItem.isHidden = true + } + if DefaultVPNFeatureGatekeeper(subscriptionManager: Application.appDelegate.subscriptionManager).isVPNVisible() { toggleNetworkProtectionShortcutMenuItem.isHidden = false toggleNetworkProtectionShortcutMenuItem.title = LocalPinningManager.shared.shortcutTitle(for: .networkProtection) @@ -704,6 +711,8 @@ final class MainMenu: NSMenu { subscriptionManager: Application.appDelegate.subscriptionManager) NSMenuItem(title: "Logging").submenu(setupLoggingMenu()) + NSMenuItem(title: "AI Chat").submenu(AIChatDebugMenu()) + } debugMenu.addItem(internalUserItem) debugMenu.autoenablesItems = false diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 00f780c18b..41b56eccbe 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -68,6 +68,7 @@ extension AppDelegate { @objc func newAIChat(_ sender: Any?) { DispatchQueue.main.async { AIChatTabOpener.openAIChatTab() + PixelKit.fire(GeneralPixel.aichatApplicationMenuFileClicked, includeAppVersionParameter: true) } } diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index 554d665a9e..c0625778b8 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -204,6 +204,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { @MainActor @objc func newAiChat(_ sender: NSMenuItem) { AIChatTabOpener.openAIChatTab() + PixelKit.fire(GeneralPixel.aichatApplicationMenuAppClicked, includeAppVersionParameter: true) } @MainActor diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index f2a2e78eac..7e893b8b73 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -58,6 +58,8 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { private(set) var savePaymentMethodPopover: SavePaymentMethodPopover? private(set) var autofillPopoverPresenter: AutofillPopoverPresenter private(set) var downloadsPopover: DownloadsPopover? + private(set) var aiChatOnboardingPopover: AIChatOnboardingPopover? + private(set) var autofillOnboardingPopover: AutofillToolbarOnboardingPopover? private var privacyDashboardPopover: PrivacyDashboardPopover? private var privacyInfoCancellable: AnyCancellable? @@ -224,9 +226,41 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { privacyDashboardPopover?.close() } + if aiChatOnboardingPopover?.isShown ?? false { + aiChatOnboardingPopover?.close() + } + + if autofillOnboardingPopover?.isShown ?? false { + autofillOnboardingPopover?.close() + } + return true } + func showAIChatOnboardingPopover(from button: MouseOverButton, + withDelegate delegate: NSPopoverDelegate, + ctaCallback: @escaping (Bool) -> Void) { + guard closeTransientPopovers() else { return } + let popover = aiChatOnboardingPopover ?? AIChatOnboardingPopover(ctaCallback: ctaCallback) + + PixelKit.fire(GeneralPixel.aichatToolbarOnboardingPopoverShown, + includeAppVersionParameter: true) + popover.delegate = delegate + aiChatOnboardingPopover = popover + show(popover, positionedBelow: button) + } + + func showAutofillOnboardingPopover(from button: MouseOverButton, + withDelegate delegate: NSPopoverDelegate, + ctaCallback: @escaping (Bool) -> Void) { + guard closeTransientPopovers() else { return } + let popover = autofillOnboardingPopover ?? AutofillToolbarOnboardingPopover(ctaCallback: ctaCallback) + + popover.delegate = delegate + autofillOnboardingPopover = popover + show(popover, positionedBelow: button) + } + func showBookmarkListPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate, forTab tab: Tab?) { guard closeTransientPopovers() else { return } @@ -272,6 +306,14 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { zoomPopover?.close() } + func closeAIChatOnboardingPopover() { + aiChatOnboardingPopover?.close() + } + + func closeAutofillOnboardingPopover() { + autofillOnboardingPopover?.close() + } + func openPrivacyDashboard(for tabViewModel: TabViewModel, from button: MouseOverButton) { guard closeTransientPopovers() else { return } @@ -377,6 +419,14 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { bookmarkListPopover = nil } + func aiChatOnboardingPopoverClosed() { + aiChatOnboardingPopover = nil + } + + func autofillOnboardingPopoverClosed() { + autofillOnboardingPopover = nil + } + func saveIdentityPopoverClosed() { saveIdentityPopover = nil } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 03d4b6de9a..1bce79d15e 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -184,6 +184,8 @@ final class NavigationBarViewController: NSViewController { #if DEBUG || REVIEW addDebugNotificationListeners() #endif + + subscribeToAIChatOnboarding() } override func viewWillAppear() { @@ -334,10 +336,6 @@ final class NavigationBarViewController: NSViewController { toggleDownloadsPopover(keepButtonVisible: false) } - @IBAction func aiChatButtonAction(_ sender: NSButton) { - AIChatTabOpener.openAIChatTab() - } - override func mouseDown(with event: NSEvent) { if let menu = view.menu, NSEvent.isContextClick(event) { NSMenu.popUpContextMenu(menu, with: event, for: view) @@ -513,20 +511,15 @@ final class NavigationBarViewController: NSViewController { guard view.window?.isKeyWindow == true else { return } DispatchQueue.main.async { - let popoverMessage = PopoverMessageViewController(message: UserText.passwordManagerPinnedPromptPopoverText, - buttonText: UserText.passwordManagerPinnedPromptPopoverButtonText, - buttonAction: {}, - onDismiss: { - self.passwordManagementButton.isHidden = !LocalPinningManager.shared.isPinned(.autofill) - }) + self.popovers.showAutofillOnboardingPopover(from: self.passwordManagementButton, + withDelegate: self) { [weak self] didAddShortcut in + guard let self = self else { return } + self.popovers.closeAutofillOnboardingPopover() - popoverMessage.viewModel.buttonAction = { [weak popoverMessage] in - LocalPinningManager.shared.pin(.autofill) - popoverMessage?.dismiss() + if didAddShortcut { + LocalPinningManager.shared.pin(.autofill) + } } - - self.passwordManagementButton.isHidden = false - popoverMessage.show(onParent: self, relativeTo: self.passwordManagementButton) } } @@ -909,18 +902,6 @@ final class NavigationBarViewController: NSViewController { } } - private func updateAIChatButton() { - - let menu = NSMenu() - let title = LocalPinningManager.shared.shortcutTitle(for: .aiChat) - menu.addItem(withTitle: title, action: #selector(toggleAIChatPanelPinning(_:)), keyEquivalent: "") - - aiChatButton.menu = menu - aiChatButton.toolTip = UserText.aiChat - - aiChatButton.isHidden = !(LocalPinningManager.shared.isPinned(.aiChat) && aiChatMenuConfig.isFeatureEnabledForToolbarShortcut) - } - private func subscribeToCredentialsToSave() { credentialsToSaveCancellable = tabCollectionViewModel.selectedTabViewModel?.tab.autofillDataToSavePublisher .receive(on: DispatchQueue.main) @@ -985,6 +966,58 @@ final class NavigationBarViewController: NSViewController { } .store(in: &navigationButtonsCancellables) } + + // MARK: - AI Chat + + private func subscribeToAIChatOnboarding() { + aiChatMenuConfig.shouldDisplayToolbarOnboardingPopover + .receive(on: DispatchQueue.main) + .sink { [weak self] in + guard let self = self else { return } + self.automaticallyShowAIChatOnboardingPopoverIfPossible() + }.store(in: &cancellables) + } + + private func automaticallyShowAIChatOnboardingPopoverIfPossible() { + guard WindowControllersManager.shared.lastKeyMainWindowController?.window === aiChatButton.window else { return } + + popovers.showAIChatOnboardingPopover(from: aiChatButton, + withDelegate: self, + ctaCallback: { [weak self] didAddShortcut in + guard let self = self else { return } + self.popovers.closeAIChatOnboardingPopover() + + if didAddShortcut { + self.showAIChatOnboardingConfirmationPopover() + } + }) + + aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() + } + + private func showAIChatOnboardingConfirmationPopover() { + DispatchQueue.main.async { + let viewController = PopoverMessageViewController(message: UserText.aiChatOnboardingPopoverConfirmation, + image: .successCheckmark) + viewController.show(onParent: self, relativeTo: self.aiChatButton) + } + } + + @IBAction func aiChatButtonAction(_ sender: NSButton) { + AIChatTabOpener.openAIChatTab() + PixelKit.fire(GeneralPixel.aichatToolbarClicked, includeAppVersionParameter: true) + } + + private func updateAIChatButton() { + let menu = NSMenu() + let title = LocalPinningManager.shared.shortcutTitle(for: .aiChat) + menu.addItem(withTitle: title, action: #selector(toggleAIChatPanelPinning(_:)), keyEquivalent: "") + + aiChatButton.menu = menu + aiChatButton.toolTip = UserText.aiChat + + aiChatButton.isHidden = !(LocalPinningManager.shared.isPinned(.aiChat) && aiChatMenuConfig.isFeatureEnabledForToolbarShortcut) + } } extension NavigationBarViewController: NSMenuDelegate { @@ -1191,9 +1224,14 @@ extension NavigationBarViewController: NSPopoverDelegate { } else if let popover = popovers.savePaymentMethodPopover, notification.object as AnyObject? === popover { popovers.savePaymentMethodPopoverClosed() updatePasswordManagementButton() + } else if let popover = popovers.aiChatOnboardingPopover, notification.object as AnyObject? === popover { + popovers.aiChatOnboardingPopoverClosed() + updateAIChatButton() + } else if let popover = popovers.autofillOnboardingPopover, notification.object as AnyObject? === popover { + popovers.autofillOnboardingPopoverClosed() + updatePasswordManagementButton() } } - } extension NavigationBarViewController: DownloadsViewControllerDelegate { diff --git a/DuckDuckGo/Preferences/Model/AIChatPreferences.swift b/DuckDuckGo/Preferences/Model/AIChatPreferences.swift index d9fadda8d0..ae247634a3 100644 --- a/DuckDuckGo/Preferences/Model/AIChatPreferences.swift +++ b/DuckDuckGo/Preferences/Model/AIChatPreferences.swift @@ -78,4 +78,8 @@ final class AIChatPreferences: ObservableObject { @MainActor func openLearnMoreLink() { WindowControllersManager.shared.show(url: learnMoreURL, source: .ui, newTab: true) } + + @MainActor func openAIChatLink() { + AIChatTabOpener.openAIChatTab() + } } diff --git a/DuckDuckGo/Preferences/Model/PreferencesSection.swift b/DuckDuckGo/Preferences/Model/PreferencesSection.swift index 980f798aa4..a97027c40a 100644 --- a/DuckDuckGo/Preferences/Model/PreferencesSection.swift +++ b/DuckDuckGo/Preferences/Model/PreferencesSection.swift @@ -26,7 +26,10 @@ struct PreferencesSection: Hashable, Identifiable { let panes: [PreferencePaneIdentifier] @MainActor - static func defaultSections(includingDuckPlayer: Bool, includingSync: Bool, includingVPN: Bool) -> [PreferencesSection] { + static func defaultSections(includingDuckPlayer: Bool, + includingSync: Bool, + includingVPN: Bool, + includingAIChat: Bool) -> [PreferencesSection] { let privacyPanes: [PreferencePaneIdentifier] = [ .defaultBrowser, .privateSearch, .webTrackingProtection, .cookiePopupProtection, .emailProtection ] @@ -42,6 +45,10 @@ struct PreferencesSection: Hashable, Identifiable { panes.append(.duckPlayer) } + if includingAIChat { + panes.append(.aiChat) + } + return panes }() diff --git a/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift b/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift index 873552457f..c09b9b7d19 100644 --- a/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift +++ b/DuckDuckGo/Preferences/Model/PreferencesSidebarModel.swift @@ -55,18 +55,15 @@ final class PreferencesSidebarModel: ObservableObject { resetTabSelectionIfNeeded() refreshSections() - let duckPlayerFeatureFlagDidChange = privacyConfigurationManager.updatesPublisher - .map { [weak privacyConfigurationManager] in - privacyConfigurationManager?.privacyConfig.isEnabled(featureKey: .duckPlayer) == true - } - .removeDuplicates() - .asVoid() + let duckPlayerFeatureFlagDidChange = featureFlagDidChange(with: privacyConfigurationManager, on: .duckPlayer) + let aiChatFeatureFlagDidChange = featureFlagDidChange(with: privacyConfigurationManager, on: .aiChat) let syncFeatureFlagsDidChange = syncService.featureFlagsPublisher.map { $0.contains(.userInterface) } .removeDuplicates() .asVoid() Publishers.Merge(duckPlayerFeatureFlagDidChange, syncFeatureFlagsDidChange) + .merge(with: aiChatFeatureFlagDidChange) .receive(on: DispatchQueue.main) .sink { [weak self] in self?.refreshSections() @@ -83,6 +80,7 @@ final class PreferencesSidebarModel: ObservableObject { syncService: DDGSyncing, vpnGatekeeper: VPNFeatureGatekeeper, includeDuckPlayer: Bool, + includeAIChat: Bool, userDefaults: UserDefaults = .netP ) { let loadSections = { @@ -91,7 +89,8 @@ final class PreferencesSidebarModel: ObservableObject { return PreferencesSection.defaultSections( includingDuckPlayer: includeDuckPlayer, includingSync: syncService.featureFlags.contains(.userInterface), - includingVPN: includingVPN + includingVPN: includingVPN, + includingAIChat: includeAIChat ) } @@ -139,6 +138,17 @@ final class PreferencesSidebarModel: ObservableObject { // MARK: - Refreshing logic + private func featureFlagDidChange(with privacyConfigurationManager: PrivacyConfigurationManaging, + on featureKey: PrivacyFeature) -> AnyPublisher { + return privacyConfigurationManager.updatesPublisher + .map { [weak privacyConfigurationManager] in + privacyConfigurationManager?.privacyConfig.isEnabled(featureKey: featureKey) == true + } + .removeDuplicates() + .asVoid() + .eraseToAnyPublisher() + } + func refreshSections() { sections = loadSections() if !sections.flatMap(\.panes).contains(selectedPane), let firstPane = sections.first?.panes.first { diff --git a/DuckDuckGo/Preferences/View/PreferencesAIChat.swift b/DuckDuckGo/Preferences/View/PreferencesAIChat.swift index 4cce8719f1..609ec8f257 100644 --- a/DuckDuckGo/Preferences/View/PreferencesAIChat.swift +++ b/DuckDuckGo/Preferences/View/PreferencesAIChat.swift @@ -30,7 +30,23 @@ extension Preferences { TextMenuTitle(UserText.aiChat) PreferencePaneSubSection { VStack(alignment: .leading, spacing: 1) { - TextMenuItemCaption(UserText.aiChatPreferencesCaption) + if #available(macOS 12, *) { + // Use Markdown for macOS 12 and newer + // .init is required for markdown to be correctly parsed from NSLocalizedString + Text(.init(UserText.aiChatPreferencesCaptionWithLinkMarkdown)) + .environment(\.openURL, OpenURLAction { _ in + model.openAIChatLink() + return .handled + }) + .tint(Color(.linkBlue)) + .frame(maxWidth: .infinity, alignment: .leading) + .fixMultilineScrollableText() + .foregroundColor(Color(.greyText)) + } else { + // Fallback for earlier macOS versions + TextMenuItemCaption(UserText.aiChatPreferencesCaptionWithLinkFallback) + } + TextButton(UserText.aiChatPreferencesLearnMoreButton) { model.openLearnMoreLink() } diff --git a/DuckDuckGo/Preferences/View/PreferencesViewController.swift b/DuckDuckGo/Preferences/View/PreferencesViewController.swift index 1d4a53b8ff..e4fca8adb2 100644 --- a/DuckDuckGo/Preferences/View/PreferencesViewController.swift +++ b/DuckDuckGo/Preferences/View/PreferencesViewController.swift @@ -40,13 +40,15 @@ final class PreferencesViewController: NSViewController { syncService: DDGSyncing, duckPlayer: DuckPlayer = DuckPlayer.shared, tabCollectionViewModel: TabCollectionViewModel, - privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager + privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, + aiChatRemoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings() ) { self.tabCollectionViewModel = tabCollectionViewModel self.privacyConfigurationManager = privacyConfigurationManager model = PreferencesSidebarModel(syncService: syncService, vpnGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: Application.appDelegate.subscriptionManager), - includeDuckPlayer: duckPlayer.shouldDisplayPreferencesSideBar) + includeDuckPlayer: duckPlayer.shouldDisplayPreferencesSideBar, + includeAIChat: aiChatRemoteSettings.isAIChatEnabled) super.init(nibName: nil, bundle: nil) } diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index 2f43b9d84a..51cf05406e 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -156,6 +156,13 @@ enum GeneralPixel: PixelKitEventV2 { case networkProtectionGeoswitchingSetCustom case networkProtectionGeoswitchingNoLocations + // AI Chat + case aichatToolbarClicked + case aichatApplicationMenuAppClicked + case aichatApplicationMenuFileClicked + case aichatToolbarOnboardingPopoverShown + case aichatToolbarOnboardingPopoverAccept + case aichatNoRemoteSettingsFound(AIChatRemoteSettings.SettingsValue) // Sync case syncSignupDirect case syncSignupConnect @@ -664,6 +671,20 @@ enum GeneralPixel: PixelKitEventV2 { case .networkProtectionEnabledOnSearch: return "m_mac_netp_ev_enabled_on_search" + // AI Chat + case .aichatToolbarClicked: + return "m_mac_aichat_toolbar-clicked" + case .aichatApplicationMenuAppClicked: + return "m_mac_aichat_application-menu-app-clicked" + case .aichatApplicationMenuFileClicked: + return "m_mac_aichat_application-menu-file-clicked" + case .aichatToolbarOnboardingPopoverShown: + return "m_mac_aichat_toolbar-onboarding-popover-shown" + case .aichatToolbarOnboardingPopoverAccept: + return "m_mac_aichat_toolbar-onboarding-popover-accept" + case .aichatNoRemoteSettingsFound(let settings): + return "m_mac_aichat_no_remote_settings_found-\(settings.rawValue.lowercased())" + // Sync case .syncSignupDirect: return "m_mac_sync_signup_direct" diff --git a/DuckDuckGo/Tab/Model/Tab+Navigation.swift b/DuckDuckGo/Tab/Model/Tab+Navigation.swift index ab3fc72e87..e0afc72ed4 100644 --- a/DuckDuckGo/Tab/Model/Tab+Navigation.swift +++ b/DuckDuckGo/Tab/Model/Tab+Navigation.swift @@ -54,6 +54,9 @@ extension Tab: NavigationResponder { // Duck Player overlay navigations handling .weak(nullable: self.duckPlayer), + // AI Chat onboarding navigations handling + .weak(nullable: self.aiChatOnboarding), + // open external scheme link in another app .weak(nullable: self.externalAppSchemeHandler), diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift new file mode 100644 index 0000000000..e7dc154601 --- /dev/null +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -0,0 +1,104 @@ +// +// AIChatOnboardingTabExtension.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 Navigation +import Foundation +import Combine +import WebKit + +final class AIChatOnboardingTabExtension { + private weak var webView: WKWebView? + private var cancellables = Set() + private let notificationCenter: NotificationCenter + private let remoteSettings: AIChatRemoteSettingsProvider + + init(webViewPublisher: some Publisher, + notificationCenter: NotificationCenter, + remoteSettings: AIChatRemoteSettingsProvider) { + + self.notificationCenter = notificationCenter + self.remoteSettings = remoteSettings + + webViewPublisher.sink { [weak self] webView in + self?.webView = webView + }.store(in: &cancellables) + } + + private func validateAIChatCookie(webView: WKWebView) { + guard let url = webView.url, + url.isDuckDuckGo, + isQueryItemEqualToDuckDuckGoAIChat(url: url) else { + return + } + + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + + cookieStore.getAllCookies { [weak self] cookies in + guard let self = self else { return } + if cookies.contains(where: { $0.isAIChatCookie(settings: self.remoteSettings) }) { + self.notificationCenter.post(name: .AIChatOpenedForReturningUser, object: nil) + } + } + } + + private func isQueryItemEqualToDuckDuckGoAIChat(url: URL) -> Bool { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + if let queryItems = components?.queryItems { + if let queryValue = queryItems.first(where: { $0.name == remoteSettings.aiChatURLIdentifiableQuery })?.value { + return queryValue == remoteSettings.aiChatURLIdentifiableQueryValue + } + } + + return false + } +} + +extension AIChatOnboardingTabExtension: NavigationResponder { + @MainActor func navigationDidFinish(_ navigation: Navigation) { + guard let webView = webView else { return } + validateAIChatCookie(webView: webView) + } + + func navigation(_ navigation: Navigation, didSameDocumentNavigationOf navigationType: WKSameDocumentNavigationType) { + guard let webView = webView else { return } + validateAIChatCookie(webView: webView) + } +} + +protocol AIChatOnboardingProtocol: AnyObject, NavigationResponder { +} + +extension AIChatOnboardingTabExtension: AIChatOnboardingProtocol, TabExtension { + func getPublicProtocol() -> AIChatOnboardingProtocol { self } +} + +extension TabExtensions { + var aiChatOnboarding: AIChatOnboardingProtocol? { + resolve(AIChatOnboardingTabExtension.self) + } +} + +private extension HTTPCookie { + func isAIChatCookie(settings: AIChatRemoteSettingsProvider) -> Bool { + name == settings.onboardingCookieName && domain == settings.onboardingCookieDomain + } +} + +extension NSNotification.Name { + static let AIChatOpenedForReturningUser = NSNotification.Name("aichat.AIChatOpenedForReturningUser") +} diff --git a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift index c72c44f584..38f5f7bc22 100644 --- a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift +++ b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift @@ -201,6 +201,12 @@ extension TabExtensionsBuilder { onboardingDecider: duckPlayerOnboardingDecider) } + add { + AIChatOnboardingTabExtension(webViewPublisher: args.webViewFuture, + notificationCenter: .default, + remoteSettings: AIChatRemoteSettings()) + } + add { SpecialErrorPageTabExtension(webViewPublisher: args.webViewFuture, scriptsPublisher: userScripts.compactMap { $0 }, diff --git a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift index 0e515cb997..42717e340e 100644 --- a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift +++ b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift @@ -23,11 +23,14 @@ import Combine class AIChatMenuConfigurationTests: XCTestCase { var configuration: AIChatMenuConfiguration! var mockStorage: MockAIChatPreferencesStorage! + var remoteSettings: MockRemoteAISettings! override func setUp() { super.setUp() mockStorage = MockAIChatPreferencesStorage() - configuration = AIChatMenuConfiguration(storage: mockStorage) + remoteSettings = MockRemoteAISettings() + configuration = AIChatMenuConfiguration(storage: mockStorage, remoteSettings: remoteSettings) + } override func tearDown() { @@ -38,8 +41,6 @@ class AIChatMenuConfigurationTests: XCTestCase { func testShouldDisplayApplicationMenuShortcut() { mockStorage.showShortcutInApplicationMenu = true - let featureEnabled = true - let result = configuration.shouldDisplayApplicationMenuShortcut XCTAssertTrue(result, "Application menu shortcut should be displayed when enabled.") @@ -47,20 +48,12 @@ class AIChatMenuConfigurationTests: XCTestCase { func testShouldDisplayToolbarShortcut() { mockStorage.shouldDisplayToolbarShortcut = true - let featureEnabled = true let result = configuration.shouldDisplayToolbarShortcut XCTAssertTrue(result, "Toolbar shortcut should be displayed when enabled.") } - func testShortcutURL() { - let url = configuration.shortcutURL - - XCTAssertEqual(url.absoluteString, "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2", "Shortcut URL should match the expected URL.") - } - func testToolbarValuesChangedPublisher() { - // Given let expectation = self.expectation(description: "Values changed publisher should emit a value.") var receivedValue: Void? @@ -95,9 +88,98 @@ class AIChatMenuConfigurationTests: XCTestCase { } cancellable.cancel() } + + func testShouldNotDisplayToolbarShortcutWhenDisabled() { + mockStorage.shouldDisplayToolbarShortcut = false + let result = configuration.shouldDisplayToolbarShortcut + + XCTAssertFalse(result, "Toolbar shortcut should not be displayed when disabled.") + } + + func testMarkToolbarOnboardingPopoverAsShown() { + mockStorage.didDisplayAIChatToolbarOnboarding = false + + configuration.markToolbarOnboardingPopoverAsShown() + + XCTAssertTrue(mockStorage.didDisplayAIChatToolbarOnboarding, "Toolbar onboarding popover should be marked as shown.") + } + + func testReset() { + mockStorage.showShortcutInApplicationMenu = true + mockStorage.shouldDisplayToolbarShortcut = true + mockStorage.didDisplayAIChatToolbarOnboarding = true + + mockStorage.reset() + + XCTAssertFalse(mockStorage.showShortcutInApplicationMenu, "Application menu shortcut should be reset to false.") + XCTAssertFalse(mockStorage.shouldDisplayToolbarShortcut, "Toolbar shortcut should be reset to false.") + XCTAssertFalse(mockStorage.didDisplayAIChatToolbarOnboarding, "Toolbar onboarding popover should be reset to false.") + } + + func testShouldNotDisplayToolbarShortcutWhenRemoteFlagIsTrueAndStorageIsFalse() { + remoteSettings.isToolbarShortcutEnabled = true + mockStorage.shouldDisplayToolbarShortcut = false + + let result = configuration.shouldDisplayToolbarShortcut + + XCTAssertFalse(result, "Toolbar shortcut should not be displayed when remote flag is true and storage is false.") + } + + func testShouldNotDisplayToolbarShortcutWhenRemoteFlagIsFalseAndStorageIsTrue() { + remoteSettings.isToolbarShortcutEnabled = false + mockStorage.shouldDisplayToolbarShortcut = true + + let result = configuration.shouldDisplayToolbarShortcut + + XCTAssertFalse(result, "Toolbar shortcut should not be displayed when remote flag is false, even if storage is true.") + } + + func testShouldNotDisplayApplicationMenuShortcutWhenRemoteFlagIsTrueAndStorageIsFalse() { + remoteSettings.isApplicationMenuShortcutEnabled = true + mockStorage.showShortcutInApplicationMenu = false + + let result = configuration.shouldDisplayApplicationMenuShortcut + + XCTAssertFalse(result, "Application menu shortcut should not be displayed when remote flag is true and storage is false.") + } + + func testShouldNotDisplayApplicationMenuShortcutWhenRemoteFlagIsFalseAndStorageIsTrue() { + remoteSettings.isApplicationMenuShortcutEnabled = false + mockStorage.showShortcutInApplicationMenu = true + + let result = configuration.shouldDisplayApplicationMenuShortcut + + XCTAssertFalse(result, "Application menu shortcut should not be displayed when remote flag is false, even if storage is true.") + } + + func testShouldDisplayToolbarShortcutWhenRemoteFlagAndStorageAreTrue() { + remoteSettings.isToolbarShortcutEnabled = true + mockStorage.shouldDisplayToolbarShortcut = true + + let result = configuration.shouldDisplayToolbarShortcut + + XCTAssertTrue(result, "Toolbar shortcut should be displayed when both remote flag and storage are true.") + } + + func testShouldDisplayApplicationMenuShortcutWhenRemoteFlagAndStorageAreTrue() { + remoteSettings.isApplicationMenuShortcutEnabled = true + mockStorage.showShortcutInApplicationMenu = true + + let result = configuration.shouldDisplayApplicationMenuShortcut + + XCTAssertTrue(result, "Application menu shortcut should be displayed when both remote flag and storage are true.") + } } class MockAIChatPreferencesStorage: AIChatPreferencesStorage { + var didDisplayAIChatToolbarOnboarding: Bool = false + + func reset() { + showShortcutInApplicationMenu = false + shouldDisplayToolbarShortcut = false + didDisplayAIChatToolbarOnboarding = false + } + var showShortcutInApplicationMenu: Bool = false { didSet { showShortcutInApplicationMenuSubject.send(showShortcutInApplicationMenu) @@ -128,4 +210,35 @@ class MockAIChatPreferencesStorage: AIChatPreferencesStorage { func updateToolbarShortcutDisplay(to value: Bool) { shouldDisplayToolbarShortcut = value } + + func markToolbarOnboardingPopoverAsShown() { } +} + +final class MockRemoteAISettings: AIChatRemoteSettingsProvider { + var onboardingCookieName: String + var onboardingCookieDomain: String + var aiChatURLIdentifiableQuery: String + var aiChatURLIdentifiableQueryValue: String + var aiChatURL: URL + var isAIChatEnabled: Bool + var isToolbarShortcutEnabled: Bool + var isApplicationMenuShortcutEnabled: Bool + + init(onboardingCookieName: String = "defaultCookie", + onboardingCookieDomain: String = "defaultdomain.com", + aiChatURLIdentifiableQuery: String = "defaultQuery", + aiChatURLIdentifiableQueryValue: String = "defaultValue", + aiChatURL: URL = URL(string: "https://duck.com/chat")!, + isAIChatEnabled: Bool = true, + isToolbarShortcutEnabled: Bool = true, + isApplicationMenuShortcutEnabled: Bool = true) { + self.onboardingCookieName = onboardingCookieName + self.onboardingCookieDomain = onboardingCookieDomain + self.aiChatURLIdentifiableQuery = aiChatURLIdentifiableQuery + self.aiChatURLIdentifiableQueryValue = aiChatURLIdentifiableQueryValue + self.aiChatURL = aiChatURL + self.isAIChatEnabled = isAIChatEnabled + self.isToolbarShortcutEnabled = isToolbarShortcutEnabled + self.isApplicationMenuShortcutEnabled = isApplicationMenuShortcutEnabled + } } diff --git a/UnitTests/AIChat/AIChatRemoteSettingsTests.swift b/UnitTests/AIChat/AIChatRemoteSettingsTests.swift new file mode 100644 index 0000000000..fe1b1e5b7d --- /dev/null +++ b/UnitTests/AIChat/AIChatRemoteSettingsTests.swift @@ -0,0 +1,209 @@ +// +// AIChatRemoteSettingsTests.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 +import Combine +import BrowserServicesKit + +@testable import DuckDuckGo_Privacy_Browser + +class AIChatRemoteSettingsTests: XCTestCase { + var mockPrivacyConfigurationManager: MockPrivacyConfigurationManager! + var aiChatRemoteSettings: AIChatRemoteSettings! + + private func setupAIChatRemoteSettings(with config: MockConfig) -> AIChatRemoteSettings { + let embeddedDataProvider = MockEmbeddedDataProvider() + embeddedDataProvider.embeddedDataEtag = "12345" + embeddedDataProvider.embeddedData = config.embeddedData + + let manager = PrivacyConfigurationManager(fetchedETag: nil, + fetchedData: nil, + embeddedDataProvider: embeddedDataProvider, + localProtection: MockDomainsProtectionStore(), + internalUserDecider: DefaultInternalUserDecider()) + return AIChatRemoteSettings(privacyConfigurationManager: manager) + } + + func testValidRemoteURL_ThenConfigUsesRemoteURL() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(config.aiChatURL, aiChatRemoteSettings.aiChatURL.absoluteString) + } + + func testInvalidRemoteURL_ThenConfigUsesDefaultURL() { + var config = MockConfig() + config.embeddedData = config.configWithoutSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(AIChatRemoteSettings.SettingsValue.aiChatURL.defaultValue, aiChatRemoteSettings.aiChatURL.absoluteString) + } + + func testOnboardingCookieName_WhenSettingExists_ThenReturnsCorrectValue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(config.cookieName, aiChatRemoteSettings.onboardingCookieName) + } + + func testOnboardingCookieDomain_WhenSettingExists_ThenReturnsCorrectValue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(config.cookieDomain, aiChatRemoteSettings.onboardingCookieDomain) + } + + func testAIChatURLIdentifiableQuery_WhenSettingExists_ThenReturnsCorrectValue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(config.aiChatURLIdentifiableQuery, aiChatRemoteSettings.aiChatURLIdentifiableQuery) + } + + func testAIChatURLIdentifiableQueryValue_WhenSettingExists_ThenReturnsCorrectValue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(config.aiChatURLIdentifiableQueryValue, aiChatRemoteSettings.aiChatURLIdentifiableQueryValue) + } + + func testOnboardingCookieName_WhenSettingDoesNotExist_ThenReturnsDefaultValue() { + var config = MockConfig() + config.embeddedData = config.configWithoutSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertEqual(AIChatRemoteSettings.SettingsValue.cookieName.defaultValue, aiChatRemoteSettings.onboardingCookieName) + } + + func testIsAIChatEnabled_WhenFeatureIsEnabled_ThenReturnsTrue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertTrue(aiChatRemoteSettings.isAIChatEnabled) + } + + func testIsAIChatEnabled_WhenFeatureIsDisabled_ThenReturnsFalse() { + var config = MockConfig() + config.featureStatus = "disabled" + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertFalse(aiChatRemoteSettings.isAIChatEnabled) + } + + func testIsToolbarShortcutEnabled_WhenShortcutIsEnabled_ThenReturnsTrue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertTrue(aiChatRemoteSettings.isToolbarShortcutEnabled) + } + + func testIsToolbarShortcutEnabled_WhenShortcutIsDisabled_ThenReturnsFalse() { + var config = MockConfig() + config.toolbarShortcutStatus = "disabled" + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertFalse(aiChatRemoteSettings.isToolbarShortcutEnabled) + } + + func testIsApplicationMenuShortcutEnabled_WhenShortcutIsEnabled_ThenReturnsTrue() { + var config = MockConfig() + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertTrue(aiChatRemoteSettings.isApplicationMenuShortcutEnabled) + } + + func testIsApplicationMenuShortcutEnabled_WhenShortcutIsDisabled_ThenReturnsFalse() { + var config = MockConfig() + config.applicationMenuShortcutStatus = "disabled" + config.embeddedData = config.configWithSettings + aiChatRemoteSettings = setupAIChatRemoteSettings(with: config) + XCTAssertFalse(aiChatRemoteSettings.isApplicationMenuShortcutEnabled) + } +} + +private struct MockConfig { + var featureStatus = "enabled" + var toolbarShortcutStatus = "enabled" + var applicationMenuShortcutStatus = "enabled" + var aiChatURL = "https://potato.com" + var cookieName = "test0" + var cookieDomain = "duck.com" + var aiChatURLIdentifiableQuery = "test1" + var aiChatURLIdentifiableQueryValue = "test2" + + var embeddedData = Data() + + var configWithSettings: Data { + let jsonString = + """ + { + "readme": "https://github.com/duckduckgo/privacy-configuration", + "version": 1722602607085, + "features": { + "aiChat": { + "state": "\(featureStatus)", + "exceptions": [], + "features": { + "toolbarShortcut": { + "state": "\(toolbarShortcutStatus)" + }, + "applicationMenuShortcut": { + "state": "\(applicationMenuShortcutStatus)" + } + }, + "settings": { + "aiChatURL": "\(aiChatURL)", + "onboardingCookieName": "\(cookieName)", + "onboardingCookieDomain": "\(cookieDomain)", + "aiChatURLIdentifiableQuery": "\(aiChatURLIdentifiableQuery)", + "aiChatURLIdentifiableQueryValue": "\(aiChatURLIdentifiableQueryValue)" + }, + "hash": "64a9f318c4cfd9fc702e641d2a69347b" + } + } + } + """ + return jsonString.data(using: .utf8)! + } + + var configWithoutSettings: Data { + let jsonString = + """ + { + "readme": "https://github.com/duckduckgo/privacy-configuration", + "version": 1722602607085, + "features": { + "aiChat": { + "state": "\(featureStatus)", + "exceptions": [], + "features": { + "toolbarShortcut": { + "state": "\(toolbarShortcutStatus)" + }, + "applicationMenuShortcut": { + "state": "\(applicationMenuShortcutStatus)" + } + }, + "settings": { + }, + "hash": "64a9f318c4cfd9fc702e641d2a69347b" + } + } + } + """ + return jsonString.data(using: .utf8)! + } +} diff --git a/UnitTests/Menus/MainMenuTests.swift b/UnitTests/Menus/MainMenuTests.swift index 9cb500daa6..3765302ff0 100644 --- a/UnitTests/Menus/MainMenuTests.swift +++ b/UnitTests/Menus/MainMenuTests.swift @@ -161,9 +161,13 @@ private class DummyAIChatConfig: AIChatMenuVisibilityConfigurable { var isFeatureEnabledForApplicationMenuShortcut = false var isFeatureEnabledForToolbarShortcut = false - var shortcutURL: URL { URL(string: "https://example.com")! } - var valuesChangedPublisher: PassthroughSubject { return PassthroughSubject() } + + var shouldDisplayToolbarOnboardingPopover: PassthroughSubject { + return PassthroughSubject() + } + + func markToolbarOnboardingPopoverAsShown() { } } diff --git a/UnitTests/Preferences/PreferencesSidebarModelTests.swift b/UnitTests/Preferences/PreferencesSidebarModelTests.swift index 4bb90e4ab0..3b80df5c01 100644 --- a/UnitTests/Preferences/PreferencesSidebarModelTests.swift +++ b/UnitTests/Preferences/PreferencesSidebarModelTests.swift @@ -32,7 +32,7 @@ final class PreferencesSidebarModelTests: XCTestCase { @MainActor private func PreferencesSidebarModel(loadSections: [PreferencesSection]? = nil, tabSwitcherTabs: [Tab.TabContent] = Tab.TabContent.displayableTabTypes) -> DuckDuckGo_Privacy_Browser.PreferencesSidebarModel { return DuckDuckGo_Privacy_Browser.PreferencesSidebarModel( - loadSections: { loadSections ?? PreferencesSection.defaultSections(includingDuckPlayer: false, includingSync: false, includingVPN: false) }, + loadSections: { loadSections ?? PreferencesSection.defaultSections(includingDuckPlayer: false, includingSync: false, includingVPN: false, includingAIChat: false) }, tabSwitcherTabs: tabSwitcherTabs, privacyConfigurationManager: MockPrivacyConfigurationManager(), syncService: MockDDGSyncing(authState: .inactive, isSyncInProgress: false) diff --git a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift new file mode 100644 index 0000000000..f19324ee13 --- /dev/null +++ b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift @@ -0,0 +1,165 @@ +// +// AIChatOnboardingTabExtensionTests.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 BrowserServicesKit +import Combine +import Navigation +import WebKit +import XCTest + +@testable import DuckDuckGo_Privacy_Browser + +class AIChatOnboardingTabExtensionTests: XCTestCase { + var mockWebViewPublisher: PassthroughSubject! + var notificationCenter: NotificationCenter! + var remoteSettings: MockRemoteAISettings! + var onboardingTabExtension: AIChatOnboardingTabExtension! + var webView: WKWebView! + + var validURL: URL { + URL(string: "https://duckduckgo.com/?\(remoteSettings.aiChatURLIdentifiableQuery)=\(remoteSettings.aiChatURLIdentifiableQueryValue)")! + } + + var invalidURL: URL { + URL(string: "https://duckduckgo.com/?wrong=value")! + } + + override func setUp() { + super.setUp() + mockWebViewPublisher = PassthroughSubject() + notificationCenter = NotificationCenter() + remoteSettings = MockRemoteAISettings() + webView = WKWebView() + + onboardingTabExtension = AIChatOnboardingTabExtension( + webViewPublisher: mockWebViewPublisher.eraseToAnyPublisher(), + notificationCenter: notificationCenter, + remoteSettings: remoteSettings + ) + } + + override func tearDown() { + onboardingTabExtension = nil + notificationCenter = nil + remoteSettings = nil + mockWebViewPublisher = nil + webView = nil + super.tearDown() + } + + // MARK: - Tests + + @MainActor + func testNotificationPostedWhenCookieIsPresent() { + let expectation = self.expectation(description: "Notification posted") + notificationCenter.addObserver(forName: .AIChatOpenedForReturningUser, object: nil, queue: .main) { _ in + expectation.fulfill() + } + + mockWebViewPublisher.send(webView) + + webView.loadHTMLString("", baseURL: validURL) + + let cookie = HTTPCookie(properties: [ + .domain: remoteSettings.onboardingCookieDomain, + .path: "/", + .name: remoteSettings.onboardingCookieName, + .value: "testValue", + .expires: NSDate(timeIntervalSinceNow: 3600) + ])! + + webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { + let navigation = Navigation(identity: NavigationIdentity(nil), responders: ResponderChain(), state: .started, isCurrent: true) + self.onboardingTabExtension.navigationDidFinish(navigation) + } + + waitForExpectations(timeout: 1.0, handler: nil) + } + + @MainActor + func testNoNotificationPostedWhenCookieIsAbsent() { + let expectation = self.expectation(description: "Notification not posted") + expectation.isInverted = true + + notificationCenter.addObserver(forName: .AIChatOpenedForReturningUser, object: nil, queue: .main) { _ in + expectation.fulfill() + } + + let webView = WKWebView() + mockWebViewPublisher.send(webView) + + webView.loadHTMLString("", baseURL: validURL) + + let navigation = Navigation(identity: NavigationIdentity(nil), responders: ResponderChain(), state: .started, isCurrent: true) + + self.onboardingTabExtension.navigationDidFinish(navigation) + + waitForExpectations(timeout: 1.0, handler: nil) + } + + @MainActor + func testNotificationPostedWhenCookieIsPresent_ForInvalidURL_ThenNotificationIsNotPosted() { + let expectation = self.expectation(description: "Notification posted for invalid URL") + expectation.isInverted = true + + notificationCenter.addObserver(forName: .AIChatOpenedForReturningUser, object: nil, queue: .main) { _ in + expectation.fulfill() + } + + let invalidWebView = WKWebView() + mockWebViewPublisher.send(invalidWebView) + + invalidWebView.loadHTMLString("", baseURL: invalidURL) + + let cookie = HTTPCookie(properties: [ + .domain: remoteSettings.onboardingCookieDomain, + .path: "/", + .name: remoteSettings.onboardingCookieName, + .value: "testValue", + .expires: NSDate(timeIntervalSinceNow: 3600) + ])! + + invalidWebView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { + let navigation = Navigation(identity: NavigationIdentity(nil), responders: ResponderChain(), state: .started, isCurrent: true) + self.onboardingTabExtension.navigationDidFinish(navigation) + } + + waitForExpectations(timeout: 1.0, handler: nil) + } + + @MainActor + func testNoNotificationPostedWhenCookieIsAbsent_ForInvalidURL_ThenNotificationIsNotPosted() { + let expectation = self.expectation(description: "Notification not posted for invalid URL") + expectation.isInverted = true + + notificationCenter.addObserver(forName: .AIChatOpenedForReturningUser, object: nil, queue: .main) { _ in + expectation.fulfill() + } + + let invalidWebView = WKWebView() + mockWebViewPublisher.send(invalidWebView) + + invalidWebView.loadHTMLString("", baseURL: invalidURL) + + let navigation = Navigation(identity: NavigationIdentity(nil), responders: ResponderChain(), state: .started, isCurrent: true) + + self.onboardingTabExtension.navigationDidFinish(navigation) + + waitForExpectations(timeout: 1.0, handler: nil) + } +}