From adf7945e003320e54ec1a76bb93123ce68e81be0 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 21 Oct 2024 14:29:27 +0100 Subject: [PATCH 01/37] WIP: Add AIChat popover --- DuckDuckGo.xcodeproj/project.pbxproj | 32 +++++++++ .../AIChatOnboardingPopover.swift | 42 +++++++++++ .../AIChatToolBarPopUpOnboardingView.swift | 71 +++++++++++++++++++ ...ToolBarPopUpOnboardingViewController.swift | 39 ++++++++++ ...IChatToolBarPopUpOnboardingViewModel.swift | 41 +++++++++++ DuckDuckGo/Common/Localizables/UserText.swift | 7 ++ .../View/NavigationBarPopovers.swift | 18 +++++ .../View/NavigationBarViewController.swift | 5 +- 8 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 DuckDuckGo/AIChat/OnboardingPoover/AIChatOnboardingPopover.swift create mode 100644 DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift create mode 100644 DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift create mode 100644 DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3178220ce2..ba7a74b981 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -228,6 +228,14 @@ 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 */; }; 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 */; }; @@ -3321,6 +3329,10 @@ 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 = ""; }; 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 = ""; }; @@ -5206,9 +5218,21 @@ path = Services; sourceTree = ""; }; + 314872762CC6898C00EEF89B /* OnboardingPoover */ = { + isa = PBXGroup; + children = ( + 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */, + 314872732CC653C700EEF89B /* AIChatOnboardingPopover.swift */, + 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */, + 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */, + ); + path = OnboardingPoover; + sourceTree = ""; + }; 31521ABE2CC0139C00248E6F /* AIChat */ = { isa = PBXGroup; children = ( + 314872762CC6898C00EEF89B /* OnboardingPoover */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */, 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */, @@ -10774,6 +10798,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 */, @@ -10850,6 +10875,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 */, @@ -11010,6 +11036,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 */, @@ -11133,6 +11160,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 */, @@ -12286,6 +12314,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 */, @@ -12381,6 +12410,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 */, @@ -12393,6 +12423,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 */, @@ -12739,6 +12770,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 */, diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/OnboardingPoover/AIChatOnboardingPopover.swift new file mode 100644 index 0000000000..0fd5d1c601 --- /dev/null +++ b/DuckDuckGo/AIChat/OnboardingPoover/AIChatOnboardingPopover.swift @@ -0,0 +1,42 @@ +// +// 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 { + override init() { + 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.didFinish = { [weak self] in + self?.close() + } + contentViewController = controller + } +} diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift new file mode 100644 index 0000000000..a3d7be774c --- /dev/null +++ b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift @@ -0,0 +1,71 @@ +// +// 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) + + Text(UserText.aiChatOnboardingPopoverMessage1) + + Text(" ") + + Text(UserText.aiChatOnboardingPopoverMessage1).bold() + } + + HStack { + createButton(title: UserText.aiChatOnboardingPopoverCTAReject, + action: viewModel.rejectToolbarIcon, + style: StandardButtonStyle()) + + createButton(title: UserText.aiChatOnboardingPopoverCTAAccept, + action: viewModel.acceptToolbarIcon, + style: DefaultActionButtonStyle(enabled: true)) + } + } + .padding() + .frame(width: Constants.panelWidth, height: Constants.panelHeight) + } + + private func createButton(title: String, action: @escaping () -> Void, style: some ButtonStyle) -> 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/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift new file mode 100644 index 0000000000..cd7cc84c61 --- /dev/null +++ b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift @@ -0,0 +1,39 @@ +// +// 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 didFinish: (() -> 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.rejectAction = didFinish + viewModel.acceptAction = didFinish + } +} diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift new file mode 100644 index 0000000000..e7ec8ab46b --- /dev/null +++ b/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift @@ -0,0 +1,41 @@ +// +// 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. +// + +final class AIChatToolBarPopUpOnboardingViewModel: ObservableObject { + var aiChatStorage: AIChatPreferencesStorage + var rejectAction: (() -> Void)? + var acceptAction: (() -> Void)? + + internal init(aiChatStorage: any AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), + rejectAction: (() -> Void)? = nil, + acceptAction: (() -> Void)? = nil) { + self.aiChatStorage = aiChatStorage + self.rejectAction = rejectAction + self.acceptAction = acceptAction + } + + func rejectToolbarIcon() { + aiChatStorage.shouldDisplayToolbarShortcut = false + rejectAction?() + } + + func acceptToolbarIcon() { + aiChatStorage.shouldDisplayToolbarShortcut = true + acceptAction?() + } +} diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index bdefb2bbc9..1a69e824d2 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -358,6 +358,13 @@ 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 aiChatOnboardingPopoverMessage1 = NSLocalizedString("ai-chat.onboarding.popover.message1", value: "You can adjust this and other AI Chat features in", comment: "AI Chat onboarding popover message") + static let aiChatOnboardingPopoverMessage2 = NSLocalizedString("ai-chat.onboarding.popover.message2", value: "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 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") diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index f2a2e78eac..1e15589c58 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -58,6 +58,7 @@ 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 var privacyDashboardPopover: PrivacyDashboardPopover? private var privacyInfoCancellable: AnyCancellable? @@ -224,9 +225,22 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { privacyDashboardPopover?.close() } + if aiChatOnboardingPopover?.isShown ?? false { + aiChatOnboardingPopover?.close() + } + return true } + func showAIChatOnboardingPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { + guard closeTransientPopovers() else { return } + let popover = aiChatOnboardingPopover ?? AIChatOnboardingPopover() + + popover.delegate = delegate + aiChatOnboardingPopover = popover + show(popover, positionedBelow: button) + } + func showBookmarkListPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate, forTab tab: Tab?) { guard closeTransientPopovers() else { return } @@ -377,6 +391,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { bookmarkListPopover = nil } + func aiChatOnboardingPopoverClosed() { + aiChatOnboardingPopover = nil + } + func saveIdentityPopoverClosed() { saveIdentityPopover = nil } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index c16e62a701..a2f2086666 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -329,7 +329,8 @@ final class NavigationBarViewController: NSViewController { } @IBAction func aiChatButtonAction(_ sender: NSButton) { - AIChatTabOpener.openAIChatTab() +// AIChatTabOpener.openAIChatTab() + popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) } override func mouseDown(with event: NSEvent) { @@ -1185,6 +1186,8 @@ 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() } } From b103228b75212fd1f02d50d10516e5f1561fad57 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 21 Oct 2024 19:44:23 +0100 Subject: [PATCH 02/37] WIP: Tab extension to show toolbar popover --- DuckDuckGo.xcodeproj/project.pbxproj | 12 ++- .../AIChatMenuVisibilityConfigurable.swift | 25 +++++- .../AIChat/AIChatPreferencesStorage.swift | 51 ++++++++---- .../AIChatOnboardingPopover.swift | 0 .../AIChatToolBarPopUpOnboardingView.swift | 0 ...ToolBarPopUpOnboardingViewController.swift | 0 ...IChatToolBarPopUpOnboardingViewModel.swift | 0 .../View/NavigationBarViewController.swift | 52 ++++++++---- DuckDuckGo/Tab/Model/Tab+Navigation.swift | 3 + .../AIChatOnboardingTabExtension.swift | 82 +++++++++++++++++++ .../Tab/TabExtensions/TabExtensions.swift | 4 + 11 files changed, 194 insertions(+), 35 deletions(-) rename DuckDuckGo/AIChat/{OnboardingPoover => OnboardingPopover}/AIChatOnboardingPopover.swift (100%) rename DuckDuckGo/AIChat/{OnboardingPoover => OnboardingPopover}/AIChatToolBarPopUpOnboardingView.swift (100%) rename DuckDuckGo/AIChat/{OnboardingPoover => OnboardingPopover}/AIChatToolBarPopUpOnboardingViewController.swift (100%) rename DuckDuckGo/AIChat/{OnboardingPoover => OnboardingPopover}/AIChatToolBarPopUpOnboardingViewModel.swift (100%) create mode 100644 DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ba7a74b981..3fc4e15532 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -236,6 +236,8 @@ 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 */; }; @@ -3333,6 +3335,7 @@ 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 = ""; }; @@ -5218,7 +5221,7 @@ path = Services; sourceTree = ""; }; - 314872762CC6898C00EEF89B /* OnboardingPoover */ = { + 314872762CC6898C00EEF89B /* OnboardingPopover */ = { isa = PBXGroup; children = ( 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */, @@ -5226,13 +5229,13 @@ 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */, 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */, ); - path = OnboardingPoover; + path = OnboardingPopover; sourceTree = ""; }; 31521ABE2CC0139C00248E6F /* AIChat */ = { isa = PBXGroup; children = ( - 314872762CC6898C00EEF89B /* OnboardingPoover */, + 314872762CC6898C00EEF89B /* OnboardingPopover */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */, 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */, @@ -8660,6 +8663,7 @@ B647EFB32922539400BA628D /* TabExtensions */ = { isa = PBXGroup; children = ( + 3148727D2CC68F6200EEF89B /* AIChatOnboardingTabExtension.swift */, B647EFBA2922584B00BA628D /* AdClickAttributionTabExtension.swift */, B6C00ECA292F839D009C73A6 /* AutofillTabExtension.swift */, B6F1B0212BCE5658005E863C /* BrokenSiteInfoTabExtension.swift */, @@ -11384,6 +11388,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 */, @@ -12378,6 +12383,7 @@ B6C0B23626E732000031CB7F /* DownloadListItem.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 */, diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 404a5246cd..5ccd5ec990 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -27,6 +27,10 @@ protocol AIChatMenuVisibilityConfigurable { var shortcutURL: URL { get } var valuesChangedPublisher: PassthroughSubject { get } + + var shouldDisplayToolbarOnboardingPopover: PassthroughSubject { get } + + func markToolbarOnboardingPopoverAsShown() } final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { @@ -37,8 +41,10 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { private var cancellables = Set() private var storage: AIChatPreferencesStorage + private let notificationCenter: NotificationCenter var valuesChangedPublisher = PassthroughSubject() + var shouldDisplayToolbarOnboardingPopover = PassthroughSubject() var isFeatureEnabledForApplicationMenuShortcut: Bool { isFeatureEnabledFor(shortcutType: .applicationMenu) @@ -60,9 +66,26 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { URL(string: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2")! } - init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage()) { + func markToolbarOnboardingPopoverAsShown() { + storage.didDisplayAIChatToolbarOnboarding = true + } + + init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), + notificationCenter: NotificationCenter = .default) { self.storage = storage + self.notificationCenter = notificationCenter 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 { + self.shouldDisplayToolbarOnboardingPopover.send() + } + }.store(in: &cancellables) } private func subscribeToValuesChanged() { diff --git a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift index ad917a3e10..c44ffc0464 100644 --- a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift +++ b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift @@ -21,18 +21,24 @@ 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 } + } 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 +53,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 +76,49 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { get { userDefaults.showAIChatShortcutInApplicationMenu } set { userDefaults.showAIChatShortcutInApplicationMenu = newValue } } + + var didDisplayAIChatToolbarOnboarding: Bool { + get { userDefaults.didDisplayAIChatToolbarOnboarding } + set { userDefaults.didDisplayAIChatToolbarOnboarding = newValue } + } } 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 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) + } + } - set(newValue, forKey: showAIChatShortcutInApplicationMenuKey) + @objc dynamic var didDisplayAIChatToolbarOnboarding: Bool { + get { + value(forKey: Keys.didDisplayAIChatToolbarOnboardingKey) as? Bool ?? Self.didDisplayAIChatToolbarOnboardingDefaultValue + } + + 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/OnboardingPoover/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/OnboardingPopover/AIChatOnboardingPopover.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPoover/AIChatOnboardingPopover.swift rename to DuckDuckGo/AIChat/OnboardingPopover/AIChatOnboardingPopover.swift diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingView.swift rename to DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewController.swift rename to DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift diff --git a/DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPoover/AIChatToolBarPopUpOnboardingViewModel.swift rename to DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index a2f2086666..d5420f080b 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -183,6 +183,8 @@ final class NavigationBarViewController: NSViewController { #if DEBUG || REVIEW addDebugNotificationListeners() #endif + + subscribeToAIChatOnboarding() } override func viewWillAppear() { @@ -328,11 +330,6 @@ final class NavigationBarViewController: NSViewController { toggleDownloadsPopover(keepButtonVisible: false) } - @IBAction func aiChatButtonAction(_ sender: NSButton) { -// AIChatTabOpener.openAIChatTab() - popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) - } - override func mouseDown(with event: NSEvent) { if let menu = view.menu, NSEvent.isContextClick(event) { NSMenu.popUpContextMenu(menu, with: event, for: view) @@ -904,18 +901,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) @@ -980,6 +965,39 @@ 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.automaticallyShowAIChatOnboardingPopover() + }.store(in: &cancellables) + } + + private func automaticallyShowAIChatOnboardingPopover() { + popovers.showAIChatOnboardingPopover(from: aiChatButton, + withDelegate: self) + aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() + } + + @IBAction func aiChatButtonAction(_ sender: NSButton) { + AIChatTabOpener.openAIChatTab() + popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) + } + + 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 { 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..f366416283 --- /dev/null +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -0,0 +1,82 @@ +// +// 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 + + init(webViewPublisher: some Publisher, + notificationCenter: NotificationCenter = .default) { + + self.notificationCenter = notificationCenter + + webViewPublisher.sink { [weak self] webView in + self?.webView = webView + }.store(in: &cancellables) + } + + private func validateAIChatCookie(webView: WKWebView) { + guard webView.url?.absoluteString == "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2" else { + return + } + + let cookieStore = webView.configuration.websiteDataStore.httpCookieStore + + cookieStore.getAllCookies { [weak self] cookies in + if cookies.contains(where: { $0.isAIChatCookie}) { + self?.notificationCenter.post(name: .AIChatOpenedForReturningUser, object: nil) + } + } + } +} + +extension AIChatOnboardingTabExtension: NavigationResponder { + @MainActor func navigationDidFinish(_ navigation: Navigation) { + 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 { + var isAIChatCookie: Bool { + name == "dcm" && domain == "duckduckgo.com" + } +} + +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..919cfc52d8 100644 --- a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift +++ b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift @@ -201,6 +201,10 @@ extension TabExtensionsBuilder { onboardingDecider: duckPlayerOnboardingDecider) } + add { + AIChatOnboardingTabExtension(webViewPublisher: args.webViewFuture) + } + add { SpecialErrorPageTabExtension(webViewPublisher: args.webViewFuture, scriptsPublisher: userScripts.compactMap { $0 }, From 3f7a4935272e89a7915584f26efee67a90917fcb Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 22 Oct 2024 19:37:48 +0100 Subject: [PATCH 03/37] WIP: Use remote settings for data --- DuckDuckGo.xcodeproj/project.pbxproj | 6 ++ .../AIChatMenuVisibilityConfigurable.swift | 15 ++-- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 73 +++++++++++++++++++ DuckDuckGo/Menus/MainMenu.swift | 12 ++- .../Model/PreferencesSection.swift | 9 ++- .../Model/PreferencesSidebarModel.swift | 24 ++++-- .../View/PreferencesViewController.swift | 7 +- .../AIChatOnboardingTabExtension.swift | 18 +++-- 8 files changed, 138 insertions(+), 26 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatRemoteSettings.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 9d79032178..b457e4221b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -289,6 +289,8 @@ 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 */; }; 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 */; }; @@ -3366,6 +3368,7 @@ 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 = ""; }; 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 = ""; }; @@ -5236,6 +5239,7 @@ isa = PBXGroup; children = ( 314872762CC6898C00EEF89B /* OnboardingPopover */, + 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */, 31521ABF2CC013A400248E6F /* AIChatMenuVisibilityConfigurable.swift */, @@ -10860,6 +10864,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 */, @@ -12477,6 +12482,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 */, diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 5ccd5ec990..9576a8753a 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -17,6 +17,7 @@ // import Combine +import BrowserServicesKit protocol AIChatMenuVisibilityConfigurable { var shouldDisplayApplicationMenuShortcut: Bool { get } @@ -42,6 +43,7 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { private var cancellables = Set() private var storage: AIChatPreferencesStorage private let notificationCenter: NotificationCenter + private let remoteSettings: AIChatRemoteSettings var valuesChangedPublisher = PassthroughSubject() var shouldDisplayToolbarOnboardingPopover = PassthroughSubject() @@ -63,7 +65,7 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { } var shortcutURL: URL { - URL(string: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2")! + remoteSettings.aiChatURL } func markToolbarOnboardingPopoverAsShown() { @@ -71,9 +73,12 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { } init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), - notificationCenter: NotificationCenter = .default) { + notificationCenter: NotificationCenter = .default, + remoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { self.storage = storage self.notificationCenter = notificationCenter + self.remoteSettings = remoteSettings + self.subscribeToValuesChanged() self.subscribeToAIChatLoadedNotification() } @@ -105,11 +110,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/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift new file mode 100644 index 0000000000..e92e820e3c --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -0,0 +1,73 @@ +// +// 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 + +/// 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 { + 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 + } + + var onboardingCookieName: String { + if let cookieName = settings["onboardingCookieName"] as? String { + return cookieName + } else { + // AICHAT-TODO: sendDebugPixel for no name in settings + return "dcm" + } + } + + var onboardingCookieDomain: String { + if let cookieDomain = settings["onboardingCookieDomain"] as? String { + return cookieDomain + } else { + // AICHAT-TODO: sendDebugPixel for no domain in settings + return "duckduckgo.com" + } + } + + var aiChatURL: URL { + if let aiChatURLString = settings["aiChatURL"] as? String, + let aiChatURL = URL(string: aiChatURLString) { + return aiChatURL + } else { + let defaultURL = URL(string: "https://duck.ai")! + // AICHAT-TODO: sendDebugPixel for no URL in settings + return defaultURL + } + } + + var isAIChatEnabled: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) + } + + var isToolbarShortcutEnabled: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.toolbarShortcut) + } + + var isApplicationMenuShortcutEnabled: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.applicationMenuShortcut) + } +} diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 0e282de800..158862723e 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -278,9 +278,7 @@ final class MainMenu: NSMenu { toggleNetworkProtectionShortcutMenuItem - if aiChatMenuConfig.shouldDisplayToolbarShortcut { - toggleAIChatShortcutMenuItem - } + toggleAIChatShortcutMenuItem NSMenuItem.separator() @@ -429,6 +427,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 +584,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) 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/PreferencesViewController.swift b/DuckDuckGo/Preferences/View/PreferencesViewController.swift index 40055bab2f..728f03544a 100644 --- a/DuckDuckGo/Preferences/View/PreferencesViewController.swift +++ b/DuckDuckGo/Preferences/View/PreferencesViewController.swift @@ -33,10 +33,13 @@ final class PreferencesViewController: NSViewController { private var bitwardenManager: BWManagement = BWManager.shared - init(syncService: DDGSyncing, duckPlayer: DuckPlayer = DuckPlayer.shared) { + init(syncService: DDGSyncing, + duckPlayer: DuckPlayer = DuckPlayer.shared, + aiChatRemoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { 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/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index f366416283..37db4ba079 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -25,27 +25,31 @@ final class AIChatOnboardingTabExtension { private weak var webView: WKWebView? private var cancellables = Set() private let notificationCenter: NotificationCenter + private let remoteSettings: AIChatRemoteSettings init(webViewPublisher: some Publisher, - notificationCenter: NotificationCenter = .default) { + notificationCenter: NotificationCenter = .default, + remoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { self.notificationCenter = notificationCenter - + self.remoteSettings = remoteSettings + webViewPublisher.sink { [weak self] webView in self?.webView = webView }.store(in: &cancellables) } private func validateAIChatCookie(webView: WKWebView) { - guard webView.url?.absoluteString == "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2" else { + guard webView.url == remoteSettings.aiChatURL else { return } let cookieStore = webView.configuration.websiteDataStore.httpCookieStore cookieStore.getAllCookies { [weak self] cookies in - if cookies.contains(where: { $0.isAIChatCookie}) { - self?.notificationCenter.post(name: .AIChatOpenedForReturningUser, object: nil) + guard let self = self else { return } + if cookies.contains(where: { $0.isAIChatCookie(settings: self.remoteSettings) }) { + self.notificationCenter.post(name: .AIChatOpenedForReturningUser, object: nil) } } } @@ -72,8 +76,8 @@ extension TabExtensions { } private extension HTTPCookie { - var isAIChatCookie: Bool { - name == "dcm" && domain == "duckduckgo.com" + func isAIChatCookie(settings: AIChatRemoteSettings) -> Bool { + name == settings.onboardingCookieName && domain == settings.onboardingCookieDomain } } From d3b60215447c3d28d41d89dae74911674a5c2efa Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 22 Oct 2024 19:40:53 +0100 Subject: [PATCH 04/37] Remove URL from AIChatMenuVisibilityConfigurable --- DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift | 5 ----- DuckDuckGo/AIChat/AIChatTabOpener.swift | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 9576a8753a..6a3e1f3e4d 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -26,7 +26,6 @@ protocol AIChatMenuVisibilityConfigurable { var isFeatureEnabledForApplicationMenuShortcut: Bool { get } var isFeatureEnabledForToolbarShortcut: Bool { get } - var shortcutURL: URL { get } var valuesChangedPublisher: PassthroughSubject { get } var shouldDisplayToolbarOnboardingPopover: PassthroughSubject { get } @@ -64,10 +63,6 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { return isFeatureEnabledForApplicationMenuShortcut && storage.showShortcutInApplicationMenu } - var shortcutURL: URL { - remoteSettings.aiChatURL - } - func markToolbarOnboardingPopoverAsShown() { storage.didDisplayAIChatToolbarOnboarding = true } 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)) } } From 39e23ef177696ca7d699959a7f1a3d533e02aef2 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 22 Oct 2024 19:57:09 +0100 Subject: [PATCH 05/37] Hide button when dismissing popover --- .../NavigationBar/View/NavigationBarViewController.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index d5420f080b..44a9597a07 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -973,11 +973,13 @@ final class NavigationBarViewController: NSViewController { .receive(on: DispatchQueue.main) .sink { [weak self] in guard let self = self else { return } - self.automaticallyShowAIChatOnboardingPopover() + self.automaticallyShowAIChatOnboardingPopoverIfPossible() }.store(in: &cancellables) } - private func automaticallyShowAIChatOnboardingPopover() { + private func automaticallyShowAIChatOnboardingPopoverIfPossible() { + guard WindowControllersManager.shared.lastKeyMainWindowController?.window === aiChatButton.window else { return } + popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() @@ -985,7 +987,6 @@ final class NavigationBarViewController: NSViewController { @IBAction func aiChatButtonAction(_ sender: NSButton) { AIChatTabOpener.openAIChatTab() - popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) } private func updateAIChatButton() { @@ -1206,6 +1207,7 @@ extension NavigationBarViewController: NSPopoverDelegate { updatePasswordManagementButton() } else if let popover = popovers.aiChatOnboardingPopover, notification.object as AnyObject? === popover { popovers.aiChatOnboardingPopoverClosed() + updateAIChatButton() } } From eadfe31cfdac8d84e4a687021dc7b50c87319ca5 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 22 Oct 2024 20:22:25 +0100 Subject: [PATCH 06/37] Add debug menu --- DuckDuckGo.xcodeproj/project.pbxproj | 6 +++ DuckDuckGo/AIChat/AIChatDebugMenu.swift | 44 +++++++++++++++++++ .../AIChat/AIChatPreferencesStorage.swift | 7 +++ DuckDuckGo/Menus/MainMenu.swift | 2 + 4 files changed, 59 insertions(+) create mode 100644 DuckDuckGo/AIChat/AIChatDebugMenu.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b457e4221b..5f8001ff27 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -291,6 +291,8 @@ 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 */; }; @@ -3369,6 +3371,7 @@ 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 = ""; }; @@ -5238,6 +5241,7 @@ 31521ABE2CC0139C00248E6F /* AIChat */ = { isa = PBXGroup; children = ( + 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */, 314872762CC6898C00EEF89B /* OnboardingPopover */, 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, @@ -10926,6 +10930,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 */, @@ -12831,6 +12836,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 */, diff --git a/DuckDuckGo/AIChat/AIChatDebugMenu.swift b/DuckDuckGo/AIChat/AIChatDebugMenu.swift new file mode 100644 index 0000000000..54d2aba3b9 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatDebugMenu.swift @@ -0,0 +1,44 @@ +// +// 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 { + 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() { + DefaultAIChatPreferencesStorage().reset() + } + + @objc func showToolbarOnboarding() { + var storage = DefaultAIChatPreferencesStorage() + storage.didDisplayAIChatToolbarOnboarding = false + NotificationCenter.default.post(name: .AIChatOpenedForReturningUser, object: nil) + } +} diff --git a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift index c44ffc0464..a070952b2a 100644 --- a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift +++ b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift @@ -26,6 +26,7 @@ protocol AIChatPreferencesStorage { var showShortcutInApplicationMenuPublisher: AnyPublisher { get } var shouldDisplayToolbarShortcutPublisher: AnyPublisher { get } + func reset() } struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { @@ -81,6 +82,12 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { get { userDefaults.didDisplayAIChatToolbarOnboarding } set { userDefaults.didDisplayAIChatToolbarOnboarding = newValue } } + + func reset() { + userDefaults.showAIChatShortcutInApplicationMenu = false + userDefaults.didDisplayAIChatToolbarOnboarding = false + pinningManager.unpin(.aiChat) + } } private extension UserDefaults { diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 158862723e..514d5f2e91 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -706,6 +706,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 From 7ae62f34d28b14cb4a860f3fac6a8035253bb05a Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 22 Oct 2024 20:56:31 +0100 Subject: [PATCH 07/37] Validate query params --- .../AIChatOnboardingTabExtension.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index 37db4ba079..f6ef311ab4 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -40,7 +40,9 @@ final class AIChatOnboardingTabExtension { } private func validateAIChatCookie(webView: WKWebView) { - guard webView.url == remoteSettings.aiChatURL else { + guard let url = webView.url, + url.isDuckDuckGo, + isQueryItemEqualToDuckDuckGoAIChat(url: url) else { return } @@ -55,6 +57,17 @@ final class AIChatOnboardingTabExtension { } } +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 == "q" })?.value { + return queryValue == "DuckDuckGo+AI+Chat" + } + } + + return false +} + extension AIChatOnboardingTabExtension: NavigationResponder { @MainActor func navigationDidFinish(_ navigation: Navigation) { guard let webView = webView else { return } From e1fc373ac796ca8810dbb97e0520166ff8454a5d Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 10:13:26 +0100 Subject: [PATCH 08/37] Fix issue identifying AI Chat url --- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 67 ++++++++++++++----- .../AIChatOnboardingTabExtension.swift | 18 ++--- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index e92e820e3c..06c6a124c8 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -21,6 +21,34 @@ import BrowserServicesKit /// 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 { + enum SettingsValue: String { + case cookieName + case cookieDomain + case aiChatURL + case aiChatURLIdentifiableQuery + case aiChatURLIdentifiableQueryValue + + var settingsKey: String { + switch self { + case .cookieName: "onboardingCookieName" + case .cookieDomain: "onboardingCookieDomain" + case .aiChatURL: "aiChatURL" + case .aiChatURLIdentifiableQuery: "aiChatURLIdentifiableQuery" + case .aiChatURLIdentifiableQueryValue: "aiChatURLIdentifiableQueryValue" + } + } + + var defaultValue: String { + switch self { + case .cookieName: "dcm" + case .cookieDomain: "duckduckgo.com" + case .aiChatURL: "https://duck.ai" + case .aiChatURLIdentifiableQuery: "ia" + case .aiChatURLIdentifiableQueryValue: "chat" + } + } + } + private let privacyConfigurationManager: PrivacyConfigurationManaging private var settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { privacyConfigurationManager.privacyConfig.settings(for: .aiChat) @@ -31,31 +59,36 @@ struct AIChatRemoteSettings { } var onboardingCookieName: String { - if let cookieName = settings["onboardingCookieName"] as? String { - return cookieName - } else { - // AICHAT-TODO: sendDebugPixel for no name in settings - return "dcm" - } + getSettingsData(.cookieName) } var onboardingCookieDomain: String { - if let cookieDomain = settings["onboardingCookieDomain"] as? String { - return cookieDomain + getSettingsData(.cookieDomain) + } + + var aiChatURLIdentifiableQuery: String { + getSettingsData(.aiChatURLIdentifiableQuery) + } + + var aiChatURLIdentifiableQueryValue: String { + getSettingsData(.aiChatURLIdentifiableQueryValue) + } + + var aiChatURL: URL { + let urlString = getSettingsData(.aiChatURL) + if let url = URL(string: urlString) { + return url } else { - // AICHAT-TODO: sendDebugPixel for no domain in settings - return "duckduckgo.com" + return URL(string: SettingsValue.aiChatURL.defaultValue)! } } - var aiChatURL: URL { - if let aiChatURLString = settings["aiChatURL"] as? String, - let aiChatURL = URL(string: aiChatURLString) { - return aiChatURL + private func getSettingsData(_ value: SettingsValue) -> String { + if let value = settings[value.settingsKey] as? String { + return value } else { - let defaultURL = URL(string: "https://duck.ai")! - // AICHAT-TODO: sendDebugPixel for no URL in settings - return defaultURL + //fire pixel value.rawValue + return value.defaultValue } } diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index f6ef311ab4..3f2a2b1d4d 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -33,7 +33,7 @@ final class AIChatOnboardingTabExtension { self.notificationCenter = notificationCenter self.remoteSettings = remoteSettings - + webViewPublisher.sink { [weak self] webView in self?.webView = webView }.store(in: &cancellables) @@ -55,17 +55,17 @@ final class AIChatOnboardingTabExtension { } } } -} -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 == "q" })?.value { - return queryValue == "DuckDuckGo+AI+Chat" + 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 + return false + } } extension AIChatOnboardingTabExtension: NavigationResponder { From cb8044ff9f797057bc0b227b5cd9c9c047823a43 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 10:25:20 +0100 Subject: [PATCH 09/37] improve settings code --- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 76 ++++++++------------ 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index 06c6a124c8..a29e209ddb 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -22,29 +22,19 @@ import BrowserServicesKit /// It also fire pixels when necessary data is missing. struct AIChatRemoteSettings { enum SettingsValue: String { - case cookieName - case cookieDomain - case aiChatURL - case aiChatURLIdentifiableQuery - case aiChatURLIdentifiableQueryValue - - var settingsKey: String { - switch self { - case .cookieName: "onboardingCookieName" - case .cookieDomain: "onboardingCookieDomain" - case .aiChatURL: "aiChatURL" - case .aiChatURLIdentifiableQuery: "aiChatURLIdentifiableQuery" - case .aiChatURLIdentifiableQueryValue: "aiChatURLIdentifiableQueryValue" - } - } + case cookieName = "onboardingCookieName" + case cookieDomain = "onboardingCookieDomain" + case aiChatURL = "aiChatURL" + case aiChatURLIdentifiableQuery = "aiChatURLIdentifiableQuery" + case aiChatURLIdentifiableQueryValue = "aiChatURLIdentifiableQueryValue" var defaultValue: String { switch self { - case .cookieName: "dcm" - case .cookieDomain: "duckduckgo.com" - case .aiChatURL: "https://duck.ai" - case .aiChatURLIdentifiableQuery: "ia" - case .aiChatURLIdentifiableQueryValue: "chat" + case .cookieName: return "dcm" + case .cookieDomain: return "duckduckgo.com" + case .aiChatURL: return "https://duck.ai" + case .aiChatURLIdentifiableQuery: return "ia" + case .aiChatURLIdentifiableQueryValue: return "chat" } } } @@ -58,38 +48,18 @@ struct AIChatRemoteSettings { self.privacyConfigurationManager = privacyConfigurationManager } - var onboardingCookieName: String { - getSettingsData(.cookieName) - } + // MARK: - Public - var onboardingCookieDomain: String { - getSettingsData(.cookieDomain) - } - - var aiChatURLIdentifiableQuery: String { - getSettingsData(.aiChatURLIdentifiableQuery) - } - - var aiChatURLIdentifiableQueryValue: String { - getSettingsData(.aiChatURLIdentifiableQueryValue) - } + var onboardingCookieName: String { getSettingsData(.cookieName) } + var onboardingCookieDomain: String { getSettingsData(.cookieDomain) } + var aiChatURLIdentifiableQuery: String { getSettingsData(.aiChatURLIdentifiableQuery) } + var aiChatURLIdentifiableQueryValue: String { getSettingsData(.aiChatURLIdentifiableQueryValue) } var aiChatURL: URL { - let urlString = getSettingsData(.aiChatURL) - if let url = URL(string: urlString) { - return url - } else { + guard let url = URL(string: getSettingsData(.aiChatURL)) else { return URL(string: SettingsValue.aiChatURL.defaultValue)! } - } - - private func getSettingsData(_ value: SettingsValue) -> String { - if let value = settings[value.settingsKey] as? String { - return value - } else { - //fire pixel value.rawValue - return value.defaultValue - } + return url } var isAIChatEnabled: Bool { @@ -103,4 +73,16 @@ struct AIChatRemoteSettings { 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 { + // Fire unique pixel for value.rawValue + print("FIRE \(value.rawValue)") + return value.defaultValue + } + } } From d2b0672176f6febff257d872388cc02cbe34274c Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 11:55:43 +0100 Subject: [PATCH 10/37] WIP: Confirmation popover --- DuckDuckGo.xcodeproj/project.pbxproj | 24 ++++++- .../AIChatOnboardingConfirmationPopover.swift | 65 +++++++++++++++++++ .../AIChatOnboardingPopover.swift | 0 .../AIChatToolBarPopUpOnboardingView.swift | 0 ...ToolBarPopUpOnboardingViewController.swift | 0 ...IChatToolBarPopUpOnboardingViewModel.swift | 0 .../View/NavigationBarPopovers.swift | 18 +++++ .../View/NavigationBarViewController.swift | 8 ++- 8 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift rename DuckDuckGo/AIChat/{ => Onboarding}/OnboardingPopover/AIChatOnboardingPopover.swift (100%) rename DuckDuckGo/AIChat/{ => Onboarding}/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift (100%) rename DuckDuckGo/AIChat/{ => Onboarding}/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift (100%) rename DuckDuckGo/AIChat/{ => Onboarding}/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift (100%) diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5f8001ff27..32ae7f68ae 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -222,6 +222,8 @@ 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 */; }; + 31031EB42CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */; }; + 31031EB52CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.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 */; }; @@ -3332,6 +3334,7 @@ 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 = ""; }; + 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatOnboardingConfirmationPopover.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 = ""; }; @@ -5227,6 +5230,23 @@ path = Services; sourceTree = ""; }; + 31031EAB2CC8FFC100684340 /* OnboardingConfirmationPopover */ = { + isa = PBXGroup; + children = ( + 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */, + ); + path = OnboardingConfirmationPopover; + sourceTree = ""; + }; + 31031EAC2CC8FFCB00684340 /* Onboarding */ = { + isa = PBXGroup; + children = ( + 31031EAB2CC8FFC100684340 /* OnboardingConfirmationPopover */, + 314872762CC6898C00EEF89B /* OnboardingPopover */, + ); + path = Onboarding; + sourceTree = ""; + }; 314872762CC6898C00EEF89B /* OnboardingPopover */ = { isa = PBXGroup; children = ( @@ -5241,8 +5261,8 @@ 31521ABE2CC0139C00248E6F /* AIChat */ = { isa = PBXGroup; children = ( + 31031EAC2CC8FFCB00684340 /* Onboarding */, 319FCFF42CC83003004F9288 /* AIChatDebugMenu.swift */, - 314872762CC6898C00EEF89B /* OnboardingPopover */, 319FCFF12CC81D54004F9288 /* AIChatRemoteSettings.swift */, 316C48EE2CC2B231000B08C1 /* AIChatPreferencesStorage.swift */, 31521AC22CC01BC300248E6F /* AIChatTabOpener.swift */, @@ -11439,6 +11459,7 @@ B69A14FB2B4D705D00B9417D /* BookmarkFolderPicker.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, + 31031EB52CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckURLSchemeHandler.swift in Sources */, 37445F9A2A1566420029F789 /* SyncDataProviders.swift in Sources */, 3706FC6F293F65D500E42796 /* FirePopoverViewModel.swift in Sources */, @@ -12632,6 +12653,7 @@ 31C9ADE52AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */, AA222CB92760F74E00321475 /* FaviconReferenceCache.swift in Sources */, 4B9292A126670D2A00AD2C21 /* BookmarkTreeController.swift in Sources */, + 31031EB42CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */, 4B29759728281F0900187C4E /* FirefoxEncryptionKeyReader.swift in Sources */, 4B4D60C22A0C849000BCD287 /* EventMapping+NetworkProtectionError.swift in Sources */, 4B9292D02667123700AD2C21 /* BookmarkManagementSplitViewController.swift in Sources */, diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift new file mode 100644 index 0000000000..3c99efb3c9 --- /dev/null +++ b/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift @@ -0,0 +1,65 @@ +// +// AIChatOnboardingConfirmationPopover.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 AIChatOnboardingConfirmationPopover: NSPopover { + override init() { + super.init() + + self.animates = true + self.behavior = .semitransient + + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("\(Self.self): Bad initializer") + } + + private func setupContentController() { + let controller = AIChatToolBarPopUpOnboardingConfirmationViewController() + contentViewController = controller + } +} + +final private class AIChatToolBarPopUpOnboardingConfirmationViewController: NSViewController { + static let preferredContentSize = CGSize(width: 200, height: 40) + + private var hostingView: NSHostingView! + + override func loadView() { + let onboardingView = AIChatToolBarPopUpConfirmationView() + hostingView = NSHostingView(rootView: onboardingView) + self.view = hostingView + } + + override func viewDidLoad() { + super.viewDidLoad() + + preferredContentSize = Self.preferredContentSize + } +} + +private struct AIChatToolBarPopUpConfirmationView: View { + var body: some View { + Text("AI Chat Shortcut Added!") + .font(.headline) + .padding() + } +} diff --git a/DuckDuckGo/AIChat/OnboardingPopover/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatOnboardingPopover.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPopover/AIChatOnboardingPopover.swift rename to DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatOnboardingPopover.swift diff --git a/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift rename to DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift diff --git a/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift rename to DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift diff --git a/DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift similarity index 100% rename from DuckDuckGo/AIChat/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift rename to DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 1e15589c58..35642e40d8 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -59,6 +59,7 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { private(set) var autofillPopoverPresenter: AutofillPopoverPresenter private(set) var downloadsPopover: DownloadsPopover? private(set) var aiChatOnboardingPopover: AIChatOnboardingPopover? + private(set) var aiChatOnboardingConfirmationPopover: AIChatOnboardingConfirmationPopover? private var privacyDashboardPopover: PrivacyDashboardPopover? private var privacyInfoCancellable: AnyCancellable? @@ -229,6 +230,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover?.close() } + if aiChatOnboardingConfirmationPopover?.isShown ?? false { + aiChatOnboardingConfirmationPopover?.close() + } + return true } @@ -241,6 +246,15 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { show(popover, positionedBelow: button) } + func showAIChatOnboardingConfirmationPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { + guard closeTransientPopovers() else { return } + let popover = aiChatOnboardingConfirmationPopover ?? AIChatOnboardingConfirmationPopover() + + popover.delegate = delegate + aiChatOnboardingConfirmationPopover = popover + show(popover, positionedBelow: button) + } + func showBookmarkListPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate, forTab tab: Tab?) { guard closeTransientPopovers() else { return } @@ -395,6 +409,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover = nil } + func aiChatOnboardingConfirmationPopoverClosed() { + aiChatOnboardingConfirmationPopover = nil + } + func saveIdentityPopoverClosed() { saveIdentityPopover = nil } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 44a9597a07..8efdce9cc9 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -980,8 +980,10 @@ final class NavigationBarViewController: NSViewController { private func automaticallyShowAIChatOnboardingPopoverIfPossible() { guard WindowControllersManager.shared.lastKeyMainWindowController?.window === aiChatButton.window else { return } - popovers.showAIChatOnboardingPopover(from: aiChatButton, - withDelegate: self) + popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) + + // popovers.showAIChatOnboardingConfirmationPopover(from: aiChatButton, withDelegate: self) + aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() } @@ -1208,6 +1210,8 @@ extension NavigationBarViewController: NSPopoverDelegate { } else if let popover = popovers.aiChatOnboardingPopover, notification.object as AnyObject? === popover { popovers.aiChatOnboardingPopoverClosed() updateAIChatButton() + } else if let popover = popovers.aiChatOnboardingConfirmationPopover, notification.object as AnyObject? === popover { + popovers.aiChatOnboardingConfirmationPopoverClosed() } } From 577d88ab75a6899c84ce15d72cc35c05a5724f9e Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 15:29:01 +0100 Subject: [PATCH 11/37] Use PopoverMessageViewController --- DuckDuckGo.xcodeproj/project.pbxproj | 24 +------ .../AIChatOnboardingPopover.swift | 0 .../AIChatToolBarPopUpOnboardingView.swift | 0 ...ToolBarPopUpOnboardingViewController.swift | 0 ...IChatToolBarPopUpOnboardingViewModel.swift | 0 .../AIChatOnboardingConfirmationPopover.swift | 65 ------------------- .../View/NavigationBarPopovers.swift | 18 ----- .../View/NavigationBarViewController.swift | 15 +++-- 8 files changed, 11 insertions(+), 111 deletions(-) rename DuckDuckGo/AIChat/Onboarding/{OnboardingPopover => }/AIChatOnboardingPopover.swift (100%) rename DuckDuckGo/AIChat/Onboarding/{OnboardingPopover => }/AIChatToolBarPopUpOnboardingView.swift (100%) rename DuckDuckGo/AIChat/Onboarding/{OnboardingPopover => }/AIChatToolBarPopUpOnboardingViewController.swift (100%) rename DuckDuckGo/AIChat/Onboarding/{OnboardingPopover => }/AIChatToolBarPopUpOnboardingViewModel.swift (100%) delete mode 100644 DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 32ae7f68ae..8708552096 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -222,8 +222,6 @@ 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 */; }; - 31031EB42CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */; }; - 31031EB52CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.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 */; }; @@ -3334,7 +3332,6 @@ 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 = ""; }; - 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatOnboardingConfirmationPopover.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 = ""; }; @@ -5230,24 +5227,7 @@ path = Services; sourceTree = ""; }; - 31031EAB2CC8FFC100684340 /* OnboardingConfirmationPopover */ = { - isa = PBXGroup; - children = ( - 31031EB32CC9015200684340 /* AIChatOnboardingConfirmationPopover.swift */, - ); - path = OnboardingConfirmationPopover; - sourceTree = ""; - }; 31031EAC2CC8FFCB00684340 /* Onboarding */ = { - isa = PBXGroup; - children = ( - 31031EAB2CC8FFC100684340 /* OnboardingConfirmationPopover */, - 314872762CC6898C00EEF89B /* OnboardingPopover */, - ); - path = Onboarding; - sourceTree = ""; - }; - 314872762CC6898C00EEF89B /* OnboardingPopover */ = { isa = PBXGroup; children = ( 314872772CC689AD00EEF89B /* AIChatToolBarPopUpOnboardingViewController.swift */, @@ -5255,7 +5235,7 @@ 3148727A2CC689C200EEF89B /* AIChatToolBarPopUpOnboardingViewModel.swift */, 314872392CC64A5400EEF89B /* AIChatToolBarPopUpOnboardingView.swift */, ); - path = OnboardingPopover; + path = Onboarding; sourceTree = ""; }; 31521ABE2CC0139C00248E6F /* AIChat */ = { @@ -11459,7 +11439,6 @@ B69A14FB2B4D705D00B9417D /* BookmarkFolderPicker.swift in Sources */, 3706FC6C293F65D500E42796 /* BookmarkViewModel.swift in Sources */, 3706FC6D293F65D500E42796 /* DaxSpeech.swift in Sources */, - 31031EB52CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */, 3706FC6E293F65D500E42796 /* DuckURLSchemeHandler.swift in Sources */, 37445F9A2A1566420029F789 /* SyncDataProviders.swift in Sources */, 3706FC6F293F65D500E42796 /* FirePopoverViewModel.swift in Sources */, @@ -12653,7 +12632,6 @@ 31C9ADE52AF0564500CEF57D /* WaitlistFeatureSetupHandler.swift in Sources */, AA222CB92760F74E00321475 /* FaviconReferenceCache.swift in Sources */, 4B9292A126670D2A00AD2C21 /* BookmarkTreeController.swift in Sources */, - 31031EB42CC9015300684340 /* AIChatOnboardingConfirmationPopover.swift in Sources */, 4B29759728281F0900187C4E /* FirefoxEncryptionKeyReader.swift in Sources */, 4B4D60C22A0C849000BCD287 /* EventMapping+NetworkProtectionError.swift in Sources */, 4B9292D02667123700AD2C21 /* BookmarkManagementSplitViewController.swift in Sources */, diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift similarity index 100% rename from DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatOnboardingPopover.swift rename to DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift similarity index 100% rename from DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingView.swift rename to DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift similarity index 100% rename from DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewController.swift rename to DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift similarity index 100% rename from DuckDuckGo/AIChat/Onboarding/OnboardingPopover/AIChatToolBarPopUpOnboardingViewModel.swift rename to DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift diff --git a/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift b/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift deleted file mode 100644 index 3c99efb3c9..0000000000 --- a/DuckDuckGo/AIChat/Onboarding/OnboardingConfirmationPopover/AIChatOnboardingConfirmationPopover.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// AIChatOnboardingConfirmationPopover.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 AIChatOnboardingConfirmationPopover: NSPopover { - override init() { - super.init() - - self.animates = true - self.behavior = .semitransient - - setupContentController() - } - - required init?(coder: NSCoder) { - fatalError("\(Self.self): Bad initializer") - } - - private func setupContentController() { - let controller = AIChatToolBarPopUpOnboardingConfirmationViewController() - contentViewController = controller - } -} - -final private class AIChatToolBarPopUpOnboardingConfirmationViewController: NSViewController { - static let preferredContentSize = CGSize(width: 200, height: 40) - - private var hostingView: NSHostingView! - - override func loadView() { - let onboardingView = AIChatToolBarPopUpConfirmationView() - hostingView = NSHostingView(rootView: onboardingView) - self.view = hostingView - } - - override func viewDidLoad() { - super.viewDidLoad() - - preferredContentSize = Self.preferredContentSize - } -} - -private struct AIChatToolBarPopUpConfirmationView: View { - var body: some View { - Text("AI Chat Shortcut Added!") - .font(.headline) - .padding() - } -} diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 35642e40d8..1e15589c58 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -59,7 +59,6 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { private(set) var autofillPopoverPresenter: AutofillPopoverPresenter private(set) var downloadsPopover: DownloadsPopover? private(set) var aiChatOnboardingPopover: AIChatOnboardingPopover? - private(set) var aiChatOnboardingConfirmationPopover: AIChatOnboardingConfirmationPopover? private var privacyDashboardPopover: PrivacyDashboardPopover? private var privacyInfoCancellable: AnyCancellable? @@ -230,10 +229,6 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover?.close() } - if aiChatOnboardingConfirmationPopover?.isShown ?? false { - aiChatOnboardingConfirmationPopover?.close() - } - return true } @@ -246,15 +241,6 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { show(popover, positionedBelow: button) } - func showAIChatOnboardingConfirmationPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { - guard closeTransientPopovers() else { return } - let popover = aiChatOnboardingConfirmationPopover ?? AIChatOnboardingConfirmationPopover() - - popover.delegate = delegate - aiChatOnboardingConfirmationPopover = popover - show(popover, positionedBelow: button) - } - func showBookmarkListPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate, forTab tab: Tab?) { guard closeTransientPopovers() else { return } @@ -409,10 +395,6 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover = nil } - func aiChatOnboardingConfirmationPopoverClosed() { - aiChatOnboardingConfirmationPopover = nil - } - func saveIdentityPopoverClosed() { saveIdentityPopover = nil } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 8efdce9cc9..00fc19b9c5 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -980,11 +980,18 @@ final class NavigationBarViewController: NSViewController { private func automaticallyShowAIChatOnboardingPopoverIfPossible() { guard WindowControllersManager.shared.lastKeyMainWindowController?.window === aiChatButton.window else { return } - popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) + showAIChatOnboardingConfirmationPopover() - // popovers.showAIChatOnboardingConfirmationPopover(from: aiChatButton, withDelegate: self) + //popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) + //aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() + } - aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() + private func showAIChatOnboardingConfirmationPopover() { + DispatchQueue.main.async { + let viewController = PopoverMessageViewController(message: "AI Chat Shortcut Added!", + image: .successCheckmark) + viewController.show(onParent: self, relativeTo: self.aiChatButton) + } } @IBAction func aiChatButtonAction(_ sender: NSButton) { @@ -1210,8 +1217,6 @@ extension NavigationBarViewController: NSPopoverDelegate { } else if let popover = popovers.aiChatOnboardingPopover, notification.object as AnyObject? === popover { popovers.aiChatOnboardingPopoverClosed() updateAIChatButton() - } else if let popover = popovers.aiChatOnboardingConfirmationPopover, notification.object as AnyObject? === popover { - popovers.aiChatOnboardingConfirmationPopoverClosed() } } From 651d6041880ac40a97425d76f10099818a4cd152 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 16:09:03 +0100 Subject: [PATCH 12/37] Fix callbacks --- .../Onboarding/AIChatOnboardingPopover.swift | 10 ++++++---- .../AIChatToolBarPopUpOnboardingView.swift | 10 +++++----- ...IChatToolBarPopUpOnboardingViewController.swift | 5 ++--- .../AIChatToolBarPopUpOnboardingViewModel.swift | 13 +++++-------- .../NavigationBar/View/NavigationBarPopovers.swift | 10 ++++++++-- .../View/NavigationBarViewController.swift | 14 +++++++++++--- 6 files changed, 37 insertions(+), 25 deletions(-) diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift b/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift index 0fd5d1c601..a9a57d834c 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatOnboardingPopover.swift @@ -19,7 +19,11 @@ import SwiftUI final class AIChatOnboardingPopover: NSPopover { - override init() { + let ctaCallback: (Bool) -> Void + + init(ctaCallback: @escaping (Bool) -> Void) { + self.ctaCallback = ctaCallback + super.init() self.animates = false @@ -34,9 +38,7 @@ final class AIChatOnboardingPopover: NSPopover { private func setupContentController() { let controller = AIChatToolBarPopUpOnboardingViewController() - controller.didFinish = { [weak self] in - self?.close() - } + controller.ctaCallback = self.ctaCallback contentViewController = controller } } diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift index a3d7be774c..13ee8330d7 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift @@ -41,19 +41,19 @@ struct AIChatToolBarPopUpOnboardingView: View { HStack { createButton(title: UserText.aiChatOnboardingPopoverCTAReject, - action: viewModel.rejectToolbarIcon, - style: StandardButtonStyle()) + style: StandardButtonStyle(), + action: viewModel.rejectToolbarIcon) createButton(title: UserText.aiChatOnboardingPopoverCTAAccept, - action: viewModel.acceptToolbarIcon, - style: DefaultActionButtonStyle(enabled: true)) + style: DefaultActionButtonStyle(enabled: true), + action: viewModel.acceptToolbarIcon) } } .padding() .frame(width: Constants.panelWidth, height: Constants.panelHeight) } - private func createButton(title: String, action: @escaping () -> Void, style: some ButtonStyle) -> some View { + private func createButton(title: String, style: some ButtonStyle, action: @escaping () -> Void) -> some View { Button(action: action) { Text(title) .font(.system(size: 13)) diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift index cd7cc84c61..147382d435 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewController.swift @@ -19,7 +19,7 @@ import SwiftUI final class AIChatToolBarPopUpOnboardingViewController: NSViewController { - var didFinish: (() -> Void)? + var ctaCallback: ((Bool) -> Void)? private let viewModel = AIChatToolBarPopUpOnboardingViewModel() private var hostingView: NSHostingView! @@ -33,7 +33,6 @@ final class AIChatToolBarPopUpOnboardingViewController: NSViewController { } private func setupViewModelCallbacks() { - viewModel.rejectAction = didFinish - viewModel.acceptAction = didFinish + viewModel.ctaCallback = ctaCallback } } diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift index e7ec8ab46b..c8d2ef58ea 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift @@ -18,24 +18,21 @@ final class AIChatToolBarPopUpOnboardingViewModel: ObservableObject { var aiChatStorage: AIChatPreferencesStorage - var rejectAction: (() -> Void)? - var acceptAction: (() -> Void)? + var ctaCallback: ((Bool) -> Void)? internal init(aiChatStorage: any AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), - rejectAction: (() -> Void)? = nil, - acceptAction: (() -> Void)? = nil) { + ctaCallback: ((Bool) -> Void)? = nil) { self.aiChatStorage = aiChatStorage - self.rejectAction = rejectAction - self.acceptAction = acceptAction + self.ctaCallback = ctaCallback } func rejectToolbarIcon() { aiChatStorage.shouldDisplayToolbarShortcut = false - rejectAction?() + ctaCallback?(false) } func acceptToolbarIcon() { aiChatStorage.shouldDisplayToolbarShortcut = true - acceptAction?() + ctaCallback?(true) } } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index 1e15589c58..67b587be0f 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -232,9 +232,11 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { return true } - func showAIChatOnboardingPopover(from button: MouseOverButton, withDelegate delegate: NSPopoverDelegate) { + func showAIChatOnboardingPopover(from button: MouseOverButton, + withDelegate delegate: NSPopoverDelegate, + ctaCallback: @escaping (Bool) -> Void) { guard closeTransientPopovers() else { return } - let popover = aiChatOnboardingPopover ?? AIChatOnboardingPopover() + let popover = aiChatOnboardingPopover ?? AIChatOnboardingPopover(ctaCallback: ctaCallback) popover.delegate = delegate aiChatOnboardingPopover = popover @@ -286,6 +288,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { zoomPopover?.close() } + func closeAIChatOnboardingPopover() { + aiChatOnboardingPopover?.close() + } + func openPrivacyDashboard(for tabViewModel: TabViewModel, from button: MouseOverButton) { guard closeTransientPopovers() else { return } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 00fc19b9c5..35393644cc 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -980,10 +980,18 @@ final class NavigationBarViewController: NSViewController { private func automaticallyShowAIChatOnboardingPopoverIfPossible() { guard WindowControllersManager.shared.lastKeyMainWindowController?.window === aiChatButton.window else { return } - showAIChatOnboardingConfirmationPopover() + popovers.showAIChatOnboardingPopover(from: aiChatButton, + withDelegate: self, + ctaCallback: { [weak self] didAddShortcut in + guard let self = self else { return } + self.popovers.closeAIChatOnboardingPopover() + + if didAddShortcut { + self.showAIChatOnboardingConfirmationPopover() + } + }) - //popovers.showAIChatOnboardingPopover(from: aiChatButton, withDelegate: self) - //aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() + aiChatMenuConfig.markToolbarOnboardingPopoverAsShown() } private func showAIChatOnboardingConfirmationPopover() { From d922706af73e19324bb2618a9cfb1f0043735178 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 16:20:16 +0100 Subject: [PATCH 13/37] Add text to userText --- DuckDuckGo/Common/Localizables/UserText.swift | 1 + DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 1a69e824d2..b986cab129 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -363,6 +363,7 @@ struct UserText { static let aiChatOnboardingPopoverMessage2 = NSLocalizedString("ai-chat.onboarding.popover.message2", value: "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") diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 35393644cc..59e29a9c8e 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -996,7 +996,7 @@ final class NavigationBarViewController: NSViewController { private func showAIChatOnboardingConfirmationPopover() { DispatchQueue.main.async { - let viewController = PopoverMessageViewController(message: "AI Chat Shortcut Added!", + let viewController = PopoverMessageViewController(message: UserText.aiChatOnboardingPopoverConfirmation, image: .successCheckmark) viewController.show(onParent: self, relativeTo: self.aiChatButton) } From 3070e3f4a16cd7788b553e81937a053066a5a4ed Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 17:01:05 +0100 Subject: [PATCH 14/37] Add settings tests --- DuckDuckGo.xcodeproj/project.pbxproj | 6 + DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 1 - .../AIChat/AIChatMenuConfigurationTests.swift | 15 +- .../AIChat/AIChatRemoteSettingsTests.swift | 209 ++++++++++++++++++ UnitTests/Menus/MainMenuTests.swift | 8 +- .../PreferencesSidebarModelTests.swift | 2 +- 6 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 UnitTests/AIChat/AIChatRemoteSettingsTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 8708552096..fa50b99696 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -222,6 +222,8 @@ 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 */; }; 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 */; }; @@ -3332,6 +3334,7 @@ 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 = ""; }; 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 = ""; }; @@ -5359,6 +5362,7 @@ 31F25EFD2CC3C9F9002F9084 /* AIChat */ = { isa = PBXGroup; children = ( + 31031EB62CC94C6C00684340 /* AIChatRemoteSettingsTests.swift */, 31F25EFE2CC3CA00002F9084 /* AIChatMenuConfigurationTests.swift */, ); path = AIChat; @@ -11686,6 +11690,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 */, 3706FE2D293F661700E42796 /* ChromiumFaviconsReaderTests.swift in Sources */, @@ -13410,6 +13415,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/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index a29e209ddb..11257fb1a7 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -81,7 +81,6 @@ struct AIChatRemoteSettings { return value } else { // Fire unique pixel for value.rawValue - print("FIRE \(value.rawValue)") return value.defaultValue } } diff --git a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift index 0e515cb997..6cd4455d1e 100644 --- a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift +++ b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift @@ -38,8 +38,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,18 +45,11 @@ 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.") @@ -98,6 +89,10 @@ class AIChatMenuConfigurationTests: XCTestCase { } class MockAIChatPreferencesStorage: AIChatPreferencesStorage { + var didDisplayAIChatToolbarOnboarding: Bool = false + + func reset() { } + var showShortcutInApplicationMenu: Bool = false { didSet { showShortcutInApplicationMenuSubject.send(showShortcutInApplicationMenu) @@ -128,4 +123,6 @@ class MockAIChatPreferencesStorage: AIChatPreferencesStorage { func updateToolbarShortcutDisplay(to value: Bool) { shouldDisplayToolbarShortcut = value } + + func markToolbarOnboardingPopoverAsShown() { } } 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) From dbfc798efb72240a0ba594d57ae72837fa9bfd6e Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 17:14:50 +0100 Subject: [PATCH 15/37] Fix configuration tests --- .../AIChatMenuVisibilityConfigurable.swift | 4 +- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 13 +++- .../View/PreferencesViewController.swift | 2 +- .../AIChatOnboardingTabExtension.swift | 6 +- .../AIChat/AIChatMenuConfigurationTests.swift | 68 ++++++++++++++++++- 5 files changed, 83 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 6a3e1f3e4d..575053e357 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -42,7 +42,7 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { private var cancellables = Set() private var storage: AIChatPreferencesStorage private let notificationCenter: NotificationCenter - private let remoteSettings: AIChatRemoteSettings + private let remoteSettings: AIChatRemoteSettingsProvider var valuesChangedPublisher = PassthroughSubject() var shouldDisplayToolbarOnboardingPopover = PassthroughSubject() @@ -69,7 +69,7 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(), notificationCenter: NotificationCenter = .default, - remoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { + remoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) { self.storage = storage self.notificationCenter = notificationCenter self.remoteSettings = remoteSettings diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index 11257fb1a7..f846209792 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -18,9 +18,20 @@ import BrowserServicesKit +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 { +struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { enum SettingsValue: String { case cookieName = "onboardingCookieName" case cookieDomain = "onboardingCookieDomain" diff --git a/DuckDuckGo/Preferences/View/PreferencesViewController.swift b/DuckDuckGo/Preferences/View/PreferencesViewController.swift index 728f03544a..5c7a2e8894 100644 --- a/DuckDuckGo/Preferences/View/PreferencesViewController.swift +++ b/DuckDuckGo/Preferences/View/PreferencesViewController.swift @@ -35,7 +35,7 @@ final class PreferencesViewController: NSViewController { init(syncService: DDGSyncing, duckPlayer: DuckPlayer = DuckPlayer.shared, - aiChatRemoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { + aiChatRemoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) { model = PreferencesSidebarModel(syncService: syncService, vpnGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: Application.appDelegate.subscriptionManager), includeDuckPlayer: duckPlayer.shouldDisplayPreferencesSideBar, diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index 3f2a2b1d4d..041eef0d6d 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -25,11 +25,11 @@ final class AIChatOnboardingTabExtension { private weak var webView: WKWebView? private var cancellables = Set() private let notificationCenter: NotificationCenter - private let remoteSettings: AIChatRemoteSettings + private let remoteSettings: AIChatRemoteSettingsProvider init(webViewPublisher: some Publisher, notificationCenter: NotificationCenter = .default, - remoteSettings: AIChatRemoteSettings = AIChatRemoteSettings()) { + remoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) { self.notificationCenter = notificationCenter self.remoteSettings = remoteSettings @@ -89,7 +89,7 @@ extension TabExtensions { } private extension HTTPCookie { - func isAIChatCookie(settings: AIChatRemoteSettings) -> Bool { + func isAIChatCookie(settings: AIChatRemoteSettingsProvider) -> Bool { name == settings.onboardingCookieName && domain == settings.onboardingCookieDomain } } diff --git a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift index 6cd4455d1e..f6a4155f44 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() { @@ -51,7 +54,6 @@ class AIChatMenuConfigurationTests: XCTestCase { } func testToolbarValuesChangedPublisher() { - // Given let expectation = self.expectation(description: "Values changed publisher should emit a value.") var receivedValue: Void? @@ -86,12 +88,43 @@ 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.") + } } class MockAIChatPreferencesStorage: AIChatPreferencesStorage { var didDisplayAIChatToolbarOnboarding: Bool = false - func reset() { } + func reset() { + showShortcutInApplicationMenu = false + shouldDisplayToolbarShortcut = false + didDisplayAIChatToolbarOnboarding = false + } var showShortcutInApplicationMenu: Bool = false { didSet { @@ -126,3 +159,32 @@ class MockAIChatPreferencesStorage: AIChatPreferencesStorage { func markToolbarOnboardingPopoverAsShown() { } } + +struct 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 + } +} From a2bb43674c22f287bdb07a49e31a4f3db3e6807b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 17:17:22 +0100 Subject: [PATCH 16/37] Bubble up dependency --- .../Tab/TabExtensions/AIChatOnboardingTabExtension.swift | 4 ++-- DuckDuckGo/Tab/TabExtensions/TabExtensions.swift | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index 041eef0d6d..f211b80955 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -28,8 +28,8 @@ final class AIChatOnboardingTabExtension { private let remoteSettings: AIChatRemoteSettingsProvider init(webViewPublisher: some Publisher, - notificationCenter: NotificationCenter = .default, - remoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) { + notificationCenter: NotificationCenter, + remoteSettings: AIChatRemoteSettingsProvider) { self.notificationCenter = notificationCenter self.remoteSettings = remoteSettings diff --git a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift index 919cfc52d8..38f5f7bc22 100644 --- a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift +++ b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift @@ -202,7 +202,9 @@ extension TabExtensionsBuilder { } add { - AIChatOnboardingTabExtension(webViewPublisher: args.webViewFuture) + AIChatOnboardingTabExtension(webViewPublisher: args.webViewFuture, + notificationCenter: .default, + remoteSettings: AIChatRemoteSettings()) } add { From ed7dcea98698fa6aaff4bba43ab558eefd2b092f Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 19:47:54 +0100 Subject: [PATCH 17/37] Add extension tests --- DuckDuckGo.xcodeproj/project.pbxproj | 6 + .../AIChatOnboardingTabExtensionTests.swift | 165 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fa50b99696..fb9f6bc912 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -224,6 +224,8 @@ 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 */; }; @@ -3335,6 +3337,7 @@ 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 = ""; }; @@ -8952,6 +8955,7 @@ B6CA4822298CDC0B0067ECCE /* TabExtensionsTests */ = { isa = PBXGroup; children = ( + 31031EB92CC9736500684340 /* AIChatOnboardingTabExtensionTests.swift */, B6CA4823298CDC2E0067ECCE /* AdClickAttributionTabExtensionTests.swift */, B626A7632992506A00053070 /* SerpHeadersNavigationResponderTests.swift */, 1D1C36E529FB019C001FA40C /* HistoryTabExtensionTests.swift */, @@ -11577,6 +11581,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 */, @@ -13232,6 +13237,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 */, diff --git a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift new file mode 100644 index 0000000000..a8aee174ac --- /dev/null +++ b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift @@ -0,0 +1,165 @@ +// +// HistoryTabExtensionTests 2.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) + } +} From da0aba6d150dfff5c91cc0a95463614bfd8c036b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 20:00:27 +0100 Subject: [PATCH 18/37] Linter --- DuckDuckGo/AIChat/AIChatDebugMenu.swift | 4 ++-- .../AIChatOnboardingTabExtensionTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatDebugMenu.swift b/DuckDuckGo/AIChat/AIChatDebugMenu.swift index 54d2aba3b9..b3c7c7d776 100644 --- a/DuckDuckGo/AIChat/AIChatDebugMenu.swift +++ b/DuckDuckGo/AIChat/AIChatDebugMenu.swift @@ -27,11 +27,11 @@ final class AIChatDebugMenu: NSMenu { NSMenuItem(title: "Show toolbar onboarding", action: #selector(showToolbarOnboarding), target: self) } } - + required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + @objc func resetToolbarOnboarding() { DefaultAIChatPreferencesStorage().reset() } diff --git a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift index a8aee174ac..1a78e17201 100644 --- a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift +++ b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift @@ -1,5 +1,5 @@ // -// HistoryTabExtensionTests 2.swift +// AIChatOnboardingTabExtensionTests 2.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // From d1d5c78c38423dc3043f82b5cb3c8dd6b2e4f739 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 23 Oct 2024 20:15:06 +0100 Subject: [PATCH 19/37] Fix header --- .../TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift index 1a78e17201..f19324ee13 100644 --- a/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift +++ b/UnitTests/TabExtensionsTests/AIChatOnboardingTabExtensionTests.swift @@ -1,5 +1,5 @@ // -// AIChatOnboardingTabExtensionTests 2.swift +// AIChatOnboardingTabExtensionTests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // From c59b611c100ca9b7ed73ea3624053464e1a4a60e Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 24 Oct 2024 18:34:58 +0100 Subject: [PATCH 20/37] Add documentation to protocol --- .../AIChatMenuVisibilityConfigurable.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 575053e357..546fe646c7 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -20,16 +20,47 @@ 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 } + /// 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() } From bfb68f322f6cf59047f7c64ed736ee7e11bf6095 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Sun, 27 Oct 2024 21:52:15 -0700 Subject: [PATCH 21/37] Fix tooltip messaging. --- .../AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift index 13ee8330d7..b637712504 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift @@ -36,7 +36,7 @@ struct AIChatToolBarPopUpOnboardingView: View { Text(UserText.aiChatOnboardingPopoverMessage1) + Text(" ") + - Text(UserText.aiChatOnboardingPopoverMessage1).bold() + Text(UserText.aiChatOnboardingPopoverMessage2).bold() } HStack { From 7e92c0dc1747ad836e85e88592de064b9f622826 Mon Sep 17 00:00:00 2001 From: Sam Symons Date: Mon, 28 Oct 2024 21:19:57 -0700 Subject: [PATCH 22/37] Show AI chat in the menus by default. --- DuckDuckGo/AIChat/AIChatPreferencesStorage.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift index a070952b2a..4525cb6026 100644 --- a/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift +++ b/DuckDuckGo/AIChat/AIChatPreferencesStorage.swift @@ -84,8 +84,8 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage { } func reset() { - userDefaults.showAIChatShortcutInApplicationMenu = false - userDefaults.didDisplayAIChatToolbarOnboarding = false + userDefaults.showAIChatShortcutInApplicationMenu = UserDefaults.showAIChatShortcutInApplicationMenuDefaultValue + userDefaults.didDisplayAIChatToolbarOnboarding = UserDefaults.didDisplayAIChatToolbarOnboardingDefaultValue pinningManager.unpin(.aiChat) } } @@ -96,7 +96,7 @@ private extension UserDefaults { static let didDisplayAIChatToolbarOnboardingKey = "aichat.didDisplayAIChatToolbarOnboarding" } - static let showAIChatShortcutInApplicationMenuDefaultValue = false + static let showAIChatShortcutInApplicationMenuDefaultValue = true static let didDisplayAIChatToolbarOnboardingDefaultValue = false @objc dynamic var showAIChatShortcutInApplicationMenu: Bool { From 7ec0bdc9b044a03a9ab02a72cb8e7764f6f55362 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 29 Oct 2024 10:59:25 +0000 Subject: [PATCH 23/37] Fix issue with navigation from same document --- .../Tab/TabExtensions/AIChatOnboardingTabExtension.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift index f211b80955..e7dc154601 100644 --- a/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/AIChatOnboardingTabExtension.swift @@ -73,6 +73,11 @@ extension AIChatOnboardingTabExtension: NavigationResponder { 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 { From 329c9f088b75043bec4406dc707ca09e93e8f38f Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 29 Oct 2024 11:45:45 +0000 Subject: [PATCH 24/37] Add pixels --- ...IChatToolBarPopUpOnboardingViewModel.swift | 4 ++++ DuckDuckGo/Menus/MainMenuActions.swift | 1 + .../NavigationBar/View/MoreOptionsMenu.swift | 1 + .../View/NavigationBarPopovers.swift | 2 ++ .../View/NavigationBarViewController.swift | 1 + DuckDuckGo/Statistics/GeneralPixel.swift | 20 +++++++++++++++++++ 6 files changed, 29 insertions(+) diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift index c8d2ef58ea..719b5e62b4 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingViewModel.swift @@ -16,6 +16,8 @@ // limitations under the License. // +import PixelKit + final class AIChatToolBarPopUpOnboardingViewModel: ObservableObject { var aiChatStorage: AIChatPreferencesStorage var ctaCallback: ((Bool) -> Void)? @@ -32,6 +34,8 @@ final class AIChatToolBarPopUpOnboardingViewModel: ObservableObject { } func acceptToolbarIcon() { + PixelKit.fire(GeneralPixel.aichatToolbarOnboardingPopoverAccept, + includeAppVersionParameter: true) aiChatStorage.shouldDisplayToolbarShortcut = true ctaCallback?(true) } diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 197e3ae0dd..807390ca52 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 0297f7330f..737d922c3b 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -179,6 +179,7 @@ final class MoreOptionsMenu: NSMenu { @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 67b587be0f..c31a28ead3 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -238,6 +238,8 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { 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) diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 8d3d8e949e..b859dec1af 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -1006,6 +1006,7 @@ final class NavigationBarViewController: NSViewController { @IBAction func aiChatButtonAction(_ sender: NSButton) { AIChatTabOpener.openAIChatTab() + PixelKit.fire(GeneralPixel.aichatToolbarClicked, includeAppVersionParameter: true) } private func updateAIChatButton() { diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index 416c3dfec3..d9122e5c7d 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -156,6 +156,14 @@ enum GeneralPixel: PixelKitEventV2 { case networkProtectionGeoswitchingSetCustom case networkProtectionGeoswitchingNoLocations + // AI Chat + case aichatToolbarClicked + case aichatApplicationMenuAppClicked + case aichatApplicationMenuFileClicked + case aichatToolbarOnboardingPopoverShown + case aichatToolbarOnboardingPopoverAccept + + // Sync case syncSignupDirect case syncSignupConnect @@ -662,6 +670,18 @@ 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" + // Sync case .syncSignupDirect: return "m_mac_sync_signup_direct" From 0dc7b8d3b4fb6f50f8b9ce9d0336f5692f6b0793 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 29 Oct 2024 12:02:37 +0000 Subject: [PATCH 25/37] Add translation --- DuckDuckGo/Localizable.xcstrings | 496 +++++++++++++++++++++++++++++++ 1 file changed, 496 insertions(+) diff --git a/DuckDuckGo/Localizable.xcstrings b/DuckDuckGo/Localizable.xcstrings index 0d45c59159..d03f499f27 100644 --- a/DuckDuckGo/Localizable.xcstrings +++ b/DuckDuckGo/Localizable.xcstrings @@ -53,6 +53,22 @@ } } }, + " " : { + "localizations" : { + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : " " + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : " " + } + } + } + }, " %@" : { "localizations" : { "de" : { @@ -1522,6 +1538,366 @@ } } }, + "ai-chat.onboarding.popover.accept" : { + "comment" : "AI Chat onboarding CTA for approval", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shortcut hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Shortcut" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir acceso directo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un raccourci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi scorciatoia" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sneltoets toevoegen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodaj skrót" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar atalho" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить ярлык" + } + } + } + }, + "ai-chat.onboarding.popover.confirmation" : { + "comment" : "Confirmation for accepting the AI Chat onboarding popover", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI-Chat-Shortcut hinzugefügt!" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "AI Chat Shortcut Added!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Se ha añadido un acceso directo a AI Chat!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccourci AI Chat ajouté !" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scorciatoia per AI Chat aggiunta!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelkoppeling naar AI Chat toegevoegd!" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dodano skrót do AI Chat!" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atalho do AI Chat adicionado!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ярлык AI Chat добавлен!" + } + } + } + }, + "ai-chat.onboarding.popover.message1" : { + "comment" : "AI Chat onboarding popover message", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kannst diese und andere AI-Chat-Funktionen anpassen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You can adjust this and other AI Chat features in" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puedes ajustar esta y otras funciones de AI Chat en" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vous pouvez ajuster cela et d'autres fonctionnalités de AI Chat dans" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puoi regolare questa e altre funzionalità di AI Chat in" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je kunt deze en andere AI Chat-functies aanpassen in" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Możesz dostosować tę i inne funkcje AI Chat wybierając kolejno" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Podes ajustar esta e outras funcionalidades do AI Chat em" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эту и другие возможности AI Chat можно проконтролировать в разделе" + } + } + } + }, + "ai-chat.onboarding.popover.message2" : { + "comment" : "AI Chat onboarding popover message continuation", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen > AI Chat." + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings > AI Chat." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes > AI Chat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres > AI Chat." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Impostazioni > AI Chat." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "'Instellingen' > 'AI Chat'." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustawienia > AI Chat." + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Definições > AI Chat." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "«Настройки > AI Chat»." + } + } + } + }, + "ai-chat.onboarding.popover.reject" : { + "comment" : "AI Chat onboarding CTA for rejection", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nein, danke" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Thanks" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No, gracias" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non merci" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "No, grazie" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nee, bedankt" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie, dziękuję" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não, obrigado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет, спасибо" + } + } + } + }, + "ai-chat.onboarding.popover.title" : { + "comment" : "AI Chat onboarding popover title", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starte AI Chat direkt von deiner Symbolleiste" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Launch AI Chat directly from your toolbar" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia AI Chat directamente desde la barra de herramientas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lancez AI Chat directement depuis votre barre d'outils" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avvia AI Chat direttamente dalla barra degli strumenti" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start AI Chat rechtstreeks vanuit je werkbalk" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uruchom AI Chat bezpośrednio z paska narzędzi" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia o AI Chat diretamente a partir da tua barra de ferramentas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запуск AI Chat прямо с панели инструментов" + } + } + } + }, "ai-chat.preferences.caption" : { "comment" : "Ai Chat preferences explanation", "extractionState" : "extracted_with_value", @@ -44206,6 +44582,66 @@ } } }, + "pinning.hide-aichat-shortcut" : { + "comment" : "Menu item for hiding the AI Chat shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI-Chat-Verknüpfung ausblenden" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide AI Chat Shortcut" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar acceso directo a AI Chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer le raccourci de chat IA" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nascondi scorciatoia AI Chat" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelkoppeling voor AI-chat verbergen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ukryj skrót do AI Chat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar atalho do AI Chat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть ярлык AI Chat" + } + } + } + }, "pinning.hide-autofill-shortcut" : { "comment" : "Menu item for hiding the passwords shortcut", "extractionState" : "extracted_with_value", @@ -44446,6 +44882,66 @@ } } }, + "pinning.show-aichat-shortcut" : { + "comment" : "Menu item for showing the AI Chat shortcut", + "extractionState" : "extracted_with_value", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AI-Chat-Verknüpfung anzeigen" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show AI Chat Shortcut" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar acceso directo a AI Chat" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afficher le raccourci AI Chat" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostra scorciatoia AI Chat" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Snelkoppeling voor AI Chat weergeven" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pokaż skrót do AI Chat" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mostrar atalho do AI Chat" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать ярлык AI Chat" + } + } + } + }, "pinning.show-autofill-shortcut" : { "comment" : "Menu item for showing the passwords shortcut", "extractionState" : "extracted_with_value", From 4bd9c0db0c9f5c08e54e7d66e504ee4c0ba168ac Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 29 Oct 2024 12:14:37 +0000 Subject: [PATCH 26/37] linter --- DuckDuckGo/Statistics/GeneralPixel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index d9122e5c7d..f835f4466c 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -163,7 +163,6 @@ enum GeneralPixel: PixelKitEventV2 { case aichatToolbarOnboardingPopoverShown case aichatToolbarOnboardingPopoverAccept - // Sync case syncSignupDirect case syncSignupConnect From da330e472da5283871f23f873b432b46df26232b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 29 Oct 2024 13:28:07 +0000 Subject: [PATCH 27/37] Do not display if the icon is visible --- DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift index 546fe646c7..a33d1fe372 100644 --- a/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift +++ b/DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift @@ -113,7 +113,7 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable { notificationCenter.publisher(for: .AIChatOpenedForReturningUser) .sink { [weak self] _ in guard let self = self else { return } - if !self.storage.didDisplayAIChatToolbarOnboarding { + if !self.storage.didDisplayAIChatToolbarOnboarding && !storage.shouldDisplayToolbarShortcut { self.shouldDisplayToolbarOnboardingPopover.send() } }.store(in: &cancellables) From a2a3621ae81dca08a14014ab159f2227b60e1f94 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 10:23:53 +0000 Subject: [PATCH 28/37] WIP: Ship review feedback --- DuckDuckGo/AIChat/AIChatDebugMenu.swift | 5 +++-- DuckDuckGo/Common/Localizables/UserText.swift | 3 +-- DuckDuckGo/Menus/MainMenu.swift | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatDebugMenu.swift b/DuckDuckGo/AIChat/AIChatDebugMenu.swift index b3c7c7d776..df28d18371 100644 --- a/DuckDuckGo/AIChat/AIChatDebugMenu.swift +++ b/DuckDuckGo/AIChat/AIChatDebugMenu.swift @@ -19,6 +19,8 @@ import AppKit final class AIChatDebugMenu: NSMenu { + private var storage = DefaultAIChatPreferencesStorage() + init() { super.init(title: "") @@ -33,11 +35,10 @@ final class AIChatDebugMenu: NSMenu { } @objc func resetToolbarOnboarding() { - DefaultAIChatPreferencesStorage().reset() + storage.reset() } @objc func showToolbarOnboarding() { - var storage = DefaultAIChatPreferencesStorage() storage.didDisplayAIChatToolbarOnboarding = false NotificationCenter.default.post(name: .AIChatOpenedForReturningUser, object: nil) } diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 60486d45b2..cd3cd78f07 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -363,8 +363,7 @@ struct UserText { static let aiChatOnboardingPopoverMessage2 = NSLocalizedString("ai-chat.onboarding.popover.message2", value: "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 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") diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index 7073462270..0adfe345ee 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() From acd73c360f640ab0a00320f9ab5d00c167d675e2 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 10:47:39 +0000 Subject: [PATCH 29/37] Update bookmarks bar copy --- .../View/Prompt/BookmarksBarPromptPopover.swift | 11 ++++++++--- DuckDuckGo/Common/Localizables/UserText.swift | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift b/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift index b6dd6a5933..0b5649515a 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,8 +84,13 @@ struct BookmarksBarPromptView: View { .font(.system(size: 15).weight(.semibold)) .padding(.bottom, 16) - Text(UserText.bookmarksBarPromptMessage) - .font(.system(size: 13)) + Group { + Text(UserText.bookmarksBarPromptMessage1) + .font(.system(size: 13)) + + Text(" ") + + Text(UserText.bookmarksBarPromptMessage2) + .font(.system(size: 13, weight: .bold)) + } .padding(.bottom, 20) HStack { diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index cd3cd78f07..b228bdc9f8 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -1184,7 +1184,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 bookmarksBarPromptMessage1 = NSLocalizedString("bookmarks.bar.prompt.message1", value: "Show the Bookmarks Bar for quick access to your favorite bookmarks. You can adjust this later in", comment: "First part for message show for bookmarks bar prompt") + static let bookmarksBarPromptMessage2 = NSLocalizedString("bookmarks.bar.prompt.message2", value: "Settings > Appearance.", comment: "Second part for 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") From 87ed70b046336f4c90d40c7758b43cda3cef8b2f Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 11:19:19 +0000 Subject: [PATCH 30/37] Add autofill password modal --- DuckDuckGo.xcodeproj/project.pbxproj | 32 +++++++++ .../AutofillToolbarOnboardingPopover.swift | 44 ++++++++++++ .../AutofillToolbarOnboardingView.swift | 68 +++++++++++++++++++ ...ofillToolbarOnboardingViewController.swift | 38 +++++++++++ .../AutofillToolbarOnboardingViewModel.swift | 33 +++++++++ DuckDuckGo/Common/Localizables/UserText.swift | 5 ++ .../View/NavigationBarPopovers.swift | 24 +++++++ .../View/NavigationBarViewController.swift | 23 +++---- 8 files changed, 254 insertions(+), 13 deletions(-) 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 diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b2c9aedd16..5cf4fc734c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -275,6 +275,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 */; }; @@ -3398,6 +3406,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 = ""; }; @@ -5327,6 +5339,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 = ( @@ -6768,6 +6791,7 @@ 7B1E819A27C8874900FF0E60 /* Autofill */ = { isa = PBXGroup; children = ( + 317307242CD2489900C492AB /* Onboarding */, C10529482C9F45720041E502 /* Debug */, 7B1E819B27C8874900FF0E60 /* ContentOverlayPopover.swift */, 7B1E819C27C8874900FF0E60 /* ContentOverlay.storyboard */, @@ -11303,6 +11327,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 */, @@ -11313,6 +11338,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 */, @@ -11434,6 +11460,7 @@ B6F1B02F2BCE6B47005E863C /* TunnelControllerProvider.swift in Sources */, 31A83FB62BE28D7D00F74E67 /* UserText+DBP.swift in Sources */, 3706FC31293F65D500E42796 /* PermissionButton.swift in Sources */, + 317307262CD248DB00C492AB /* AutofillToolbarOnboardingPopover.swift in Sources */, 9F6434622BEC82B700D2D8A0 /* AttributionPixelHandler.swift in Sources */, 3706FC32293F65D500E42796 /* MoreOptionsMenu.swift in Sources */, 3706FC34293F65D500E42796 /* PermissionAuthorizationViewController.swift in Sources */, @@ -11547,6 +11574,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 */, @@ -12496,6 +12524,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 */, @@ -12572,6 +12601,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 */, @@ -12690,6 +12720,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 */, @@ -13203,6 +13234,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 */, 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/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index b228bdc9f8..388e1386e5 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -405,6 +405,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") diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index c31a28ead3..e3c8d4ea5d 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -59,6 +59,7 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { 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? @@ -229,6 +230,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover?.close() } + if autofillOnboardingPopover?.isShown ?? false { + autofillOnboardingPopover?.close() + } + return true } @@ -245,6 +250,17 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { 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 } @@ -294,6 +310,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { aiChatOnboardingPopover?.close() } + func closeAutofillOnboardingPopover() { + autofillOnboardingPopover?.close() + } + func openPrivacyDashboard(for tabViewModel: TabViewModel, from button: MouseOverButton) { guard closeTransientPopovers() else { return } @@ -403,6 +423,10 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { 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 b859dec1af..241c0e37d7 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -507,20 +507,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) } } @@ -1228,9 +1223,11 @@ extension NavigationBarViewController: NSPopoverDelegate { } 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 { From 866c9f648342caa056e60de825cb387c576f63bf Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 15:12:01 +0000 Subject: [PATCH 31/37] Fix text with markdown --- .../AIChatToolBarPopUpOnboardingView.swift | 11 ++++++++--- .../Prompt/BookmarksBarPromptPopover.swift | 18 +++++++++++------- DuckDuckGo/Common/Localizables/UserText.swift | 8 ++++---- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift index b637712504..57f4d52772 100644 --- a/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift +++ b/DuckDuckGo/AIChat/Onboarding/AIChatToolBarPopUpOnboardingView.swift @@ -34,9 +34,14 @@ struct AIChatToolBarPopUpOnboardingView: View { Text(UserText.aiChatOnboardingPopoverTitle) .font(.headline) - Text(UserText.aiChatOnboardingPopoverMessage1) + - Text(" ") + - Text(UserText.aiChatOnboardingPopoverMessage2).bold() + 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 { diff --git a/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift b/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift index 0b5649515a..5c648cc399 100644 --- a/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift +++ b/DuckDuckGo/BookmarksBar/View/Prompt/BookmarksBarPromptPopover.swift @@ -84,14 +84,18 @@ struct BookmarksBarPromptView: View { .font(.system(size: 15).weight(.semibold)) .padding(.bottom, 16) - Group { - Text(UserText.bookmarksBarPromptMessage1) - .font(.system(size: 13)) + - Text(" ") + - Text(UserText.bookmarksBarPromptMessage2) - .font(.system(size: 13, weight: .bold)) + 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) } - .padding(.bottom, 20) HStack { Button { diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 388e1386e5..078c18e253 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -359,8 +359,8 @@ struct UserText { // 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 aiChatOnboardingPopoverMessage1 = NSLocalizedString("ai-chat.onboarding.popover.message1", value: "You can adjust this and other AI Chat features in", comment: "AI Chat onboarding popover message") - static let aiChatOnboardingPopoverMessage2 = NSLocalizedString("ai-chat.onboarding.popover.message2", value: "Settings > AI Chat.", comment: "AI Chat onboarding popover message continuation") + 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") @@ -1189,8 +1189,8 @@ 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 bookmarksBarPromptMessage1 = NSLocalizedString("bookmarks.bar.prompt.message1", value: "Show the Bookmarks Bar for quick access to your favorite bookmarks. You can adjust this later in", comment: "First part for message show for bookmarks bar prompt") - static let bookmarksBarPromptMessage2 = NSLocalizedString("bookmarks.bar.prompt.message2", value: "Settings > Appearance.", comment: "Second part for 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") From ddeaf856b654addf54a9efdaf828837b47fdd12a Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 18:36:10 +0000 Subject: [PATCH 32/37] Linter --- DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift index e3c8d4ea5d..7e893b8b73 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarPopovers.swift @@ -251,8 +251,8 @@ final class NavigationBarPopovers: NSObject, PopoverPresenter { } func showAutofillOnboardingPopover(from button: MouseOverButton, - withDelegate delegate: NSPopoverDelegate, - ctaCallback: @escaping (Bool) -> Void) { + withDelegate delegate: NSPopoverDelegate, + ctaCallback: @escaping (Bool) -> Void) { guard closeTransientPopovers() else { return } let popover = autofillOnboardingPopover ?? AutofillToolbarOnboardingPopover(ctaCallback: ctaCallback) From 5d583114bb045669113aeb11e64724a007852c8a Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 30 Oct 2024 23:19:02 +0000 Subject: [PATCH 33/37] Update copy for AI Chat preferences --- DuckDuckGo/Common/Localizables/UserText.swift | 4 ++++ .../Preferences/Model/AIChatPreferences.swift | 4 ++++ .../Preferences/View/PreferencesAIChat.swift | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/Common/Localizables/UserText.swift b/DuckDuckGo/Common/Localizables/UserText.swift index 078c18e253..5b4b20b0c2 100644 --- a/DuckDuckGo/Common/Localizables/UserText.swift +++ b/DuckDuckGo/Common/Localizables/UserText.swift @@ -369,6 +369,10 @@ struct UserText { 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") 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/View/PreferencesAIChat.swift b/DuckDuckGo/Preferences/View/PreferencesAIChat.swift index 4cce8719f1..ad4193c9a0 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.bookmarksBarPromptMessageFallback) + } + TextButton(UserText.aiChatPreferencesLearnMoreButton) { model.openLearnMoreLink() } From eb5c440d7831122b9673bbed4d84076c2d42ec64 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 31 Oct 2024 00:08:04 +0000 Subject: [PATCH 34/37] Fix fallback string --- DuckDuckGo/Preferences/View/PreferencesAIChat.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/Preferences/View/PreferencesAIChat.swift b/DuckDuckGo/Preferences/View/PreferencesAIChat.swift index ad4193c9a0..609ec8f257 100644 --- a/DuckDuckGo/Preferences/View/PreferencesAIChat.swift +++ b/DuckDuckGo/Preferences/View/PreferencesAIChat.swift @@ -44,7 +44,7 @@ extension Preferences { .foregroundColor(Color(.greyText)) } else { // Fallback for earlier macOS versions - TextMenuItemCaption(UserText.bookmarksBarPromptMessageFallback) + TextMenuItemCaption(UserText.aiChatPreferencesCaptionWithLinkFallback) } TextButton(UserText.aiChatPreferencesLearnMoreButton) { From 7d23316aa4d33fa2d248238be6873e05a790eac6 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 31 Oct 2024 15:33:30 +0000 Subject: [PATCH 35/37] Add extra tests --- .../AIChat/AIChatMenuConfigurationTests.swift | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift index f6a4155f44..42717e340e 100644 --- a/UnitTests/AIChat/AIChatMenuConfigurationTests.swift +++ b/UnitTests/AIChat/AIChatMenuConfigurationTests.swift @@ -115,6 +115,60 @@ class AIChatMenuConfigurationTests: XCTestCase { 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 { @@ -160,7 +214,7 @@ class MockAIChatPreferencesStorage: AIChatPreferencesStorage { func markToolbarOnboardingPopoverAsShown() { } } -struct MockRemoteAISettings: AIChatRemoteSettingsProvider { +final class MockRemoteAISettings: AIChatRemoteSettingsProvider { var onboardingCookieName: String var onboardingCookieDomain: String var aiChatURLIdentifiableQuery: String From 545618ffe5fac0a2aaa27340f2ec7da58f71ee83 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 1 Nov 2024 14:41:20 +0000 Subject: [PATCH 36/37] Add debug pixel --- DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 3 ++- DuckDuckGo/Statistics/GeneralPixel.swift | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index f846209792..bd227a3d25 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -17,6 +17,7 @@ // import BrowserServicesKit +import PixelKit protocol AIChatRemoteSettingsProvider { var onboardingCookieName: String { get } @@ -91,7 +92,7 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { if let value = settings[value.rawValue] as? String { return value } else { - // Fire unique pixel for value.rawValue + PixelKit.fire(GeneralPixel.aichatNoRemoteSettingsFound(value), includeAppVersionParameter: true) return value.defaultValue } } diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index f835f4466c..90bb48aeac 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -162,7 +162,7 @@ enum GeneralPixel: PixelKitEventV2 { case aichatApplicationMenuFileClicked case aichatToolbarOnboardingPopoverShown case aichatToolbarOnboardingPopoverAccept - + case aichatNoRemoteSettingsFound(AIChatRemoteSettings.SettingsValue) // Sync case syncSignupDirect case syncSignupConnect @@ -680,6 +680,8 @@ enum GeneralPixel: PixelKitEventV2 { 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)" // Sync case .syncSignupDirect: From 1fca336925485f798b25098bf2f87329cb9c38c6 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 1 Nov 2024 15:49:13 +0000 Subject: [PATCH 37/37] lowercase pixel --- DuckDuckGo/Statistics/GeneralPixel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/Statistics/GeneralPixel.swift b/DuckDuckGo/Statistics/GeneralPixel.swift index 90bb48aeac..7228008f1b 100644 --- a/DuckDuckGo/Statistics/GeneralPixel.swift +++ b/DuckDuckGo/Statistics/GeneralPixel.swift @@ -681,7 +681,7 @@ enum GeneralPixel: PixelKitEventV2 { case .aichatToolbarOnboardingPopoverAccept: return "m_mac_aichat_toolbar-onboarding-popover-accept" case .aichatNoRemoteSettingsFound(let settings): - return "m_mac_aichat_no_remote_settings_found-\(settings.rawValue)" + return "m_mac_aichat_no_remote_settings_found-\(settings.rawValue.lowercased())" // Sync case .syncSignupDirect: