From edf899df9105c3ec4318fa774e888df2c3c2248e Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 4 Dec 2024 16:42:26 -0300 Subject: [PATCH] AI Chat browsing menu (#3635) Task/Issue URL: https://app.asana.com/0/1204167627774280/1208794395441049/f **Description**: Add AI Chat entry point in the browsing menu --- Core/FeatureFlag.swift | 1 + Core/PixelEvent.swift | 19 +- DuckDuckGo.xcodeproj/project.pbxproj | 49 +++++ DuckDuckGo/AIChat/AIChatPixelHandler.swift | 32 +++ DuckDuckGo/AIChat/AIChatSettings.swift | 108 +++++++++ .../24px/AIChat-24.imageset/AIChat-24.pdf | Bin 0 -> 1590 bytes .../24px/AIChat-24.imageset/Contents.json | 16 ++ .../MenuAIChat.imageset/AIChat.pdf | Bin 0 -> 2549 bytes .../MenuAIChat.imageset/Contents.json | 12 + .../BrowsingMenuViewController.swift | 5 +- DuckDuckGo/MainViewController+Segues.swift | 6 +- DuckDuckGo/MainViewController.swift | 25 ++- .../SettingsAIChat.imageset/Contents.json | 12 + .../SettingsAIChat.pdf | Bin 0 -> 2766 bytes .../SettingsAIChatHero.imageset/Contents.json | 12 + .../SettingsAIChatHero.pdf | Bin 0 -> 3779 bytes DuckDuckGo/SettingsAIChatView.swift | 54 +++++ DuckDuckGo/SettingsMainSettingsView.swift | 10 +- DuckDuckGo/SettingsRootView.swift | 2 + DuckDuckGo/SettingsState.swift | 8 +- DuckDuckGo/SettingsViewModel.swift | 24 +- DuckDuckGo/TabDelegate.swift | 2 + ...bViewControllerBrowsingMenuExtension.swift | 63 +++++- DuckDuckGo/UserText.swift | 21 +- DuckDuckGo/en.lproj/Localizable.strings | 26 ++- .../AIChat/AIChatSettingsTests.swift | 131 +++++++++++ DuckDuckGoTests/MockTabDelegate.swift | 2 + LocalPackages/AIChat/Package.swift | 20 ++ .../Sources/AIChat/AIChatViewModel.swift | 81 +++++++ .../AIChat/AIChatWebViewController.swift | 133 ++++++++++++ .../Public API/AIChatPixelHandling.swift | 27 +++ .../Public API/AIChatSettingsProvider.swift | 37 ++++ .../Public API/AIChatViewController.swift | 205 ++++++++++++++++++ .../AIChat/Public API/Logger+AIChat.swift | 25 +++ .../Sources/AIChat/TimerPixelHandler.swift | 56 +++++ .../AIChat/Sources/AIChat/UserText.swift | 24 ++ 36 files changed, 1218 insertions(+), 30 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatPixelHandler.swift create mode 100644 DuckDuckGo/AIChat/AIChatSettings.swift create mode 100644 DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf create mode 100644 DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json create mode 100644 DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/AIChat.pdf create mode 100644 DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf create mode 100644 DuckDuckGo/SettingsAIChatView.swift create mode 100644 DuckDuckGoTests/AIChat/AIChatSettingsTests.swift create mode 100644 LocalPackages/AIChat/Package.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/UserText.swift diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 15263d9e7b..8bf183dafd 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -56,6 +56,7 @@ public enum FeatureFlag: String { /// https://app.asana.com/0/1208592102886666/1208613627589762/f case crashReportOptInStatusResetting + case isPrivacyProLaunchedROW case isPrivacyProLaunchedROWOverride diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index b98773df96..2edc84584d 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -80,6 +80,7 @@ extension Pixel { case browsingMenuShare case browsingMenuCopy case browsingMenuPrint + case browsingMenuListPrint case browsingMenuFindInPage case browsingMenuZoom case browsingMenuDisableProtection @@ -87,7 +88,8 @@ extension Pixel { case browsingMenuReportBrokenSite case browsingMenuFireproof case browsingMenuAutofill - + case browsingMenuAIChat + case addressBarShare case addressBarSettings case addressBarCancelPressedOnNTP @@ -895,9 +897,13 @@ extension Pixel { case appDidShowUITime(time: BucketAggregation) case appDidBecomeActiveTime(time: BucketAggregation) + // MARK: AI Chat + case openAIChatBefore10min + case openAIChatAfter10min + case aiChatNoRemoteSettingsFound(settings: String) + // MARK: Lifecycle case appDidTransitionToUnexpectedState - } } @@ -959,6 +965,7 @@ extension Pixel.Event { case .browsingMenuToggleBrowsingMode: return "mb_dm" case .browsingMenuCopy: return "mb_cp" case .browsingMenuPrint: return "mb_pr" + case .browsingMenuFindInPage: return "mb_fp" case .browsingMenuZoom: return "m_menu_page_zoom_taps" case .browsingMenuDisableProtection: return "mb_wla" @@ -968,6 +975,8 @@ extension Pixel.Event { case .browsingMenuAutofill: return "m_nav_autofill_menu_item_pressed" case .browsingMenuShare: return "m_browsingmenu_share" + case .browsingMenuAIChat: return "m_aichat_menu_tab_icon" + case .browsingMenuListPrint: return "m_browsing_menu_list_print" case .addressBarShare: return "m_addressbar_share" case .addressBarSettings: return "m_addressbar_settings" @@ -1787,6 +1796,12 @@ extension Pixel.Event { case .appDidShowUITime(let time): return "m_debug_app-did-show-ui-time-\(time)" case .appDidBecomeActiveTime(let time): return "m_debug_app-did-become-active-time-\(time)" + // MARK: AI Chat + case .openAIChatAfter10min: return "m_aichat_open_after_10_min" + case .openAIChatBefore10min: return "m_aichat_open_before_10_min" + case .aiChatNoRemoteSettingsFound(let settings): + return "m_aichat_no_remote_settings_found-\(settings.lowercased())" + // MARK: Lifecycle case .appDidTransitionToUnexpectedState: return "m_debug_app-did-transition-to-unexpected-state" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 74091cfaf2..b4d015e475 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -128,6 +128,7 @@ 1EFDCBC127D2393C00916BC5 /* DownloadsDeleteHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EFDCBC027D2393C00916BC5 /* DownloadsDeleteHelper.swift */; }; 22CB1ED8203DDD2C00D2C724 /* AppDeepLinksTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22CB1ED7203DDD2C00D2C724 /* AppDeepLinksTests.swift */; }; 2DC3FC65C6D9DA634426672D /* AutofillNoAuthAvailableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DC3FBD62FBAF21E87610FA8 /* AutofillNoAuthAvailableView.swift */; }; + 31043B162CFA5B8E0028A97F /* AIChatPixelHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */; }; 310742A62848CD780012660B /* BackForwardMenuHistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310742A52848CD780012660B /* BackForwardMenuHistoryItem.swift */; }; 310742AB2848E6FD0012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310742A92848E5B70012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift */; }; 310C4B45281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310C4B44281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift */; }; @@ -137,6 +138,7 @@ 310D09212799FD1A00DC0060 /* MIMEType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310D09202799FD1A00DC0060 /* MIMEType.swift */; }; 310E79BD2949CAA5007C49E8 /* FireButtonReferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310E79BC2949CAA5007C49E8 /* FireButtonReferenceTests.swift */; }; 310ECFDD282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310ECFDC282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift */; }; + 310EEA2F2CFFCDC60043CA1A /* AIChatSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */; }; 311BD1AD2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1AC2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift */; }; 311BD1AF2836BB4200AEF6C1 /* AutofillItemsLockedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1AE2836BB4200AEF6C1 /* AutofillItemsLockedView.swift */; }; 311BD1B12836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311BD1B02836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift */; }; @@ -157,13 +159,16 @@ 3157B43827F4C8490042D3D7 /* FaviconsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3157B43727F4C8490042D3D7 /* FaviconsHelper.swift */; }; 31584616281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31584615281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift */; }; 3158461A281B08F5004ADB8B /* AutofillLoginListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31584619281B08F5004ADB8B /* AutofillLoginListViewModel.swift */; }; + 315C77822CFA41A400699683 /* AIChat in Frameworks */ = {isa = PBXBuildFile; productRef = 315C77812CFA41A400699683 /* AIChat */; }; 3161D13227AC161B00285CF6 /* DownloadMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3161D13127AC161B00285CF6 /* DownloadMetadata.swift */; }; 31669B9A28020A460071CC18 /* SaveLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31669B9928020A460071CC18 /* SaveLoginViewModel.swift */; }; 316790E52C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316790E42C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift */; }; 316931D727BD10BB0095F5ED /* SaveToDownloadsAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316931D627BD10BB0095F5ED /* SaveToDownloadsAlert.swift */; }; 316931D927BD22A80095F5ED /* DownloadActionMessageViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316931D827BD22A80095F5ED /* DownloadActionMessageViewHelper.swift */; }; + 316AA45A2CF8E31F00A2ED28 /* AIChatSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */; }; 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; + 317CA3432CFF82E100F88848 /* SettingsAIChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; 31860A5B2C57ED2D005561F5 /* DuckPlayerStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */; }; 31951E8E2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */; }; @@ -1488,6 +1493,7 @@ 1EFDCBC027D2393C00916BC5 /* DownloadsDeleteHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsDeleteHelper.swift; sourceTree = ""; }; 22CB1ED7203DDD2C00D2C724 /* AppDeepLinksTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDeepLinksTests.swift; sourceTree = ""; }; 2DC3FBD62FBAF21E87610FA8 /* AutofillNoAuthAvailableView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AutofillNoAuthAvailableView.swift; sourceTree = ""; }; + 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatPixelHandler.swift; sourceTree = ""; }; 310742A52848CD780012660B /* BackForwardMenuHistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackForwardMenuHistoryItem.swift; sourceTree = ""; }; 310742A92848E5B70012660B /* BackForwardMenuHistoryItemURLSanitizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackForwardMenuHistoryItemURLSanitizerTests.swift; sourceTree = ""; }; 310C4B44281B5A9A00BA79A9 /* AutofillLoginDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsView.swift; sourceTree = ""; }; @@ -1497,6 +1503,7 @@ 310D09202799FD1A00DC0060 /* MIMEType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MIMEType.swift; sourceTree = ""; }; 310E79BC2949CAA5007C49E8 /* FireButtonReferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FireButtonReferenceTests.swift; sourceTree = ""; }; 310ECFDC282A8BB0005029B3 /* EnableAutofillSettingsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnableAutofillSettingsTableViewCell.swift; sourceTree = ""; }; + 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatSettingsTests.swift; sourceTree = ""; }; 311BD1AC2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillItemsEmptyView.swift; sourceTree = ""; }; 311BD1AE2836BB4200AEF6C1 /* AutofillItemsLockedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillItemsLockedView.swift; sourceTree = ""; }; 311BD1B02836C0CA00AEF6C1 /* AutofillLoginListAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListAuthenticator.swift; sourceTree = ""; }; @@ -1517,14 +1524,17 @@ 3157B43727F4C8490042D3D7 /* FaviconsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconsHelper.swift; sourceTree = ""; }; 31584615281AFB46004ADB8B /* AutofillLoginDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsViewController.swift; sourceTree = ""; }; 31584619281B08F5004ADB8B /* AutofillLoginListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginListViewModel.swift; sourceTree = ""; }; + 315C77802CFA414400699683 /* AIChat */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = AIChat; sourceTree = ""; }; 3161D13127AC161B00285CF6 /* DownloadMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadMetadata.swift; sourceTree = ""; }; 31669B9928020A460071CC18 /* SaveLoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveLoginViewModel.swift; sourceTree = ""; }; 316790E42C9352190090B0A2 /* MarketplaceAdPostbackManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackManagerTests.swift; sourceTree = ""; }; 316931D627BD10BB0095F5ED /* SaveToDownloadsAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToDownloadsAlert.swift; sourceTree = ""; }; 316931D827BD22A80095F5ED /* DownloadActionMessageViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActionMessageViewHelper.swift; sourceTree = ""; }; + 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatSettings.swift; sourceTree = ""; }; 3170048127A9504F00C03F35 /* DownloadMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadMocks.swift; sourceTree = ""; }; 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillInterfaceEmailTruncatorTests.swift; sourceTree = ""; }; 31794BFF2821DFB600F18633 /* DuckUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DuckUI; sourceTree = ""; }; + 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAIChatView.swift; sourceTree = ""; }; 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketplaceAdPostbackStorage.swift; sourceTree = ""; }; 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerStorage.swift; sourceTree = ""; }; 31951E8D2823003200CAF535 /* AutofillLoginDetailsHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillLoginDetailsHeaderView.swift; sourceTree = ""; }; @@ -3127,6 +3137,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 315C77822CFA41A400699683 /* AIChat in Frameworks */, 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */, 853273B624FFE0BB00E3C778 /* WidgetKit.framework in Frameworks */, 0238E44F29C0FAA100615E30 /* FindInPageIOSJSSupport in Frameworks */, @@ -3341,6 +3352,7 @@ 1DEAADEB2BA45B4400E25A97 /* SettingsAccessibilityView.swift */, 1DEAADED2BA45DFE00E25A97 /* SettingsDataClearingView.swift */, D65625A02C232F5E006EF297 /* SettingsDuckPlayerView.swift */, + 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */, ); name = MainSettings; sourceTree = ""; @@ -3588,6 +3600,30 @@ name = Downloads; sourceTree = ""; }; + 310EEA2C2CFFCD9B0043CA1A /* New Group */ = { + isa = PBXGroup; + children = ( + ); + path = "New Group"; + sourceTree = ""; + }; + 310EEA2D2CFFCDB60043CA1A /* AIChat */ = { + isa = PBXGroup; + children = ( + 310EEA2E2CFFCDBF0043CA1A /* AIChatSettingsTests.swift */, + ); + path = AIChat; + sourceTree = ""; + }; + 311C79E22CF790270021196A /* AIChat */ = { + isa = PBXGroup; + children = ( + 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */, + 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */, + ); + path = AIChat; + sourceTree = ""; + }; 3132FA2227A0776B00DD7A12 /* FilePreview */ = { isa = PBXGroup; children = ( @@ -3768,6 +3804,7 @@ 85875B5F29912A2D00115F05 /* SyncUI */, 37FCAACB2993149A000E420A /* Waitlist */, 31794BFF2821DFB600F18633 /* DuckUI */, + 315C77802CFA414400699683 /* AIChat */, ); path = LocalPackages; sourceTree = ""; @@ -4309,6 +4346,7 @@ 84E341941E2F7EFB00BDBA6F /* DuckDuckGo */ = { isa = PBXGroup; children = ( + 311C79E22CF790270021196A /* AIChat */, 6FD1BAE02B87A0E8000C475C /* AdAttribution */, AA4D6A8023DE4973007E8790 /* AppIcon */, F1C5ECF31E37812900C599A4 /* Application */, @@ -6523,6 +6561,7 @@ F1E092B31E92A6B900732CCC /* Core */ = { isa = PBXGroup; children = ( + 310EEA2C2CFFCD9B0043CA1A /* New Group */, 316790E32C9350980090B0A2 /* MarketplaceAdPostback */, 858479CA2B8795BF00D156C1 /* History */, EA7EFE662677F5BD0075464E /* PrivacyReferenceTests */, @@ -6533,6 +6572,7 @@ EE3B226929DE0EE10082298A /* FeatureFlags */, F1134EC91F40E74800B73467 /* Statistics */, F198D78F1E3976300088DA8A /* Utilities */, + 310EEA2D2CFFCDB60043CA1A /* AIChat */, ); name = Core; sourceTree = ""; @@ -6736,6 +6776,7 @@ 9F8FE9482BAE50E50071E372 /* Lottie */, 9F96F73A2C9144D5009E45D5 /* Onboarding */, 1E5918462CA422A7008ED2B3 /* Navigation */, + 315C77812CFA41A400699683 /* AIChat */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -7801,6 +7842,7 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, + 316AA45A2CF8E31F00A2ED28 /* AIChatSettings.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, @@ -7980,6 +8022,7 @@ 9FE05CEE2C36424E00D9046B /* OnboardingPixelReporter.swift in Sources */, 9821234E2B6D0A6300F08C57 /* UserAuthenticator.swift in Sources */, 310C4B47281B60E300BA79A9 /* AutofillLoginDetailsViewModel.swift in Sources */, + 31043B162CFA5B8E0028A97F /* AIChatPixelHandler.swift in Sources */, 85EE7F572246685B000FE757 /* WebContainerViewController.swift in Sources */, CB48D3332B90CE9F00631D8B /* PageRefreshStore.swift in Sources */, 1EC458462948932500CB2B13 /* UIHostingControllerExtension.swift in Sources */, @@ -8111,6 +8154,7 @@ 8590CB67268A2E520089F6BF /* RootDebugViewController.swift in Sources */, 1DEAADEA2BA4539800E25A97 /* SettingsAppearanceView.swift in Sources */, B623C1C22862CA9E0043013E /* DownloadSession.swift in Sources */, + 317CA3432CFF82E100F88848 /* SettingsAIChatView.swift in Sources */, 9F7CFF7F2C8A94F70012833E /* OnboardingView+AddressBarPositionContent.swift in Sources */, 985892522260B1B200EEB31B /* ProgressView.swift in Sources */, 85BA585A1F3506AE00C6E8CA /* AppSettings.swift in Sources */, @@ -8313,6 +8357,7 @@ 987130C8294AAB9F00AB05E0 /* BookmarksTestHelpers.swift in Sources */, 9F4CC51D2C48D240006A96EB /* CoreDataDatabaseTestUtilities.swift in Sources */, C185ED672BD43DA100BAE9DC /* ImportPasswordsStatusHandlerTests.swift in Sources */, + 310EEA2F2CFFCDC60043CA1A /* AIChatSettingsTests.swift in Sources */, 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */, 85C503FD2CF0E7B10075DF6F /* MockFireproofing.swift in Sources */, 6F3529FF2CDCEDFF00A59170 /* OmniBarLoadingStateBearerTests.swift in Sources */, @@ -11426,6 +11471,10 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Common; }; + 315C77812CFA41A400699683 /* AIChat */ = { + isa = XCSwiftPackageProductDependency; + productName = AIChat; + }; 31E69A62280F4CB600478327 /* DuckUI */ = { isa = XCSwiftPackageProductDependency; productName = DuckUI; diff --git a/DuckDuckGo/AIChat/AIChatPixelHandler.swift b/DuckDuckGo/AIChat/AIChatPixelHandler.swift new file mode 100644 index 0000000000..b4e1ccd4b5 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatPixelHandler.swift @@ -0,0 +1,32 @@ +// +// AIChatPixelHandler.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AIChat +import Core + +struct AIChatPixelHandler: AIChatPixelHandling { + func fire(pixel: AIChatPixel) { + switch pixel { + case .openAfter10min: + Pixel.fire(pixel: .openAIChatAfter10min) + case .openBefore10min: + Pixel.fire(pixel: .openAIChatBefore10min) + } + } +} diff --git a/DuckDuckGo/AIChat/AIChatSettings.swift b/DuckDuckGo/AIChat/AIChatSettings.swift new file mode 100644 index 0000000000..6a0b1e3706 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatSettings.swift @@ -0,0 +1,108 @@ +// +// AIChatSettings.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import BrowserServicesKit +import AIChat +import Foundation +import Core + +/// 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 AIChatSettings: AIChatSettingsProvider { + enum SettingsValue: String { + case aiChatURL + + var defaultValue: String { + switch self { + case .aiChatURL: return "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=4" + } + } + } + + private let privacyConfigurationManager: PrivacyConfigurationManaging + private var remoteSettings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + privacyConfigurationManager.privacyConfig.settings(for: .aiChat) + } + private let internalUserDecider: InternalUserDecider + private let userDefaults: UserDefaults + + init(privacyConfigurationManager: PrivacyConfigurationManaging, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = .standard) { + self.internalUserDecider = internalUserDecider + self.privacyConfigurationManager = privacyConfigurationManager + self.userDefaults = userDefaults + } + + // MARK: - Public + + var aiChatURL: URL { + guard let url = URL(string: getSettingsData(.aiChatURL)) else { + return URL(string: SettingsValue.aiChatURL.defaultValue)! + } + return url + } + + var isAIChatBrowsingMenuUserSettingsEnabled: Bool { + userDefaults.showAIChatBrowsingMenu + } + + var isAIChatFeatureEnabled: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) || internalUserDecider.isInternalUser + } + + var isAIChatBrowsingToolbarShortcutFeatureEnabled: Bool { + let isBrowsingToolbarShortcutFeatureFlagEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.browsingToolbarShortcut) + let isInternalUser = internalUserDecider.isInternalUser + let isFeatureEnabled = isBrowsingToolbarShortcutFeatureFlagEnabled || isInternalUser + return isFeatureEnabled && isAIChatBrowsingMenuUserSettingsEnabled + } + + func enableAIChatBrowsingMenuUserSettings(enable: Bool) { + userDefaults.showAIChatBrowsingMenu = enable + } + + // MARK: - Private + + private func getSettingsData(_ value: SettingsValue) -> String { + if let value = remoteSettings[value.rawValue] as? String { + return value + } else { + Pixel.fire(pixel: .aiChatNoRemoteSettingsFound(settings: value.rawValue)) + return value.defaultValue + } + } +} + +private extension UserDefaults { + enum Keys { + static let showAIChatBrowsingMenu = "aichat.settings.showAIChatBrowsingMenu" + } + + static let showAIChatBrowsingMenuDefaultValue = true + + @objc dynamic var showAIChatBrowsingMenu: Bool { + get { + value(forKey: Keys.showAIChatBrowsingMenu) as? Bool ?? Self.showAIChatBrowsingMenuDefaultValue + } + + set { + guard newValue != showAIChatBrowsingMenu else { return } + set(newValue, forKey: Keys.showAIChatBrowsingMenu) + } + } +} diff --git a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/AIChat-24.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b806da0f5968053e90bf582df2ee923e9bbd50b3 GIT binary patch literal 1590 zcmY!laBkm(vTRHc6J~)rRJr8Ji%pz;v2WjoRZWc1%0<1ppRTqlk-zjA+Fc= zNzF?y$xtu`Dh~n*0Zk|_DN0Su<*H!ZI`wp(h@pV%_rIc>-MSuEP2X;FMCwP27~>KZ zp{^s}i|@|dXnT@Paj7Zy`ONFD!*168{`h--{O|AU=ii@iS93e>`ToC|zxUp)TXea9 zd9Ro4yx_e$dB?K$JU-pDDJ7_D{tPyBf?^i0aWy8`>+EvpsK3Ys+ zaekrvE#dbyF3&4^ml`4;{JeF)%k2TDd)wvPIw@_kZ=#ATlh5uIh-dpW$HruSNs7M& zf8D&x6SWe%cJ10_kfC?jHo6w5C{0|>$n*Mh!*LxEg&FpHYCb~+xFJ(rV*pa*f57*VR6z(_enVqbtYU9h1 zxY=>aJag7nV$J!wXO2na%02vL?K4-GqweI4RfozJ#%<8w{54LxI7`NCT0yO3=$qMV z;=QC8^^mRjk}LTKrPf@@_jvnReM9p!wuRLa>!U6%tA27}Re_OFj+|K2^ck%OO6E!5 ztrF6&zN+3jVYY_*dEVc#!n!Wy35V`jnXGI0&hArTpAc5`O5o9!Rf#*wF3&djTHM`G zv7jStnag(esHxE!HnSete4U!i#&q`Z?R%U&|B5+l?$e67NV-P<2dd%=DxqmTK@{u-HE zzh+k5J$UcN?(*ZZAH;L~mob2H3pBaI@(m~>KuQ5~69af|GJ%VN$_B^0y!?`4h3Hsl z9tx=}NLA1eNKA)iD&N$U%tWXB3WaE3c>yXdfaL|8VPpamLa<;a1*I0}mlh?b7At^C zBT$9}0)6NFypq&BppBqR4HFDVECPxtm_mh+vOY)%QIL6OrU0F)0P;bQ0?Z!g{8FG^ zLk#y9Ly7=cV3?tp1oB`p+(MAUARc#4EJ@7CPe;{SQIwj-WuRcr1@S&O2+T}PjZGDR z!cbrc3>C0M9!$u{5NIWekfDJIx++5hGhjfVsxkr^j3#7kUUZB>AC&7{K(Sl zUTfbTKWz5m_36tWcX(!QIC{8OZCToH<^+$Y>$u-Y3Z?IV>AIxrbJLOt`?Yi%5P4>l&g0~BmxS#m+Z+3~kc*5%83O+-(J0QW@XQmc)EB0Zk4%mz)&T~zDXSuFF-^jBDd#^oz3=$`kvl6OZe&f#nzH$IfpK)G{qd^-Lgd1=VSBcd%R(RE0x3amt1Y$ zd@#@P=9K8xt`FI+XIGVPc(>!JL{INU!zDb~rhi%Lp11JK>N?yn5Hf4QnoDycZ~fyn zT~N|0tEYA(W3fQlfk_L@WG2L?p89&AXp{TZt|hPT_$-iH;GmKtD!w{Ux0vDEDW^#j zE-p~XnQ%mzr9RX(?y$SUhSSZBD-=C+GG^XxiC_sg=}|rB&%o=PxTkhn;wjS$jeoN) zy;8kxyySk%?nS0=r$$aZ)^;|dbpOL=IR_Qct_)@5YH9SSqi30j;z`E zI7d&G@zBoVRoP9k&W9CVtx)(|rT2EG+noi{b>fK&HMNXQUwNES>pQYmLEp~$hh@m7 zsL8CJ4!<;_LKOr~WjlRnR+0F}{W0kBZ}aEo|AkM7%+%#d%}aqMX&?)fp}9cW5|Xpc z4UItZU{PbZC@8<<$<-!9I9Kbtq?TnSrv|w@DL4mnA@P!nxeB=qVTA&`z|e)WFy-J< z$+=u`HYTGuIS(O?C~b1N5Nrf12T2reR1QKvHZf$~8C+>xd0c^9SYjO%ZID(EjhTuet8DuAkhAVg{ET2bO2Tmmfr!Sb$#U@F)*u{aydf+QIb7p2%nlMMko z0us|Nx1uOD zjmtp6(2xsK^@B(SGgDJzQw5+n6o4!M0R>TS04zTORR910 literal 0 HcmV?d00001 diff --git a/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json new file mode 100644 index 0000000000..0fcb063039 --- /dev/null +++ b/DuckDuckGo/BrowsingMenu/BrowsingMenu.xcassets/MenuAIChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "AIChat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift b/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift index 6b7f6b9abe..cc3d04d61b 100644 --- a/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift +++ b/DuckDuckGo/BrowsingMenu/BrowsingMenuViewController.swift @@ -249,8 +249,9 @@ extension BrowsingMenuViewController: UITableViewDelegate { switch menuEntries[indexPath.row] { case .regular(_, _, _, _, let action): - dismiss(animated: true) - action() + dismiss(animated: true) { + action() + } case .separator: break } diff --git a/DuckDuckGo/MainViewController+Segues.swift b/DuckDuckGo/MainViewController+Segues.swift index 5eafe2cdea..beee686007 100644 --- a/DuckDuckGo/MainViewController+Segues.swift +++ b/DuckDuckGo/MainViewController+Segues.swift @@ -296,6 +296,9 @@ extension MainViewController { fireproofing: fireproofing, websiteDataManager: websiteDataManager) + let aiChatSettings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + let settingsViewModel = SettingsViewModel(legacyViewProvider: legacyViewProvider, subscriptionManager: AppDependencyProvider.shared.subscriptionManager, subscriptionFeatureAvailability: subscriptionFeatureAvailability, @@ -304,7 +307,8 @@ extension MainViewController { historyManager: historyManager, syncPausedStateManager: syncPausedStateManager, privacyProDataReporter: privacyProDataReporter, - textZoomCoordinator: textZoomCoordinator) + textZoomCoordinator: textZoomCoordinator, + aiChatSettings: aiChatSettings) Pixel.fire(pixel: .settingsPresented) if let navigationController = self.presentedViewController as? UINavigationController, diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index ab0835156d..3e09cddf25 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -38,6 +38,7 @@ import Onboarding import os.log import PageRefreshMonitor import BrokenSitePrompt +import AIChat class MainViewController: UIViewController { @@ -186,6 +187,16 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? + private lazy var aiChatNavigationController: UINavigationController = { + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + let aiChatViewController = AIChatViewController(settings: settings, + webViewConfiguration: WKWebViewConfiguration.persistent(), + pixelHandler: AIChatPixelHandler()) + aiChatViewController.delegate = self + return UINavigationController(rootViewController: aiChatViewController) + }() + init( bookmarksDatabase: CoreDataDatabase, bookmarksDatabaseCleaner: BookmarkDatabaseCleaner, @@ -351,6 +362,7 @@ class MainViewController: UIViewController { let launchTime = CFAbsoluteTimeGetCurrent() - appDidFinishLaunchingStartTime Pixel.fire(pixel: .appDidShowUITime(time: Pixel.Event.BucketAggregation(number: launchTime)), withAdditionalParameters: [PixelParameters.time: String(launchTime)]) + self.appDidFinishLaunchingStartTime = nil /// We only want this pixel to be fired once } } @@ -1688,7 +1700,6 @@ class MainViewController: UIViewController { Pixel.fire(pixel: pixel, withAdditionalParameters: pixelParameters, includedParameters: [.atb]) } - } extension MainViewController: FindInPageDelegate { @@ -2347,6 +2358,11 @@ extension MainViewController: TabDelegate { segueToReportBrokenSite(entryPoint: .toggleReport(completionHandler: completionHandler)) } + func tabDidRequestAIChat(tab: TabViewController) { + aiChatNavigationController.modalPresentationStyle = .fullScreen + tab.present(aiChatNavigationController, animated: true, completion: nil) + } + func tabDidRequestBookmarks(tab: TabViewController) { Pixel.fire(pixel: .bookmarksButtonPressed, withAdditionalParameters: [PixelParameters.originatedFromMenu: "1"]) @@ -2931,3 +2947,10 @@ extension MainViewController: AutofillLoginSettingsListViewControllerDelegate { controller.dismiss(animated: true) } } + +// MARK: - AIChatViewControllerDelegate +extension MainViewController: AIChatViewControllerDelegate { + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) { + loadUrlInNewTab(url, inheritedAttribution: nil) + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json new file mode 100644 index 0000000000..356f0fd6ea --- /dev/null +++ b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SettingsAIChat.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChat.imageset/SettingsAIChat.pdf new file mode 100644 index 0000000000000000000000000000000000000000..adad6866a41da085c02f8b7c3b9225e9c0cd2a66 GIT binary patch literal 2766 zcma)8dsq}z6i>xZT{XiLO~NjMBpU3_%syB`0v1+5v~`K>(-6nqVI5dz*4Q&3QRp!h-_Cg2OwG(t2qA3*{NSVH+o{4}xd?C!u~TYCSPIrE!y&pr3t-#O=w zR|Lzw5N`>OhX4Vf=g0B@@bd%waDvh>3jhj1C4}JMD{)FgfH^?mEiey&aKft~QC2I# zbUaQ{cFk}qawDZ;NSgX6K}P@~oagV)GcbArkLM)j;+p{XJR@<@8O~6>+tP`w` z{qF<>V~2Hu)%WfR$$*RjtY6)Me%U4j-WVdWi49^C4S}~1%O3Y=jGJ!K|~0x`d)4~Z39>NPR?m*i=U5BaM6%|L9X(r!|MFz8RktH^0Wqfv`f84b zm>77ZFm8Vi4PQx09 z$1B>}3^!tC#jgtpKYuQ2dPRFnPS=BmyE!Mf&P{ptJge*HoYGCVuKxP4^Dht2+M<-L zIiK8(j#}nH-YEEe&ttDWKN;RCQ!OZ2hse{5Qv8#(TNdA+&1~9IoLHbrxfwR``pVYV zvc9=-ZQ7CjZid;iagfgS+n!X(#3g(w8XheG6%bb+;yPtWR~ZvGH*u}4fdU!*T07;R6M`$ zxb^Xa(3p&X0|c`@_FVq@b?uWCr2oF~;uUEjdqsQKD349cQy1KjxfRr082!h_8M*o> zO^(8iG&t}e)?5GNL{$<>VT}ASmwcw~YXy=HL$5Q(aEfJnMKeEiT zxiWRKBul=%WG}vBK%R@@OXuu<&8~%K_V=&r?_QOt8NgH@3#hD#Jih7N^;Ju^B$Nat zhkAIFhbb;!UKbcv@wxJ*#FzIB&(DAVQbWeg4W7g|;AB?oyAgj&%A2U=!GU8-Yn)g9 zhEEDv_F4H#-te}DQwrIZj+n!geEkUit)sclg`?!<*_tN{f63^}ANc)ukCwjvX>()0 z0Zr{oD4$_#`$m^Kg(O^~+#f2Av{mC$zeQC~Gw!Tzx|Qb`mNw;&BhGzYF1)Ao3zOG0 zXQAQD!9ksU0)38e+!w0xYo4Y*jmoMu6G}&wFBuY$JABW~h>oN+LB|IVA9J++{iuq= zq)y$9;(&<`c*Hhfy^OCmx zIrr)~?bmCuKjU2m9jDJei@ut&ds4q88Sf6AQ`-^R=Cnwsu1+2HRc+<)HwSz$s`-u1 zR4-{vc4b*svFr7Uv3>aN4$Zz*$@A(O=iQrSnthIWBkBI@OANaQNn0P?6UqjiZ++A_ zXX?P}M`^KmUE|x^oKTnSCkHE^_c!>xKpXWc!T{Lz#$9z}GzDuUT#+q59H_wc z5Cak`WK*PA$g&02*=eM?E3rFJa=i>T79*UCSgTc!{|1QISn}y*Nr7viW88wU)>=!b z3}yFc7^U z6oI#_7a(|9dJ$r8W-n+7g>XYwTZ|qjwS=C>hAlK09^M249Zz_I38dQ75<0$;rWwG7 f#e59>2y!t2_h_!~xSla@8Wjn|yz%2hK9v6hA}e?H literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json new file mode 100644 index 0000000000..bb0ab0cbc3 --- /dev/null +++ b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "SettingsAIChatHero.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf b/DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a47a1810197e86e4d9c2c075104133d15258033c GIT binary patch literal 3779 zcma)9X&{vA`!36v#$YVTGBOek${5R#t*jy0Qj~3m83w~Jh7hvNWEoqN(=rj!M3k+v zmo*`4iDZu~N%lzUKk9VO|NPFEbANfC_j>Q=e(vjj-VfI$V`XT31gWSF0wG~aFrup$ z2nN&Eh9QiC$tO;eF=X7YH?^M+Hw;WkQCVpxV7j`j%zp*KNcBHwD*xw9mH(WH{4bfh zx;x$De6c%pLCXK&ZR|-P<47=sF@eS15a&k3;(qZ&nB#ms$at6vE8T_#u_^?RNjQuT zD0p|@jdtIImHb9u*7jDSvgRilLtl3n`CQV+Ja;=TR)?<}{TyI=HuZs*c<*5`5k>iY z23Bc=-#mPC#q!5y|MtCSKh{!OZh!aRD*aw{sdjs7eG0L#G2Qdv^6|-hqVu3wbKuffPM{ zBbOqyv;A&5S~1(z8F`$E7H>f0yqai@oRt%~YX1@o1n>ooKnhpTVLbJke9yNbG#=8vE{V73`2&AsPV)p%Vu73?F!dd39`<1$_<)HKx&HAJQ73l$kp956mcwgrTMi7ANQzpSAJwTwkMq1~ zc2Pug%8=DwD~1c6wHALqLmxWO`|-@)J5zU~;&x%A(h~|Vuc#>*?a4v@x7br<= znjg5rH{Gnm9_R8L&^mOQ!EG24!=D!~q8Lf#I&^%+He>+XJa8K2KbTt2goy0jRFvZ= z6(Gk7g2b1zd5v6NdEr3(Mm`Bxig(LF=d}AF&Bw3G4O17}XC4JB`?bow3oO-;X+?$< zuTZfWT<^VsZ$gw7s?mmB*G(Fh+V=F2R+WHRVY9|q>j4co4&Pk1w0<8g01mIuGJ${3 zs>Wq0Wc zcXf$QT|R@I)yprv>~cZm2DJ|ii4f&Z`(bfP3o;sT>3miY z+E&`v`0G+40?BMqVyt`AE4inyFTX!J8_`?>+Bd{kAJM<}T%VuNL&?)=kBNEvSPgG7 zgd#U-tcJDyfYjK=TN|?wunzpti+Mjy4g;6AJ*2W^(_+ct2!7Nx_Ez z9OhvU{P-VdW%ML-`BF)hW)>W2uM{3LEO>IbL(q>DR)Zy^>FHqcQP&cuaU1!ByZI6a zswqRIsbF=J{hf1G(d&;Dq?nzPk;X~c)yC$M;+*D2VzolIc~7uYYD{X(Eq7t8UC)NC z`*Yw38uhF8`(ynL&p+s<2Wfe)bH2J4bY)JkCYbxjylLb0lVSr_6{13E9VXw2s@=V< zV`*klNnsGDL0a1&wh~(2Xg^2W_hjX1wkmDrx>yruKE*I5h@+wa>E>3PP}vg(iw!G; zXAV>3r(EThkOP0|L}TVrA;Mq3&f;S;bcK>erao8|+$pJZ zJ+10`Mw?Kcp7GKbhp+Ov17UvRe zPP(Rg0Kj>52A8X1u>D!}7Eo=hMg*QJ2qjes9nF9-XRYWaLSxHTDR5W;o_+y7c9o++ zisPEM(b|HhTzgzWMay|n(Z$h3EkKl>Erq1VTz=^8SYLmkuF^0SB2%bXn0&{HocpMi zewFr3MhZ}~qHdsR*l5@1%ua8#Y`gnP8w{x8-Tdkpmr@c}g29H9Wm_JISJi}CI}Svw zQV;BAPJf8Co1WwT@;Q=`CT^;Yw(H35O~C{hK35y)cpFZbxu6rA@5*@l4zFA2_BEC7 z-PUw`n20wLhqj9ltypARDl*?XG|Hf_b)pUf5w;dmKZseUXjCa;#Ifjk^txEZ z28xt;Gs7ElULC;W=qmD<)92oIQlrNWWmKl8{30>!U;_AV&se?vmBaX)%HvuW(;QyU zJH`yOJvgC-ijN2Fry2R^z02oz91?we3aAx?v1#nO6f8+x52Y|_6;LV{Aq2DVvhbW_ zq+5f+bKsK^0~O(+?W9$Pi6Y=3smYFS`SGAYj4=Nr+{~R)_m7_j%akacg8pt8Yb2ik z?VUdSK$G)>H9(-wC9@9Zun~_AYcQt$0l|)uf5g9i)P1~h9)GU2?4=|;y(2wQzNkv? zg8umQ>$!m3XqyRnCscZSPxGW$UVPJhv$j!{(|O%M6#3An+&Wn=sccPChKHibwl7Fk z7wQTt{|fPk=6vwf%axfJU z-Lek?jFQxw42NztzDN+U1c+;%p_tqzNV&H2=+*yG25xennH^q&M{}{~y)t?L5XH=h z;=u-8$LZb@CAls&hrtQ$QQ$M1%xfL1TM*b5G@x6z&?9!cJY}iF#>riH8#p6S6}( zDeY4orWEKpNRG)uSAL|3MG?D01PJgW7jGK6!Lv3G1+Ed6@(bq`3%J@m)7Qb9>DOjY zixMJ^SwBtXoV_AmZ#-?uNW6<@cbE!#q*ORsdKn?M{Dxea!}CQdr$3navX)X-!A^2Z zJckHmGz(C>#RV1}!30Uk@d^mFO;^*`DYmbr$x-ZkM zY{2L?)qTUG?7M-U_$i{!`K>)m;hEybR#6#Ro3SQzi_Zn~S=Bl}KuYmfp&O9i4YSKI z1K+1{s-X_UY4?1|DQ~D0xM^+w{f3FtvzN*bjRZJh-|!94-|^a6K0R3DmpJjkym!H6 zhP^+;R|xR*N{lq+rey*5qYt8aJ=)#%h&c0MulZ9y*rqxi(G?~L5_*Gg`xs_T;{wKJ zHKv);Hg-dL^Vt?vv+$Lod{l8rX9J2VjHe+&Wk)mzvi5sC>pOfKr~*wMG&ZSSwH*`u za@J>}(Ri#Z&dT3#5bZF2^7z7aL>M4#RMX2@N=9v2;T-+?taf1&u zx0^9t_pSMaXe?be?#SV>pcFPjM=R#$c|$#Gtk z6Y;85zP7UOey#Mb{H|E*$v1LxJkq-qe4;c)gNQfZD+t8z4Xj;Pn)Vh6nWW^#r(*Zr z@Mhnn3T!qMe;S<(v)_K!$6xw+M~?rgr+*9c9i{!tkSJ?tsQsnaRsVu_e2Sj0FOeJo zb8z}orQ3%1;a~_WjK^=;ehP>6#OM=)VGba~C_%->&MUCzjPKY=?pk?Dsha z#6Z?w { + Binding( + get: { self.aiChatSettings.isAIChatBrowsingMenuUserSettingsEnabled }, + set: { newValue in + self.aiChatSettings.enableAIChatBrowsingMenuUserSettings(enable: newValue) + } + ) + } + var textZoomLevelBinding: Binding { Binding( get: { self.state.textZoom.level }, @@ -386,7 +397,8 @@ final class SettingsViewModel: ObservableObject { historyManager: HistoryManaging, syncPausedStateManager: any SyncPausedStateManaging, privacyProDataReporter: PrivacyProDataReporting, - textZoomCoordinator: TextZoomCoordinating) { + textZoomCoordinator: TextZoomCoordinating, + aiChatSettings: AIChatSettingsProvider) { self.state = SettingsState.defaults self.legacyViewProvider = legacyViewProvider @@ -398,6 +410,7 @@ final class SettingsViewModel: ObservableObject { self.syncPausedStateManager = syncPausedStateManager self.privacyProDataReporter = privacyProDataReporter self.textZoomCoordinator = textZoomCoordinator + self.aiChatSettings = aiChatSettings setupNotificationObservers() updateRecentlyVisitedSitesVisibility() @@ -447,8 +460,9 @@ extension SettingsViewModel { duckPlayerEnabled: featureFlagger.isFeatureOn(.duckPlayer) || shouldDisplayDuckPlayerContingencyMessage, duckPlayerMode: appSettings.duckPlayerMode, duckPlayerOpenInNewTab: appSettings.duckPlayerOpenInNewTab, - duckPlayerOpenInNewTabEnabled: featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab) - + duckPlayerOpenInNewTabEnabled: featureFlagger.isFeatureOn(.duckPlayerOpenInNewTab), + aiChatEnabled: aiChatSettings.isAIChatFeatureEnabled + ) updateRecentlyVisitedSitesVisibility() @@ -665,6 +679,7 @@ extension SettingsViewModel { case subscriptionFlow(origin: String? = nil) case restoreFlow case duckPlayer + case aiChat // Add other cases as needed var id: String { @@ -675,6 +690,7 @@ extension SettingsViewModel { case .subscriptionFlow: return "subscriptionFlow" case .restoreFlow: return "restoreFlow" case .duckPlayer: return "duckPlayer" + case .aiChat: return "aiChat" // Ensure all cases are covered } } @@ -683,7 +699,7 @@ extension SettingsViewModel { // Default to .sheet, specify .push where needed var type: DeepLinkType { switch self { - case .netP, .dbp, .itr, .subscriptionFlow, .restoreFlow, .duckPlayer: + case .netP, .dbp, .itr, .subscriptionFlow, .restoreFlow, .duckPlayer, .aiChat: return .navigationLink } } diff --git a/DuckDuckGo/TabDelegate.swift b/DuckDuckGo/TabDelegate.swift index 90424e25fb..6865b4bf65 100644 --- a/DuckDuckGo/TabDelegate.swift +++ b/DuckDuckGo/TabDelegate.swift @@ -59,6 +59,8 @@ protocol TabDelegate: AnyObject { func tabDidRequestDownloads(tab: TabViewController) + func tabDidRequestAIChat(tab: TabViewController) + func tabDidRequestAutofillLogins(tab: TabViewController) func tabDidRequestSettings(tab: TabViewController) diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index d76c6c18b2..8cba18608b 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -28,25 +28,32 @@ import PrivacyDashboard extension TabViewController { - func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { + private var shouldShowAIChatInMenuHeader: Bool { + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + return settings.isAIChatBrowsingToolbarShortcutFeatureEnabled + } + + private var shouldShowPrintButtonInBrowsingMenuList: Bool { shouldShowAIChatInMenuHeader } + func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { var entries = [BrowsingMenuEntry]() - entries.append(BrowsingMenuEntry.regular(name: UserText.actionNewTab, - accessibilityLabel: UserText.keyCommandNewTab, - image: UIImage(named: "Add-24")!, - action: { [weak self] in + let newTabEntry = BrowsingMenuEntry.regular(name: UserText.actionNewTab, + accessibilityLabel: UserText.keyCommandNewTab, + image: UIImage(named: "Add-24")!, + action: { [weak self] in self?.onNewTabAction() - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionShare, image: UIImage(named: "Share-24")!, action: { [weak self] in + let shareEntry = BrowsingMenuEntry.regular(name: UserText.actionShare, image: UIImage(named: "Share-24")!, action: { [weak self] in guard let self = self else { return } guard let menu = self.chromeDelegate?.omniBar.menuButton else { return } Pixel.fire(pixel: .browsingMenuShare) self.onShareAction(forLink: self.link!, fromView: menu) - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionCopy, image: UIImage(named: "Copy-24")!, action: { [weak self] in + let copyEntry = BrowsingMenuEntry.regular(name: UserText.actionCopy, image: UIImage(named: "Copy-24")!, action: { [weak self] in guard let strongSelf = self else { return } if !strongSelf.isError, let url = strongSelf.webView.url { strongSelf.onCopyAction(forUrl: url) @@ -58,16 +65,34 @@ extension TabViewController { let addressBarBottom = strongSelf.appSettings.currentAddressBarPosition.isBottom ActionMessageView.present(message: UserText.actionCopyMessage, presentationLocation: .withBottomBar(andAddressBarBottom: addressBarBottom)) - })) + }) - entries.append(BrowsingMenuEntry.regular(name: UserText.actionPrint, image: UIImage(named: "Print-24")!, action: { [weak self] in + let printEntry = BrowsingMenuEntry.regular(name: UserText.actionPrint, image: UIImage(named: "Print-24")!, action: { [weak self] in Pixel.fire(pixel: .browsingMenuPrint) self?.print() - })) + }) + + let chatEntry = BrowsingMenuEntry.regular(name: UserText.actionOpenAIChat, image: UIImage(named: "AIChat-24")!, action: { [weak self] in + Pixel.fire(pixel: .browsingMenuAIChat) + self?.openAIChat() + }) + + if shouldShowAIChatInMenuHeader { + entries.append(newTabEntry) + entries.append(chatEntry) + entries.append(shareEntry) + entries.append(copyEntry) + } else { + entries.append(newTabEntry) + entries.append(shareEntry) + entries.append(copyEntry) + entries.append(printEntry) + } return entries } + var favoriteEntryIndex: Int { 1 } func buildShortcutsMenu() -> [BrowsingMenuEntry] { @@ -80,6 +105,16 @@ extension TabViewController { let linkEntries = buildLinkEntries(with: bookmarksInterface) entries.append(contentsOf: linkEntries) + if shouldShowPrintButtonInBrowsingMenuList { + entries.append(.regular(name: UserText.actionPrintSite, + accessibilityLabel: UserText.actionPrintSite, + image: UIImage(named: "Print-16")!, + action: { [weak self] in + Pixel.fire(pixel: .browsingMenuListPrint) + self?.print() + })) + } + if let domain = self.privacyInfo?.domain { entries.append(self.buildToggleProtectionEntry(forDomain: domain)) } @@ -440,6 +475,10 @@ extension TabViewController { delegate?.tabDidRequestBookmarks(tab: self) } + private func openAIChat() { + delegate?.tabDidRequestAIChat(tab: self) + } + private func buildToggleProtectionEntry(forDomain domain: String) -> BrowsingMenuEntry { let config = ContentBlocking.shared.privacyConfigurationManager.privacyConfig let isProtected = !config.isUserUnprotected(domain: domain) diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index f4d0d7460b..ff3430d987 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -49,7 +49,10 @@ public struct UserText { public static let actionCopy = NSLocalizedString("action.title.copy", value: "Copy", comment: "Copy action") public static let actionCopyMessage = NSLocalizedString("action.title.copy.message", value: "URL copied", comment: "Floating message indicating URL has been copied") public static let actionShare = NSLocalizedString("action.title.share", value: "Share", comment: "Share action") - public static let actionPrint = NSLocalizedString("action.title.print", value: "Print", comment: "Print action") + public static let actionPrint = NSLocalizedString("action.title.print", value: "Print", comment: "Print action in the menu header") + public static let actionPrintSite = NSLocalizedString("action.title.print.site", value: "Print", comment: "Print action in the menu list") + public static let actionOpenAIChat = NSLocalizedString("action.title.aichat", value: "Chat", comment: "Open AI Chat action in the menu list") + public static let actionOpenBookmarks = NSLocalizedString("action.title.bookmarks", value: "Bookmarks", comment: "Button: Open bookmarks list") public static let actionEnableProtection = NSLocalizedString("action.title.enable.protection", value: "Enable Privacy Protection", comment: "Enable protection action") public static let actionDisableProtection = NSLocalizedString("action.title.disable.protection", value: "Disable Privacy Protection", comment: "Disable protection action") @@ -1293,8 +1296,8 @@ But if you *do* want a peek under the hood, you can find more information about public static let settingsOpenVideosInDuckPlayerLabel = NSLocalizedString("duckplayer.settings.open-videos-in", value: "Open YouTube Videos in Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let duckPlayerFeatureName = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") - public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a new tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") - + public static let settingsOpenDuckPlayerNewTabLabel = NSLocalizedString("duckplayer.settings.open-new-tab-label", value: "Open Duck Player in a New Tab", comment: "Settings screen cell text for DuckPlayer settings to open in new tab") + public static let settingsOpenVideosInDuckPlayerTitle = NSLocalizedString("duckplayer.settings.title", value: "Duck Player", comment: "Settings screen cell text for DuckPlayer settings") public static let settingsDuckPlayerFooter = NSLocalizedString("duckplayer.settings.footer", value: "DuckDuckGo provides all the privacy essentials you need to protect yourself as you browse the web.", comment: "Footer label in the settings screen for Duck Player") @@ -1310,6 +1313,18 @@ But if you *do* want a peek under the hood, you can find more information about static let duckPlayerContingencyMessageBody = NSLocalizedString("duck-player.video-contingency-message", value: "Duck Player's functionality has been affected by recent changes to YouTube. We’re working to fix these issues and appreciate your understanding.", comment: "Message explaining to the user that Duck Player is not available") static let duckPlayerContingencyMessageCTA = NSLocalizedString("duck-player.video-contingency-cta", value: "Learn More", comment: "Button for the message explaining to the user that Duck Player is not available so the user can learn more") + // MARK: - AI Chat + public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") + public static let aiChatFeatureName = NSLocalizedString("aichat.settings.title", value: "AI Chat", comment: "Settings screen cell text for AI Chat settings") + + public static let aiChatSettingsEnableFooter = NSLocalizedString("aichat.settings.enable.footer", value: "Turning this off will hide the AI Chat feature in the DuckDuckGo app.", comment: "Footer text for AI Chat settings") + static let aiChatSettingsCaptionWithLinkMarkdown = NSLocalizedString("ai-chat.preferences.text.markdown", value: """ +AI Chat is an optional feature available at [duck.ai](ddgquicklink://duck.ai) that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models. +[Learn More](ddgquicklink://duckduckgo.com/duckduckgo-help-pages/aichat/) +""", comment: "Ai Chat preferences explanation with a markdown link. Do not translate what's inside [] and ()") + public static let aiChatSettingsEnableBrowsingMenuToggle = NSLocalizedString("aichat.settings.enable.browsing-menu-toggle", value: "Show AI Chat in Browser Menu", comment: "Toggle text to enable/disable AI Chat in the browsing menu") + + // MARK: - New Tab Page // MARK: Shortcuts diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index f12ff46fea..f65a3bac1a 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -25,6 +25,9 @@ /* Add action - button shown in alert */ "action.title.add" = "Add"; +/* Open AI Chat action in the menu list */ +"action.title.aichat" = "Chat"; + /* Autofill Logins menu item opening the login list */ "action.title.autofill.logins" = "Passwords"; @@ -79,9 +82,12 @@ /* Paste and Go action */ "action.title.pasteAndGo" = "Paste & Go"; -/* Print action */ +/* Print action in the menu header */ "action.title.print" = "Print"; +/* Print action in the menu list */ +"action.title.print.site" = "Print"; + /* Refresh action - button shown in alert */ "action.title.refresh" = "Refresh"; @@ -136,6 +142,22 @@ /* No comment provided by engineer. */ "addWidget.title" = "One tap to your favorite sites."; +/* Ai Chat preferences explanation with a markdown link. Do not translate what's inside [] and () */ +"ai-chat.preferences.text.markdown" = "AI Chat is an optional feature available at [duck.ai](ddgquicklink://duck.ai) that lets you have private conversations with popular 3rd-party AI chat models. Your chats are not used to train chat models. +[Learn More](ddgquicklink://duckduckgo.com/duckduckgo-help-pages/aichat/)"; + +/* Toggle text to enable/disable AI Chat in the browsing menu */ +"aichat.settings.enable.browsing-menu-toggle" = "Show AI Chat in Browser Menu"; + +/* Footer text for AI Chat settings */ +"aichat.settings.enable.footer" = "Turning this off will hide the AI Chat feature in the DuckDuckGo app."; + +/* Settings screen cell text for AI Chat settings */ +"aichat.settings.title" = "AI Chat"; + +/* Title for DuckDuckGo AI Chat. Should not be translated */ +"aichat.title" = "DuckDuckGo AI Chat"; + /* No comment provided by engineer. */ "alert.message.bookmarkAll" = "Existing bookmarks will not be duplicated."; @@ -1083,7 +1105,7 @@ "duckplayer.settings.learn-more" = "Learn More"; /* Settings screen cell text for DuckPlayer settings to open in new tab */ -"duckplayer.settings.open-new-tab-label" = "Open Duck Player in a new tab"; +"duckplayer.settings.open-new-tab-label" = "Open Duck Player in a New Tab"; /* Settings screen cell text for DuckPlayer settings */ "duckplayer.settings.open-videos-in" = "Open YouTube Videos in Duck Player"; diff --git a/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift b/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift new file mode 100644 index 0000000000..93667a4aa9 --- /dev/null +++ b/DuckDuckGoTests/AIChat/AIChatSettingsTests.swift @@ -0,0 +1,131 @@ +// +// AIChatSettingsTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Core +@testable import DuckDuckGo +import BrowserServicesKit +import Combine + +class AIChatSettingsTests: XCTestCase { + + private var mockPrivacyConfigurationManager: PrivacyConfigurationManagerMock! + private var mockInternalUserDecider: MockInternalUserDecider! + private var mockUserDefaults: UserDefaults! + + override func setUp() { + super.setUp() + mockPrivacyConfigurationManager = PrivacyConfigurationManagerMock() + mockInternalUserDecider = MockInternalUserDecider() + mockUserDefaults = UserDefaults(suiteName: "TestDefaults") + } + + override func tearDown() { + mockUserDefaults.removePersistentDomain(forName: "TestDefaults") + mockPrivacyConfigurationManager = nil + mockInternalUserDecider = nil + mockUserDefaults = nil + super.tearDown() + } + + func testAIChatURLReturnsDefaultWhenRemoteSettingsMissing() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings = [:] + + let expectedURL = URL(string: AIChatSettings.SettingsValue.aiChatURL.defaultValue)! + XCTAssertEqual(settings.aiChatURL, expectedURL) + } + + func testAIChatURLReturnsRemoteSettingWhenAvailable() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + let remoteURL = "https://example.com/ai-chat" + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.settings = [ + .aiChat: [AIChatSettings.SettingsValue.aiChatURL.rawValue: remoteURL] + ] + + XCTAssertEqual(settings.aiChatURL, URL(string: remoteURL)) + } + + func testIsAIChatFeatureEnabledWhenFeatureIsEnabled() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledFeaturesForVersions = [ + .aiChat: [AppVersionProvider().appVersion() ?? ""] + ] + + XCTAssertTrue(settings.isAIChatFeatureEnabled) + } + + func testIsAIChatFeatureEnabledForInternalUser() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + mockInternalUserDecider.mockIsInternalUser = true + XCTAssertTrue(settings.isAIChatFeatureEnabled) + } + + func testEnableAIChatBrowsingMenuUserSettings() { + let settings = AIChatSettings(privacyConfigurationManager: mockPrivacyConfigurationManager, + internalUserDecider: mockInternalUserDecider, + userDefaults: mockUserDefaults) + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledFeaturesForVersions = [ + .aiChat: [AppVersionProvider().appVersion() ?? ""] + ] + + (mockPrivacyConfigurationManager.privacyConfig as? PrivacyConfigurationMock)?.enabledSubfeaturesForVersions = [ + AIChatSubfeature.browsingToolbarShortcut.rawValue: [AppVersionProvider().appVersion() ?? ""] + ] + settings.enableAIChatBrowsingMenuUserSettings(enable: false) + XCTAssertFalse(settings.isAIChatBrowsingToolbarShortcutFeatureEnabled) + + settings.enableAIChatBrowsingMenuUserSettings(enable: true) + XCTAssertTrue(settings.isAIChatBrowsingToolbarShortcutFeatureEnabled) + } +} + + +final private class MockInternalUserDecider: InternalUserDecider { + var mockIsInternalUser: Bool = false + var mockIsInternalUserPublisher: AnyPublisher { + Just(mockIsInternalUser).eraseToAnyPublisher() + } + + var isInternalUser: Bool { + return mockIsInternalUser + } + + var isInternalUserPublisher: AnyPublisher { + return mockIsInternalUserPublisher + } + + @discardableResult + func markUserAsInternalIfNeeded(forUrl url: URL?, response: HTTPURLResponse?) -> Bool { + return mockIsInternalUser + } +} diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index 7878b46cbd..47522ae542 100644 --- a/DuckDuckGoTests/MockTabDelegate.swift +++ b/DuckDuckGoTests/MockTabDelegate.swift @@ -68,6 +68,8 @@ final class MockTabDelegate: TabDelegate { func tabDidRequestAutofillLogins(tab: DuckDuckGo.TabViewController) {} + func tabDidRequestAIChat(tab: TabViewController) {} + func tabDidRequestSettings(tab: DuckDuckGo.TabViewController) {} func tab(_ tab: DuckDuckGo.TabViewController, didRequestSettingsToLogins account: BrowserServicesKit.SecureVaultModels.WebsiteAccount) {} diff --git a/LocalPackages/AIChat/Package.swift b/LocalPackages/AIChat/Package.swift new file mode 100644 index 0000000000..70d6f9499d --- /dev/null +++ b/LocalPackages/AIChat/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "AIChat", + platforms: [ + .iOS(.v15) + ], + products: [ + .library( + name: "AIChat", + targets: ["AIChat"]), + ], + targets: [ + .target( + name: "AIChat"), + ] +) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift new file mode 100644 index 0000000000..36c326dd8a --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -0,0 +1,81 @@ +// +// AIChatViewModel.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import WebKit +import Combine +import os.log + +protocol AIChatViewModeling { + /// The URL to be loaded in the AI Chat View Controller's web view. + var aiChatURL: URL { get } + + /// The configuration settings for the web view used in the AI Chat. + /// This configuration can include preferences such as data storage + var webViewConfiguration: WKWebViewConfiguration { get } + + /// A publisher that emits a signal after a 10-minute interval. + /// This is used to notify the controller that it should perform a reload or cleanup operation, + var cleanupPublisher: PassthroughSubject { get } + + /// Cancels the currently active cleanup timer. + func cancelTimer() + + /// Initiates the cleanup timer, which is set to trigger after a specified duration. + /// The purpose of this timer is to clear previous chat conversations + func startCleanupTimer() +} + + +final class AIChatViewModel: AIChatViewModeling { + private let settings: AIChatSettingsProvider + private var cleanupTimerCancellable: AnyCancellable? + + let webViewConfiguration: WKWebViewConfiguration + let cleanupPublisher = PassthroughSubject() + + let cleanupTime: TimeInterval + + init(webViewConfiguration: WKWebViewConfiguration, settings: AIChatSettingsProvider, cleanupTime: TimeInterval = 600) { + self.cleanupTime = cleanupTime + self.webViewConfiguration = webViewConfiguration + self.settings = settings + } + + func cancelTimer() { + Logger.aiChat.debug("Cancelling cleanup timer") + cleanupTimerCancellable?.cancel() + } + + func startCleanupTimer() { + cancelTimer() + + Logger.aiChat.debug("Starting cleanup timer") + + cleanupTimerCancellable = Just(()) + .delay(for: .seconds(cleanupTime), scheduler: RunLoop.main) + .sink { [weak self] in + Logger.aiChat.debug("Cleanup timer done") + self?.cleanupPublisher.send() + } + } + + var aiChatURL: URL { + settings.aiChatURL + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift new file mode 100644 index 0000000000..ec0d1d5d2d --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -0,0 +1,133 @@ +// +// AIChatWebViewController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import UIKit +import WebKit + +protocol AIChatWebViewControllerDelegate: AnyObject { + @MainActor func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) +} + +final class AIChatWebViewController: UIViewController { + weak var delegate: AIChatWebViewControllerDelegate? + private let chatModel: AIChatViewModeling + + private lazy var webView: WKWebView = { + let webView = WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) + webView.isOpaque = false /// Required to make the background color visible + webView.backgroundColor = .systemBackground + webView.navigationDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + return webView + }() + + private lazy var loadingView: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.color = .label + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + init(chatModel: AIChatViewModeling) { + self.chatModel = chatModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + setupWebView() + setupLoadingView() + loadWebsite() + } + + private func setupWebView() { + view.addSubview(webView) + + NSLayoutConstraint.activate([ + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } + + private func setupLoadingView() { + view.addSubview(loadingView) + + NSLayoutConstraint.activate([ + loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - WebView functions + +extension AIChatWebViewController { + + func reload() { + loadWebsite() + } + + private func loadWebsite() { + let request = URLRequest(url: chatModel.aiChatURL) + webView.load(request) + } +} + +// MARK: - WKNavigationDelegate + +extension AIChatWebViewController: WKNavigationDelegate { + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + if let url = navigationAction.request.url { + if url == chatModel.aiChatURL || navigationAction.targetFrame?.isMainFrame == false { + return .allow + } else { + delegate?.aiChatWebViewController(self, didRequestToLoad: url) + return .cancel + } + } else { + return .allow + } + } + + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + loadingView.startAnimating() + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + loadingView.stopAnimating() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + loadingView.stopAnimating() + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + loadingView.stopAnimating() + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift new file mode 100644 index 0000000000..f1981fd264 --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift @@ -0,0 +1,27 @@ +// +// AIChatPixelHandling.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +public enum AIChatPixel { + case openBefore10min + case openAfter10min +} + +public protocol AIChatPixelHandling { + func fire(pixel: AIChatPixel) +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift new file mode 100644 index 0000000000..afd76aaf9b --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift @@ -0,0 +1,37 @@ +// +// AIChatSettingsProvider.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public protocol AIChatSettingsProvider { + /// The URL used to open AI Chat in `AIChatViewController`. + var aiChatURL: URL { get } + + /// User settings state for AI Chat browsing menu icon + var isAIChatBrowsingMenuUserSettingsEnabled: Bool { get } + + /// Remote feature flag state for AI Chat + var isAIChatFeatureEnabled: Bool { get } + + /// Remote feature flag for AI Chat shortcut in browsing menu + var isAIChatBrowsingToolbarShortcutFeatureEnabled: Bool { get } + + /// Update user settings state for AI Chat browsing menu + func enableAIChatBrowsingMenuUserSettings(enable: Bool) +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift new file mode 100644 index 0000000000..436ec008c8 --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -0,0 +1,205 @@ +// +// AIChatViewController.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +import UIKit +import Combine +import WebKit + +/// A protocol that defines the delegate methods for `AIChatViewController`. +public protocol AIChatViewControllerDelegate: AnyObject { + /// Tells the delegate that a request to load a URL has been made. + /// + /// - Parameters: + /// - viewController: The `AIChatViewController` instance making the request. + /// - url: The `URL` that is requested to be loaded. + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) +} + +public final class AIChatViewController: UIViewController { + public weak var delegate: AIChatViewControllerDelegate? + private let chatModel: AIChatViewModeling + private var webViewController: AIChatWebViewController? + private var cleanupCancellable: AnyCancellable? + private var didCleanup: Bool = false + private let timerPixelHandler: TimerPixelHandler + + /// Initializes a new instance of `AIChatViewController` with the specified remote settings and web view configuration. + /// + /// - Parameters: + /// - remoteSettings: An object conforming to `AIChatSettingsProvider` that provides remote settings. + /// - webViewConfiguration: A `WKWebViewConfiguration` object used to configure the web view. + /// - pixelHandler: A `AIChatPixelHandling` object used to send pixel events. + public convenience init(settings: AIChatSettingsProvider, webViewConfiguration: WKWebViewConfiguration, pixelHandler: AIChatPixelHandling) { + let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, settings: settings) + self.init(chatModel: chatModel, pixelHandler: pixelHandler) + } + + internal init(chatModel: AIChatViewModeling, pixelHandler: AIChatPixelHandling) { + self.chatModel = chatModel + self.timerPixelHandler = TimerPixelHandler(pixelHandler: pixelHandler) + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Lifecycle +extension AIChatViewController { + + public override func viewDidLoad() { + super.viewDidLoad() + self.view.backgroundColor = .black + + setupNavigationBar() + + subscribeToCleanupPublisher() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + addWebViewController() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + timerPixelHandler.sendOpenPixel() + chatModel.cancelTimer() + } + + public override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + + if viewIfLoaded?.window == nil { + chatModel.cancelTimer() + removeWebViewController() + } + } +} + +// MARK: - Views Setup +extension AIChatViewController { + + private func setupNavigationBar() { + guard let navigationController = navigationController else { return } + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.shadowImage = UIImage() + appearance.shadowColor = .clear + + navigationController.navigationBar.standardAppearance = appearance + navigationController.navigationBar.scrollEdgeAppearance = appearance + navigationController.navigationBar.compactAppearance = appearance + navigationController.navigationBar.isTranslucent = true + + let imageView = UIImageView(image: UIImage(named: "Logo")) + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + let imageSize: CGFloat = 28 + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageSize), + imageView.heightAnchor.constraint(equalToConstant: imageSize) + ]) + + let titleLabel = UILabel() + titleLabel.text = UserText.aiChatTitle + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + titleLabel.textColor = .white + let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .fill + + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) + + let closeButton = UIBarButtonItem( + image: UIImage(named: "Close-24"), + style: .plain, + target: self, + action: #selector(closeAIChat) + ) + closeButton.accessibilityIdentifier = "aichat.close.button" + closeButton.tintColor = .white + + navigationItem.rightBarButtonItem = closeButton + } + + + private func addWebViewController() { + guard webViewController == nil else { return } + + let viewController = AIChatWebViewController(chatModel: chatModel) + viewController.delegate = self + webViewController = viewController + + addChild(viewController) + view.addSubview(viewController.view) + viewController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + viewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + viewController.view.backgroundColor = .black + viewController.view.layer.cornerRadius = 20 + viewController.view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + viewController.view.clipsToBounds = true + + viewController.didMove(toParent: self) + } + + private func removeWebViewController() { + webViewController?.removeFromParent() + webViewController?.view.removeFromSuperview() + webViewController = nil + } +} + +// MARK: - Event handling +extension AIChatViewController { + + private func subscribeToCleanupPublisher() { + cleanupCancellable = chatModel.cleanupPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.webViewController?.reload() + self?.timerPixelHandler.markCleanup() + } + } + + @objc private func closeAIChat() { + chatModel.startCleanupTimer() + dismiss(animated: true) + } +} + +extension AIChatViewController: AIChatWebViewControllerDelegate { + func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) { + delegate?.aiChatViewController(self, didRequestToLoad: url) + closeAIChat() + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift new file mode 100644 index 0000000000..d928d98e2e --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift @@ -0,0 +1,25 @@ +// +// Logger+AIChat.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import os.log + +public extension Logger { + static let aiChat = { Logger(subsystem: "AI Chat", category: "") }() +} diff --git a/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift b/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift new file mode 100644 index 0000000000..91f64f5cdd --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift @@ -0,0 +1,56 @@ +// +// TimerPixelHandler.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +final class TimerPixelHandler { + private let pixelHandler: AIChatPixelHandling + private var hasCleanedUp = false + private var isFirstPixelSent = true + + init(pixelHandler: any AIChatPixelHandling) { + self.pixelHandler = pixelHandler + } + + /// Marks that cleanup has been performed. + func markCleanup() { + hasCleanedUp = true + } + + /// Sends an "open" pixel based on the cleanup status. + /// - If this is the first time sending a pixel, it will not send any pixel. + /// - If cleanup has been called, it sends an `openAfter10min` pixel. + /// - Otherwise, it sends an `openBefore10min` pixel. + func sendOpenPixel() { + defer { + hasCleanedUp = false + } + + // Skip sending a pixel on the first call. + guard !isFirstPixelSent else { + isFirstPixelSent = false + return + } + + if hasCleanedUp { + pixelHandler.fire(pixel: .openAfter10min) + } else { + pixelHandler.fire(pixel: .openBefore10min) + } + } +} diff --git a/LocalPackages/AIChat/Sources/AIChat/UserText.swift b/LocalPackages/AIChat/Sources/AIChat/UserText.swift new file mode 100644 index 0000000000..d33e745d2d --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/UserText.swift @@ -0,0 +1,24 @@ +// +// UserText.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +public struct UserText { + public static let aiChatTitle = NSLocalizedString("aichat.title", value: "DuckDuckGo AI Chat", comment: "Title for DuckDuckGo AI Chat. Should not be translated") +}