From 8abb195c1f9a67f4b85e902d7ed55350073efcdf Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 27 Nov 2024 16:56:17 -0300 Subject: [PATCH 01/38] WIP: Ai chat browsing menu --- Core/FeatureFlag.swift | 6 ++ DuckDuckGo.xcodeproj/project.pbxproj | 27 +++++-- DuckDuckGo/AIChat/AIChatViewController.swift | 72 ++++++++++++++++++ .../AIChat/AIChatWebViewController.swift | 51 +++++++++++++ .../24px/AIChat-24.imageset/AIChat-24.pdf | Bin 0 -> 1590 bytes .../24px/AIChat-24.imageset/Contents.json | 12 +++ .../MenuAIChat.imageset/AIChat.pdf | Bin 0 -> 2549 bytes .../MenuAIChat.imageset/Contents.json | 12 +++ .../BrowsingMenuViewController.swift | 5 +- DuckDuckGo/MainViewController.swift | 19 ++++- DuckDuckGo/TabDelegate.swift | 2 + ...bViewControllerBrowsingMenuExtension.swift | 58 +++++++++++--- DuckDuckGo/UserText.swift | 8 +- DuckDuckGo/en.lproj/Localizable.strings | 11 ++- 14 files changed, 260 insertions(+), 23 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatViewController.swift create mode 100644 DuckDuckGo/AIChat/AIChatWebViewController.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 diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index da04bcfaf6..d374197c6b 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -56,6 +56,10 @@ public enum FeatureFlag: String { /// https://app.asana.com/0/1208592102886666/1208613627589762/f case crashReportOptInStatusResetting + + /// https://app.asana.com/0/1204167627774280/1208794395441049/f + case aiChatBrowsingToolbarShortcut + } extension FeatureFlag: FeatureFlagDescribing { @@ -123,6 +127,8 @@ extension FeatureFlag: FeatureFlagDescribing { return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: return .internalOnly + case .aiChatBrowsingToolbarShortcut: + return .internalOnly } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index fcb5896dd8..3c7b867460 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -140,6 +140,8 @@ 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 */; }; + 311C79E42CF790340021196A /* AIChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311C79E32CF7902F0021196A /* AIChatViewController.swift */; }; + 311C79E62CF790400021196A /* AIChatWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311C79E52CF790380021196A /* AIChatWebViewController.swift */; }; 312E5746283BB04A00C18FA0 /* AutofillEmptySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312E5745283BB04A00C18FA0 /* AutofillEmptySearchView.swift */; }; 3132FA2627A0784600DD7A12 /* FilePreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3132FA2527A0784600DD7A12 /* FilePreviewHelper.swift */; }; 3132FA2827A0788400DD7A12 /* PassKitPreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3132FA2727A0788400DD7A12 /* PassKitPreviewHelper.swift */; }; @@ -365,9 +367,9 @@ 6FEC0B852C999352006B4F6E /* FavoriteItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */; }; 6FEC0B882C999961006B4F6E /* FavoritesListInteractingAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */; }; 6FF915822B88E07A0042AC87 /* AdAttributionFetcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */; }; - 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */; }; 6FF9AD3F2CE63DD800C5A406 /* TabSwitcherOpenDailyPixel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */; }; 6FF9AD412CE6610F00C5A406 /* TabSwitcherDailyPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */; }; + 6FF9AD452CE766F700C5A406 /* NewTabPageControllerPixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */; }; 7B1604E82CB685B400A44EC6 /* Logger+TipKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */; }; 7B1604EC2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */; }; 7B1604EE2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */; }; @@ -470,7 +472,6 @@ 853A717820F645FB00FE60BC /* PixelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853A717720F645FB00FE60BC /* PixelTests.swift */; }; 853C5F6121C277C7001F7A05 /* global.swift in Sources */ = {isa = PBXBuildFile; fileRef = 853C5F6021C277C7001F7A05 /* global.swift */; }; 8540BBA22440857A00017FE4 /* FireproofingWorking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BBA12440857A00017FE4 /* FireproofingWorking.swift */; }; - 8540BD5223D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */; }; 8540BD5423D8D5080057FDD2 /* FireproofingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5323D8D5080057FDD2 /* FireproofingAlert.swift */; }; 8540BD5623D9E9C20057FDD2 /* FireproofingSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5523D9E9C20057FDD2 /* FireproofingSettingsViewController.swift */; }; 85449EF523FDA02800512AAF /* KeyboardSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85449EF423FDA02800512AAF /* KeyboardSettingsViewController.swift */; }; @@ -651,7 +652,7 @@ 98424AAD2CED4FF10071C7DB /* WKWebViewConfigurationExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F198D7971E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift */; }; 98424AAE2CED4FF10071C7DB /* WebCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 850559D123CF710C0055C0D5 /* WebCacheManagerTests.swift */; }; 98424AAF2CED4FF10071C7DB /* UserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 834DF990248FDDF60075EA48 /* UserAgentTests.swift */; }; - 98424AB02CED4FF10071C7DB /* PreserveLoginsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* PreserveLoginsTests.swift */; }; + 98424AB02CED4FF10071C7DB /* UserDefaultsFireproofingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */; }; 98424AB22CEDD6150071C7DB /* BrowserServicesKit in Frameworks */ = {isa = PBXBuildFile; productRef = 98424AB12CEDD6150071C7DB /* BrowserServicesKit */; }; 98424AB42CEDD61C0071C7DB /* BrowserServicesKitTestsUtils in Frameworks */ = {isa = PBXBuildFile; productRef = 98424AB32CEDD61C0071C7DB /* BrowserServicesKitTestsUtils */; }; 9847C00027A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9847BFFF27A2DDBB00DB07AA /* AppPrivacyConfigurationDataProvider.swift */; }; @@ -1487,6 +1488,8 @@ 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 = ""; }; + 311C79E32CF7902F0021196A /* AIChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewController.swift; sourceTree = ""; }; + 311C79E52CF790380021196A /* AIChatWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatWebViewController.swift; sourceTree = ""; }; 312E5745283BB04A00C18FA0 /* AutofillEmptySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillEmptySearchView.swift; sourceTree = ""; }; 3132FA2527A0784600DD7A12 /* FilePreviewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewHelper.swift; sourceTree = ""; }; 3132FA2727A0788400DD7A12 /* PassKitPreviewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassKitPreviewHelper.swift; sourceTree = ""; }; @@ -1698,9 +1701,9 @@ 6FEC0B842C999352006B4F6E /* FavoriteItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteItem.swift; sourceTree = ""; }; 6FEC0B872C999961006B4F6E /* FavoritesListInteractingAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesListInteractingAdapter.swift; sourceTree = ""; }; 6FF915802B88E0750042AC87 /* AdAttributionFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdAttributionFetcherTests.swift; sourceTree = ""; }; - 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerPixelTests.swift; sourceTree = ""; }; 6FF9AD3E2CE63DC200C5A406 /* TabSwitcherOpenDailyPixel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherOpenDailyPixel.swift; sourceTree = ""; }; 6FF9AD402CE6610600C5A406 /* TabSwitcherDailyPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabSwitcherDailyPixelTests.swift; sourceTree = ""; }; + 6FF9AD442CE766F700C5A406 /* NewTabPageControllerPixelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageControllerPixelTests.swift; sourceTree = ""; }; 7B1604E72CB685B400A44EC6 /* Logger+TipKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+TipKit.swift"; sourceTree = ""; }; 7B1604EB2CB68BDA00A44EC6 /* TipKitController+ConvenienceInitializers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TipKitController+ConvenienceInitializers.swift"; sourceTree = ""; }; 7B1604ED2CB68D2600A44EC6 /* TipKitDebugOptionsUIActionHandling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipKitDebugOptionsUIActionHandling.swift; sourceTree = ""; }; @@ -3564,6 +3567,15 @@ name = Downloads; sourceTree = ""; }; + 311C79E22CF790270021196A /* AIChat */ = { + isa = PBXGroup; + children = ( + 311C79E52CF790380021196A /* AIChatWebViewController.swift */, + 311C79E32CF7902F0021196A /* AIChatViewController.swift */, + ); + path = AIChat; + sourceTree = ""; + }; 3132FA2227A0776B00DD7A12 /* FilePreview */ = { isa = PBXGroup; children = ( @@ -4287,6 +4299,7 @@ 84E341941E2F7EFB00BDBA6F /* DuckDuckGo */ = { isa = PBXGroup; children = ( + 311C79E22CF790270021196A /* AIChat */, 6FD1BAE02B87A0E8000C475C /* AdAttribution */, AA4D6A8023DE4973007E8790 /* AppIcon */, F1C5ECF31E37812900C599A4 /* Application */, @@ -4785,7 +4798,7 @@ isa = PBXGroup; children = ( 834DF990248FDDF60075EA48 /* UserAgentTests.swift */, - 8540BD5123D8C2220057FDD2 /* PreserveLoginsTests.swift */, + 8540BD5123D8C2220057FDD2 /* UserDefaultsFireproofingTests.swift */, 850559D123CF710C0055C0D5 /* WebCacheManagerTests.swift */, 981C49AF2C8FA61D00DF11E8 /* DataStoreIdManagerTests.swift */, F198D7971E3A45D90088DA8A /* WKWebViewConfigurationExtensionTests.swift */, @@ -7562,6 +7575,7 @@ 3151F0F02735802800226F58 /* VoiceSearchViewController.swift in Sources */, D6FEB8B32B74990D00C3615F /* HeadlessWebViewNavCoordinator.swift in Sources */, 85BDC310243359040053DB07 /* FindInPageUserScript.swift in Sources */, + 311C79E62CF790400021196A /* AIChatWebViewController.swift in Sources */, F1DE78581E5CAE350058895A /* TabViewGridCell.swift in Sources */, F132D6A52C62239B00D85426 /* SubscriptionSettingsHeaderView.swift in Sources */, 984D035824ACCC6F0066CFB8 /* TabViewListCell.swift in Sources */, @@ -7904,6 +7918,7 @@ 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */, 312E5746283BB04A00C18FA0 /* AutofillEmptySearchView.swift in Sources */, 8565A34B1FC8D96B00239327 /* LaunchTabNotification.swift in Sources */, + 311C79E42CF790340021196A /* AIChatViewController.swift in Sources */, 311BD1AD2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift in Sources */, C1F341C52A6924000032057B /* EmailAddressPromptView.swift in Sources */, 316931D727BD10BB0095F5ED /* SaveToDownloadsAlert.swift in Sources */, @@ -8390,7 +8405,7 @@ 98424AAD2CED4FF10071C7DB /* WKWebViewConfigurationExtensionTests.swift in Sources */, 98424AAE2CED4FF10071C7DB /* WebCacheManagerTests.swift in Sources */, 98424AAF2CED4FF10071C7DB /* UserAgentTests.swift in Sources */, - 98424AB02CED4FF10071C7DB /* PreserveLoginsTests.swift in Sources */, + 98424AB02CED4FF10071C7DB /* UserDefaultsFireproofingTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift new file mode 100644 index 0000000000..a02ad8a0cf --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -0,0 +1,72 @@ +// +// 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 + +final class AIChatViewController: UIViewController { + + private let webViewController = AIChatWebViewController() + + override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + addWebViewController() + } + + private func setupNavigationBar() { + let imageView = UIImageView(image: UIImage(systemName: "globe")) + imageView.contentMode = .scaleAspectFit + + let titleLabel = UILabel() + titleLabel.text = UserText.aiChatTitle + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) + + let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .leading + stackView.distribution = .fill + + let leftBarButtonItem = UIBarButtonItem(customView: stackView) + navigationItem.leftBarButtonItem = leftBarButtonItem + + let closeButton = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(closeButtonTapped)) + navigationItem.rightBarButtonItem = closeButton + } + + private func addWebViewController() { + addChild(webViewController) + view.addSubview(webViewController.view) + webViewController.view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + webViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + webViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + webViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + webViewController.didMove(toParent: self) + } + + @objc private func closeButtonTapped() { + dismiss(animated: true, completion: nil) + } +} diff --git a/DuckDuckGo/AIChat/AIChatWebViewController.swift b/DuckDuckGo/AIChat/AIChatWebViewController.swift new file mode 100644 index 0000000000..d762b7e39d --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatWebViewController.swift @@ -0,0 +1,51 @@ +// +// 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 + +final class AIChatWebViewController: UIViewController { + private var webView = WKWebView() + + override func viewDidLoad() { + super.viewDidLoad() + + setupWebView() + loadWebsite() + } + + private func setupWebView() { + webView.translatesAutoresizingMaskIntoConstraints = false + 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 loadWebsite() { + if let url = URL(string: "https://duck.ai") { + let request = URLRequest(url: url) + webView.load(request) + } + } +} 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.swift b/DuckDuckGo/MainViewController.swift index 29ed297fec..371d68a9de 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -185,6 +185,8 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? + private var aiChatNavigationController: UINavigationController? + init( bookmarksDatabase: CoreDataDatabase, bookmarksDatabaseCleaner: BookmarkDatabaseCleaner, @@ -1683,7 +1685,11 @@ class MainViewController: UIViewController { Pixel.fire(pixel: pixel, withAdditionalParameters: pixelParameters, includedParameters: [.atb]) } - + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + self.aiChatNavigationController = nil + } } extension MainViewController: FindInPageDelegate { @@ -2342,6 +2348,17 @@ extension MainViewController: TabDelegate { segueToReportBrokenSite(entryPoint: .toggleReport(completionHandler: completionHandler)) } + func tabDidRequestAIChat(tab: TabViewController) { + if aiChatNavigationController == nil { + aiChatNavigationController = UINavigationController(rootViewController: AIChatViewController()) + } + + guard let aiChatNavigationController else { return } + + aiChatNavigationController.modalPresentationStyle = .fullScreen + tab.present(aiChatNavigationController, animated: true, completion: nil) + } + func tabDidRequestBookmarks(tab: TabViewController) { Pixel.fire(pixel: .bookmarksButtonPressed, withAdditionalParameters: [PixelParameters.originatedFromMenu: "1"]) 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..e88b5e1dec 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -28,25 +28,29 @@ import PrivacyDashboard extension TabViewController { - func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { + private var shouldShowAIChatInMenuHeader: Bool { + featureFlagger.isFeatureOn(.aiChatBrowsingToolbarShortcut) + } + private var shouldShowPrintButtonInBrowsingMenu: 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 +62,33 @@ 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 + 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] { @@ -84,6 +105,15 @@ extension TabViewController { entries.append(self.buildToggleProtectionEntry(forDomain: domain)) } + if shouldShowPrintButtonInBrowsingMenu { + entries.append(.regular(name: UserText.actionPrintSite, + accessibilityLabel: UserText.actionPrintSite, + image: UIImage(named: "Print-16")!, + action: { [weak self] in + self?.print() + })) + } + if link != nil { let name = UserText.actionReportBrokenSite entries.append(BrowsingMenuEntry.regular(name: name, @@ -440,6 +470,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 f68cd99670..5e0f799d1d 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 Site", 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") @@ -1309,6 +1312,9 @@ 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") + // MARK: - New Tab Page // MARK: Shortcuts diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index cbc4f3d533..6c5dd2abf7 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 Site"; + /* Refresh action - button shown in alert */ "action.title.refresh" = "Refresh"; @@ -136,6 +142,9 @@ /* No comment provided by engineer. */ "addWidget.title" = "One tap to your favorite sites."; +/* 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."; From da3b1d53c82ed84275f357efd44e027c2498cb9b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 27 Nov 2024 17:17:55 -0300 Subject: [PATCH 02/38] Improve navigation bar --- DuckDuckGo/AIChat/AIChatViewController.swift | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index a02ad8a0cf..39e7c43857 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -31,23 +31,35 @@ final class AIChatViewController: UIViewController { } private func setupNavigationBar() { - let imageView = UIImageView(image: UIImage(systemName: "globe")) + let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + let imageSize: CGFloat = 32 + 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) let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) stackView.axis = .horizontal stackView.spacing = 8 - stackView.alignment = .leading + stackView.alignment = .center stackView.distribution = .fill let leftBarButtonItem = UIBarButtonItem(customView: stackView) navigationItem.leftBarButtonItem = leftBarButtonItem - let closeButton = UIBarButtonItem(barButtonSystemItem: .close, target: self, action: #selector(closeButtonTapped)) + let closeButton = UIBarButtonItem( + image: UIImage(named: "Close-24")!, + style: .plain, + target: self, + action: #selector(closeButtonTapped) + ) + closeButton.tintColor = .label navigationItem.rightBarButtonItem = closeButton } From ca543de9c4e8de833040b59f628f6fc4a169b033 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 27 Nov 2024 17:22:05 -0300 Subject: [PATCH 03/38] Title font --- DuckDuckGo/AIChat/AIChatViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index 39e7c43857..3ec0554d8a 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -43,6 +43,7 @@ final class AIChatViewController: UIViewController { let titleLabel = UILabel() titleLabel.text = UserText.aiChatTitle + titleLabel.font = UIFont.semiBoldAppFont(ofSize: 17) let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) stackView.axis = .horizontal From c5846d029061bbb5c43076142a3db7f19fb40b41 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 27 Nov 2024 19:05:49 -0300 Subject: [PATCH 04/38] Don't save state --- DuckDuckGo/MainViewController.swift | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 371d68a9de..30b7acb883 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -185,8 +185,6 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? - private var aiChatNavigationController: UINavigationController? - init( bookmarksDatabase: CoreDataDatabase, bookmarksDatabaseCleaner: BookmarkDatabaseCleaner, @@ -1685,11 +1683,6 @@ class MainViewController: UIViewController { Pixel.fire(pixel: pixel, withAdditionalParameters: pixelParameters, includedParameters: [.atb]) } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - self.aiChatNavigationController = nil - } } extension MainViewController: FindInPageDelegate { @@ -2349,14 +2342,10 @@ extension MainViewController: TabDelegate { } func tabDidRequestAIChat(tab: TabViewController) { - if aiChatNavigationController == nil { - aiChatNavigationController = UINavigationController(rootViewController: AIChatViewController()) - } - - guard let aiChatNavigationController else { return } + let navigationController = UINavigationController(rootViewController: AIChatViewController()) - aiChatNavigationController.modalPresentationStyle = .fullScreen - tab.present(aiChatNavigationController, animated: true, completion: nil) + navigationController.modalPresentationStyle = .fullScreen + tab.present(navigationController, animated: true, completion: nil) } func tabDidRequestBookmarks(tab: TabViewController) { From 9987ae4d795e6536a39525ba93f849e2482f15fa Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 27 Nov 2024 19:12:02 -0300 Subject: [PATCH 05/38] Fix chat button dark mode --- .../DesignSystemIcons/24px/AIChat-24.imageset/Contents.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json index 54b549b11e..014a3657bf 100644 --- a/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/DesignSystemIcons/24px/AIChat-24.imageset/Contents.json @@ -8,5 +8,9 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" } } From 531f766be968542487e292c182210406fad22d61 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 11:52:00 -0300 Subject: [PATCH 06/38] WIP: Model --- DuckDuckGo.xcodeproj/project.pbxproj | 4 +++ DuckDuckGo/AIChat/AIChatModel.swift | 28 +++++++++++++++++++ DuckDuckGo/AIChat/AIChatViewController.swift | 11 +++++++- .../AIChat/AIChatWebViewController.swift | 15 +++++++++- 4 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatModel.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3c7b867460..414d1d0bc2 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -164,6 +164,7 @@ 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 */; }; + 316AA4582CF8AABA00A2ED28 /* AIChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */; }; 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; @@ -1512,6 +1513,7 @@ 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 = ""; }; + 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatModel.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 = ""; }; @@ -3570,6 +3572,7 @@ 311C79E22CF790270021196A /* AIChat */ = { isa = PBXGroup; children = ( + 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */, 311C79E52CF790380021196A /* AIChatWebViewController.swift */, 311C79E32CF7902F0021196A /* AIChatViewController.swift */, ); @@ -7789,6 +7792,7 @@ 981FED6E22025151008488D7 /* BlankSnapshotViewController.swift in Sources */, D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */, 851B128822200575004781BC /* Onboarding.swift in Sources */, + 316AA4582CF8AABA00A2ED28 /* AIChatModel.swift in Sources */, 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */, 859DB8142CE6263C001F7210 /* TextZoomController.swift in Sources */, F1EFB0062C5B8B8E009AB44B /* StatusIndicatorView.swift in Sources */, diff --git a/DuckDuckGo/AIChat/AIChatModel.swift b/DuckDuckGo/AIChat/AIChatModel.swift new file mode 100644 index 0000000000..16232cf1ee --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatModel.swift @@ -0,0 +1,28 @@ +// +// AIChatModel.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 + +final class AIChatModel { + let webViewConfiguration: WKWebViewConfiguration + + init(webViewConfiguration: WKWebViewConfiguration) { + self.webViewConfiguration = webViewConfiguration + } +} diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index 3ec0554d8a..9b262141cd 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -20,8 +20,17 @@ import UIKit final class AIChatViewController: UIViewController { + private let chatModel: AIChatModel + private lazy var webViewController = AIChatWebViewController(chatModel: chatModel) - private let webViewController = AIChatWebViewController() + init(chatModel: AIChatModel) { + 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() diff --git a/DuckDuckGo/AIChat/AIChatWebViewController.swift b/DuckDuckGo/AIChat/AIChatWebViewController.swift index d762b7e39d..459ea864af 100644 --- a/DuckDuckGo/AIChat/AIChatWebViewController.swift +++ b/DuckDuckGo/AIChat/AIChatWebViewController.swift @@ -21,7 +21,20 @@ import UIKit import WebKit final class AIChatWebViewController: UIViewController { - private var webView = WKWebView() + private let chatModel: AIChatModel + + private lazy var webView: WKWebView = { + return WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) + }() + + init(chatModel: AIChatModel) { + 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() From 4e07bebea68d4cd52306cf4d1f8af43159810563 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 12:22:06 -0300 Subject: [PATCH 07/38] WIP: Timer for reloading AI Chat --- DuckDuckGo/AIChat/AIChatModel.swift | 19 ++++++ DuckDuckGo/AIChat/AIChatViewController.swift | 61 ++++++++++++++++--- .../AIChat/AIChatWebViewController.swift | 4 ++ DuckDuckGo/MainViewController.swift | 11 ++-- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatModel.swift b/DuckDuckGo/AIChat/AIChatModel.swift index 16232cf1ee..6fd0dc00ec 100644 --- a/DuckDuckGo/AIChat/AIChatModel.swift +++ b/DuckDuckGo/AIChat/AIChatModel.swift @@ -18,11 +18,30 @@ // import WebKit +import Combine final class AIChatModel { + private var cleanupTimerCancellable: AnyCancellable? + let webViewConfiguration: WKWebViewConfiguration + let cleanupPublisher = PassthroughSubject() init(webViewConfiguration: WKWebViewConfiguration) { self.webViewConfiguration = webViewConfiguration } + + func cancelTimer() { + cleanupTimerCancellable?.cancel() + } + + func startCleanupTimer() { + print("Start timer") + cancelTimer() + + cleanupTimerCancellable = Just(()) + .delay(for: .seconds(5), scheduler: RunLoop.main) + .sink { [weak self] in + self?.cleanupPublisher.send() + } + } } diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index 9b262141cd..9f6e4780c6 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -18,10 +18,12 @@ // import UIKit +import Combine final class AIChatViewController: UIViewController { private let chatModel: AIChatModel - private lazy var webViewController = AIChatWebViewController(chatModel: chatModel) + private var webViewController: AIChatWebViewController? + private var cleanupCancellable: AnyCancellable? init(chatModel: AIChatModel) { self.chatModel = chatModel @@ -36,9 +38,19 @@ final class AIChatViewController: UIViewController { super.viewDidLoad() setupNavigationBar() + subscribeToCleanupPublisher() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) addWebViewController() } + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + chatModel.cancelTimer() + } + private func setupNavigationBar() { let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit @@ -72,23 +84,52 @@ final class AIChatViewController: UIViewController { closeButton.tintColor = .label navigationItem.rightBarButtonItem = closeButton } - private func addWebViewController() { - addChild(webViewController) - view.addSubview(webViewController.view) - webViewController.view.translatesAutoresizingMaskIntoConstraints = false + guard webViewController == nil else { + print("WebViewController already exists, returning") + return + } + + let newWebViewController = AIChatWebViewController(chatModel: chatModel) + webViewController = newWebViewController + + addChild(newWebViewController) + view.addSubview(newWebViewController.view) + newWebViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - webViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - webViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - webViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - webViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + newWebViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + newWebViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + newWebViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + newWebViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - webViewController.didMove(toParent: self) + newWebViewController.didMove(toParent: self) + } + + + private func removeWebViewController() { + webViewController?.removeFromParent() + webViewController?.view.removeFromSuperview() + webViewController = nil + } + + private func subscribeToCleanupPublisher() { + cleanupCancellable = chatModel.cleanupPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] in + self?.webViewController?.reload() + } } @objc private func closeButtonTapped() { + chatModel.startCleanupTimer() dismiss(animated: true, completion: nil) } + + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + chatModel.cancelTimer() + removeWebViewController() + } } diff --git a/DuckDuckGo/AIChat/AIChatWebViewController.swift b/DuckDuckGo/AIChat/AIChatWebViewController.swift index 459ea864af..168102d000 100644 --- a/DuckDuckGo/AIChat/AIChatWebViewController.swift +++ b/DuckDuckGo/AIChat/AIChatWebViewController.swift @@ -55,6 +55,10 @@ final class AIChatWebViewController: UIViewController { ]) } + func reload() { + loadWebsite() + } + private func loadWebsite() { if let url = URL(string: "https://duck.ai") { let request = URLRequest(url: url) diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 30b7acb883..2d4422cc15 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -185,6 +185,11 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? + private lazy var aiChatNavigationController: UINavigationController = { + let chatModel = AIChatModel(webViewConfiguration: WKWebViewConfiguration.persistent()) + return UINavigationController(rootViewController: AIChatViewController(chatModel: chatModel)) + }() + init( bookmarksDatabase: CoreDataDatabase, bookmarksDatabaseCleaner: BookmarkDatabaseCleaner, @@ -2342,10 +2347,8 @@ extension MainViewController: TabDelegate { } func tabDidRequestAIChat(tab: TabViewController) { - let navigationController = UINavigationController(rootViewController: AIChatViewController()) - - navigationController.modalPresentationStyle = .fullScreen - tab.present(navigationController, animated: true, completion: nil) + aiChatNavigationController.modalPresentationStyle = .fullScreen + tab.present(aiChatNavigationController, animated: true, completion: nil) } func tabDidRequestBookmarks(tab: TabViewController) { From 081ff8bd938e651be901825b2f1f410eead17ab3 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 12:23:56 -0300 Subject: [PATCH 08/38] Use 10min timer --- DuckDuckGo/AIChat/AIChatModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatModel.swift b/DuckDuckGo/AIChat/AIChatModel.swift index 6fd0dc00ec..54f7f0901c 100644 --- a/DuckDuckGo/AIChat/AIChatModel.swift +++ b/DuckDuckGo/AIChat/AIChatModel.swift @@ -34,14 +34,16 @@ final class AIChatModel { cleanupTimerCancellable?.cancel() } + /// Starts a 10-minute timer to trigger cleanup after AI Chat is closed. + /// Cancels any existing timer before starting a new one. func startCleanupTimer() { print("Start timer") cancelTimer() cleanupTimerCancellable = Just(()) - .delay(for: .seconds(5), scheduler: RunLoop.main) + .delay(for: .seconds(600), scheduler: RunLoop.main) .sink { [weak self] in self?.cleanupPublisher.send() } } -} + From 9889a79f8de9eae935438ec04cdfa1101a75b325 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 12:27:01 -0300 Subject: [PATCH 09/38] improve code --- DuckDuckGo/AIChat/AIChatModel.swift | 2 +- DuckDuckGo/AIChat/AIChatViewController.swift | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatModel.swift b/DuckDuckGo/AIChat/AIChatModel.swift index 54f7f0901c..589eddb97a 100644 --- a/DuckDuckGo/AIChat/AIChatModel.swift +++ b/DuckDuckGo/AIChat/AIChatModel.swift @@ -46,4 +46,4 @@ final class AIChatModel { self?.cleanupPublisher.send() } } - +} diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index 9f6e4780c6..ca7498df94 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -16,7 +16,6 @@ // See the License for the specific language governing permissions and // limitations under the License. // - import UIKit import Combine @@ -36,7 +35,6 @@ final class AIChatViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - setupNavigationBar() subscribeToCleanupPublisher() } @@ -64,7 +62,7 @@ final class AIChatViewController: UIViewController { let titleLabel = UILabel() titleLabel.text = UserText.aiChatTitle - titleLabel.font = UIFont.semiBoldAppFont(ofSize: 17) + titleLabel.font = .semiBoldAppFont(ofSize: 17) let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) stackView.axis = .horizontal @@ -72,11 +70,10 @@ final class AIChatViewController: UIViewController { stackView.alignment = .center stackView.distribution = .fill - let leftBarButtonItem = UIBarButtonItem(customView: stackView) - navigationItem.leftBarButtonItem = leftBarButtonItem + navigationItem.leftBarButtonItem = UIBarButtonItem(customView: stackView) let closeButton = UIBarButtonItem( - image: UIImage(named: "Close-24")!, + image: UIImage(named: "Close-24"), style: .plain, target: self, action: #selector(closeButtonTapped) @@ -84,6 +81,7 @@ final class AIChatViewController: UIViewController { closeButton.tintColor = .label navigationItem.rightBarButtonItem = closeButton } + private func addWebViewController() { guard webViewController == nil else { print("WebViewController already exists, returning") @@ -107,7 +105,6 @@ final class AIChatViewController: UIViewController { newWebViewController.didMove(toParent: self) } - private func removeWebViewController() { webViewController?.removeFromParent() webViewController?.view.removeFromSuperview() @@ -124,7 +121,7 @@ final class AIChatViewController: UIViewController { @objc private func closeButtonTapped() { chatModel.startCleanupTimer() - dismiss(animated: true, completion: nil) + dismiss(animated: true) } override func didReceiveMemoryWarning() { From 1bcbea05596a09dc176a5da035a4bd77a1a5f2ec Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 15:10:25 -0300 Subject: [PATCH 10/38] AI Chat modal --- DuckDuckGo.xcodeproj/project.pbxproj | 16 +++- .../AIChat/AIChatRemoteSettingsProvider.swift | 86 +++++++++++++++++++ DuckDuckGo/AIChat/AIChatViewController.swift | 28 ++++-- ...IChatModel.swift => AIChatViewModel.swift} | 16 +++- .../AIChat/AIChatWebViewController.swift | 15 ++-- DuckDuckGo/AIChat/Logger+AIChat.swift | 25 ++++++ DuckDuckGo/MainViewController.swift | 3 +- 7 files changed, 166 insertions(+), 23 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift rename DuckDuckGo/AIChat/{AIChatModel.swift => AIChatViewModel.swift} (73%) create mode 100644 DuckDuckGo/AIChat/Logger+AIChat.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 414d1d0bc2..91cfa8281c 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -164,7 +164,9 @@ 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 */; }; - 316AA4582CF8AABA00A2ED28 /* AIChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */; }; + 316AA4582CF8AABA00A2ED28 /* AIChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */; }; + 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */; }; + 316AA45C2CF8E82700A2ED28 /* Logger+AIChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */; }; 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; @@ -1513,7 +1515,9 @@ 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 = ""; }; - 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatModel.swift; sourceTree = ""; }; + 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModel.swift; sourceTree = ""; }; + 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettingsProvider.swift; sourceTree = ""; }; + 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AIChat.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 = ""; }; @@ -3572,7 +3576,9 @@ 311C79E22CF790270021196A /* AIChat */ = { isa = PBXGroup; children = ( - 316AA4572CF8AAA800A2ED28 /* AIChatModel.swift */, + 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */, + 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */, + 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */, 311C79E52CF790380021196A /* AIChatWebViewController.swift */, 311C79E32CF7902F0021196A /* AIChatViewController.swift */, ); @@ -7762,6 +7768,7 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, + 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, @@ -7792,7 +7799,7 @@ 981FED6E22025151008488D7 /* BlankSnapshotViewController.swift in Sources */, D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */, 851B128822200575004781BC /* Onboarding.swift in Sources */, - 316AA4582CF8AABA00A2ED28 /* AIChatModel.swift in Sources */, + 316AA4582CF8AABA00A2ED28 /* AIChatViewModel.swift in Sources */, 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */, 859DB8142CE6263C001F7210 /* TextZoomController.swift in Sources */, F1EFB0062C5B8B8E009AB44B /* StatusIndicatorView.swift in Sources */, @@ -7983,6 +7990,7 @@ F130D73A1E5776C500C45811 /* OmniBarDelegate.swift in Sources */, 85DFEDEF24C7EA3B00973FE7 /* SmallOmniBarState.swift in Sources */, 1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */, + 316AA45C2CF8E82700A2ED28 /* Logger+AIChat.swift in Sources */, 1D200C972BA3157A00108701 /* SettingsNextStepsView.swift in Sources */, 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */, 1E7A71192934EC6100B7EA19 /* OmniBarNotificationContainerView.swift in Sources */, diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift b/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift new file mode 100644 index 0000000000..f988b13bc7 --- /dev/null +++ b/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift @@ -0,0 +1,86 @@ +// +// AIChatRemoteSettingsProvider.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 Foundation + +protocol AIChatRemoteSettingsProvider { + var aiChatURL: URL { get } + var isAIChatEnabled: Bool { get } + var isBrowsingToolbarShortcutEnabled: Bool { get } + var isAddressBarShortcutEnabled: Bool { get } +} + +/// This struct serves as a wrapper for PrivacyConfigurationManaging, enabling the retrieval of data relevant to AIChat. +/// It also fire pixels when necessary data is missing. +struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { + enum SettingsValue: String { + case aiChatURL + + var defaultValue: String { + switch self { + case .aiChatURL: return "https://duck.ai" + } + } + } + + private let privacyConfigurationManager: PrivacyConfigurationManaging + private var settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + privacyConfigurationManager.privacyConfig.settings(for: .aiChat) + } + + init(privacyConfigurationManager: PrivacyConfigurationManaging) { + self.privacyConfigurationManager = privacyConfigurationManager + } + + // MARK: - Public + + var aiChatURL: URL { + guard let url = URL(string: getSettingsData(.aiChatURL)) else { + return URL(string: SettingsValue.aiChatURL.defaultValue)! + } + return url + } + + var isAIChatEnabled: Bool { + privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) + } + + // browsingToolbarShortcut + var isBrowsingToolbarShortcutEnabled: Bool { + privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.toolbarShortcut) + } + + // addressBarShortcut + var isAddressBarShortcutEnabled: 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 { + Logger.aiChat.debug("No remote settings found \(value.rawValue)") + // PixelKit.fire(GeneralPixel.aichatNoRemoteSettingsFound(value), includeAppVersionParameter: true) + return value.defaultValue + } + } +} diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index ca7498df94..c31b2dc3d3 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -20,11 +20,11 @@ import UIKit import Combine final class AIChatViewController: UIViewController { - private let chatModel: AIChatModel + private let chatModel: AIChatViewModel private var webViewController: AIChatWebViewController? private var cleanupCancellable: AnyCancellable? - init(chatModel: AIChatModel) { + init(chatModel: AIChatViewModel) { self.chatModel = chatModel super.init(nibName: nil, bundle: nil) } @@ -32,6 +32,10 @@ final class AIChatViewController: UIViewController { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} + +// MARK: - Lifecycle +extension AIChatViewController { override func viewDidLoad() { super.viewDidLoad() @@ -49,6 +53,16 @@ final class AIChatViewController: UIViewController { chatModel.cancelTimer() } + override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + chatModel.cancelTimer() + removeWebViewController() + } +} + +// MARK: - Views Setup +extension AIChatViewController { + private func setupNavigationBar() { let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit @@ -110,6 +124,10 @@ final class AIChatViewController: UIViewController { webViewController?.view.removeFromSuperview() webViewController = nil } +} + +// MARK: - Event handling +extension AIChatViewController { private func subscribeToCleanupPublisher() { cleanupCancellable = chatModel.cleanupPublisher @@ -123,10 +141,4 @@ final class AIChatViewController: UIViewController { chatModel.startCleanupTimer() dismiss(animated: true) } - - override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - chatModel.cancelTimer() - removeWebViewController() - } } diff --git a/DuckDuckGo/AIChat/AIChatModel.swift b/DuckDuckGo/AIChat/AIChatViewModel.swift similarity index 73% rename from DuckDuckGo/AIChat/AIChatModel.swift rename to DuckDuckGo/AIChat/AIChatViewModel.swift index 589eddb97a..a33eca596b 100644 --- a/DuckDuckGo/AIChat/AIChatModel.swift +++ b/DuckDuckGo/AIChat/AIChatViewModel.swift @@ -1,5 +1,5 @@ // -// AIChatModel.swift +// AIChatViewModel.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -20,30 +20,38 @@ import WebKit import Combine -final class AIChatModel { +final class AIChatViewModel { + private let remoteSettings: AIChatRemoteSettingsProvider private var cleanupTimerCancellable: AnyCancellable? let webViewConfiguration: WKWebViewConfiguration let cleanupPublisher = PassthroughSubject() - init(webViewConfiguration: WKWebViewConfiguration) { + init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider) { self.webViewConfiguration = webViewConfiguration + self.remoteSettings = remoteSettings } func cancelTimer() { + Logger.aiChat.debug("Cancelling cleanup timer") cleanupTimerCancellable?.cancel() } /// Starts a 10-minute timer to trigger cleanup after AI Chat is closed. /// Cancels any existing timer before starting a new one. func startCleanupTimer() { - print("Start timer") + Logger.aiChat.debug("Starting cleanup timer") cancelTimer() cleanupTimerCancellable = Just(()) .delay(for: .seconds(600), scheduler: RunLoop.main) .sink { [weak self] in + Logger.aiChat.debug("Cleanup timer done") self?.cleanupPublisher.send() } } + + var aiChatURL: URL { + remoteSettings.aiChatURL + } } diff --git a/DuckDuckGo/AIChat/AIChatWebViewController.swift b/DuckDuckGo/AIChat/AIChatWebViewController.swift index 168102d000..2683226657 100644 --- a/DuckDuckGo/AIChat/AIChatWebViewController.swift +++ b/DuckDuckGo/AIChat/AIChatWebViewController.swift @@ -21,13 +21,13 @@ import UIKit import WebKit final class AIChatWebViewController: UIViewController { - private let chatModel: AIChatModel + private let chatModel: AIChatViewModel private lazy var webView: WKWebView = { return WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) }() - init(chatModel: AIChatModel) { + init(chatModel: AIChatViewModel) { self.chatModel = chatModel super.init(nibName: nil, bundle: nil) } @@ -54,15 +54,18 @@ final class AIChatWebViewController: UIViewController { webView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) } +} + +// MARK: - WebView functions + +extension AIChatWebViewController { func reload() { loadWebsite() } private func loadWebsite() { - if let url = URL(string: "https://duck.ai") { - let request = URLRequest(url: url) - webView.load(request) - } + let request = URLRequest(url: chatModel.aiChatURL) + webView.load(request) } } diff --git a/DuckDuckGo/AIChat/Logger+AIChat.swift b/DuckDuckGo/AIChat/Logger+AIChat.swift new file mode 100644 index 0000000000..184452f869 --- /dev/null +++ b/DuckDuckGo/AIChat/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 var aiChat = { Logger(subsystem: "AI Chat", category: "") }() +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 2d4422cc15..44e67d072c 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -186,7 +186,8 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? private lazy var aiChatNavigationController: UINavigationController = { - let chatModel = AIChatModel(webViewConfiguration: WKWebViewConfiguration.persistent()) + let remoteSettings = AIChatRemoteSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager) + let chatModel = AIChatViewModel(webViewConfiguration: WKWebViewConfiguration.persistent(), remoteSettings: remoteSettings) return UINavigationController(rootViewController: AIChatViewController(chatModel: chatModel)) }() From 912150fd201727cd5cfe2815cd9dcbed8f7cc56e Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 15:45:51 -0300 Subject: [PATCH 11/38] Update default URL --- DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift b/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift index f988b13bc7..81207a4649 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift @@ -35,7 +35,7 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { var defaultValue: String { switch self { - case .aiChatURL: return "https://duck.ai" + case .aiChatURL: return "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=4" } } } From da51019ebf2d10879b89b9662a11ac2a366ac7fc Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 16:47:09 -0300 Subject: [PATCH 12/38] use correct feature flag --- Core/FeatureFlag.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index d374197c6b..5f7e84b9fe 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -128,7 +128,7 @@ extension FeatureFlag: FeatureFlagDescribing { case .crashReportOptInStatusResetting: return .internalOnly case .aiChatBrowsingToolbarShortcut: - return .internalOnly + return .remoteReleasable(.subfeature(AIChatSubfeature.browsingToolbarShortcut)) } } } From 5aca0bb89df8a591a97c7187591f6deab613ce49 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Thu, 28 Nov 2024 18:52:47 -0300 Subject: [PATCH 13/38] WIP: Handle external navigation --- DuckDuckGo/AIChat/AIChatViewController.swift | 37 +++++++++++++------ .../AIChat/AIChatWebViewController.swift | 29 ++++++++++++++- DuckDuckGo/MainViewController.swift | 10 ++++- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/DuckDuckGo/AIChat/AIChatViewController.swift index c31b2dc3d3..09e47f8e84 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/DuckDuckGo/AIChat/AIChatViewController.swift @@ -19,7 +19,12 @@ import UIKit import Combine +protocol AIChatViewControllerDelegate: AnyObject { + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) +} + final class AIChatViewController: UIViewController { + weak var delegate: AIChatViewControllerDelegate? private let chatModel: AIChatViewModel private var webViewController: AIChatWebViewController? private var cleanupCancellable: AnyCancellable? @@ -90,7 +95,7 @@ extension AIChatViewController { image: UIImage(named: "Close-24"), style: .plain, target: self, - action: #selector(closeButtonTapped) + action: #selector(closeAIChat) ) closeButton.tintColor = .label navigationItem.rightBarButtonItem = closeButton @@ -102,21 +107,22 @@ extension AIChatViewController { return } - let newWebViewController = AIChatWebViewController(chatModel: chatModel) - webViewController = newWebViewController + let viewController = AIChatWebViewController(chatModel: chatModel) + viewController.delegate = self + webViewController = viewController - addChild(newWebViewController) - view.addSubview(newWebViewController.view) - newWebViewController.view.translatesAutoresizingMaskIntoConstraints = false + addChild(viewController) + view.addSubview(viewController.view) + viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - newWebViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - newWebViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - newWebViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - newWebViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - newWebViewController.didMove(toParent: self) + viewController.didMove(toParent: self) } private func removeWebViewController() { @@ -137,8 +143,15 @@ extension AIChatViewController { } } - @objc private func closeButtonTapped() { + @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/DuckDuckGo/AIChat/AIChatWebViewController.swift b/DuckDuckGo/AIChat/AIChatWebViewController.swift index 2683226657..6969425129 100644 --- a/DuckDuckGo/AIChat/AIChatWebViewController.swift +++ b/DuckDuckGo/AIChat/AIChatWebViewController.swift @@ -20,11 +20,19 @@ import UIKit import WebKit +protocol AIChatWebViewControllerDelegate: AnyObject { + func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) +} + final class AIChatWebViewController: UIViewController { + weak var delegate: AIChatWebViewControllerDelegate? + private var didLoadAIChat = false private let chatModel: AIChatViewModel private lazy var webView: WKWebView = { - return WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) + let webView = WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) + webView.navigationDelegate = self // Set the navigation delegate + return webView }() init(chatModel: AIChatViewModel) { @@ -69,3 +77,22 @@ extension AIChatWebViewController { webView.load(request) } } + +// MARK: - WKNavigationDelegate + +extension AIChatWebViewController: WKNavigationDelegate { + + /// Allow loading only AI Chat-related requests; delegate others to the parent for handling. + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let url = navigationAction.request.url { + if url == chatModel.aiChatURL || navigationAction.targetFrame?.isMainFrame == false { + decisionHandler(.allow) + } else { + delegate?.aiChatWebViewController(self, didRequestToLoad: url) + decisionHandler(.cancel) + } + } else { + decisionHandler(.allow) + } + } +} diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 44e67d072c..591b433310 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -188,7 +188,9 @@ class MainViewController: UIViewController { private lazy var aiChatNavigationController: UINavigationController = { let remoteSettings = AIChatRemoteSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager) let chatModel = AIChatViewModel(webViewConfiguration: WKWebViewConfiguration.persistent(), remoteSettings: remoteSettings) - return UINavigationController(rootViewController: AIChatViewController(chatModel: chatModel)) + let aiChatViewController = AIChatViewController(chatModel: chatModel) + aiChatViewController.delegate = self + return UINavigationController(rootViewController: aiChatViewController) }() init( @@ -2936,3 +2938,9 @@ extension MainViewController: AutofillLoginSettingsListViewControllerDelegate { controller.dismiss(animated: true) } } + +extension MainViewController: AIChatViewControllerDelegate { + func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) { + handleRequestedURL(url) + } +} From ee03d8010513e26a2026c0e689f76fa2f6029ddf Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 15:10:53 -0300 Subject: [PATCH 14/38] Load chat delegate pages in new tab --- DuckDuckGo/MainViewController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 591b433310..98d2cb57bc 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -2939,8 +2939,9 @@ extension MainViewController: AutofillLoginSettingsListViewControllerDelegate { } } +// MARK: - AIChatViewControllerDelegate extension MainViewController: AIChatViewControllerDelegate { func aiChatViewController(_ viewController: AIChatViewController, didRequestToLoad url: URL) { - handleRequestedURL(url) + loadUrlInNewTab(url, inheritedAttribution: nil) } } From 6efd16281fc38224abe663ca8a3f5f34a7828ef3 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 16:17:11 -0300 Subject: [PATCH 15/38] Use local package for AI Chat --- DuckDuckGo.xcodeproj/project.pbxproj | 33 ++++++-------- ...vider.swift => AIChatRemoteSettings.swift} | 10 +---- DuckDuckGo/MainViewController.swift | 5 ++- LocalPackages/AIChat/Package.swift | 20 +++++++++ .../Sources}/AIChat/AIChatViewModel.swift | 13 ++++-- .../AIChat/AIChatWebViewController.swift | 17 ++++---- .../AIChatRemoteSettingsProvider.swift | 34 +++++++++++++++ .../Public API}/AIChatViewController.swift | 43 +++++++++++++------ .../AIChat/Public API}/Logger+AIChat.swift | 2 +- .../AIChat/Sources/AIChat/UserText.swift | 12 ++++++ 10 files changed, 133 insertions(+), 56 deletions(-) rename DuckDuckGo/AIChat/{AIChatRemoteSettingsProvider.swift => AIChatRemoteSettings.swift} (91%) create mode 100644 LocalPackages/AIChat/Package.swift rename {DuckDuckGo => LocalPackages/AIChat/Sources}/AIChat/AIChatViewModel.swift (84%) rename {DuckDuckGo => LocalPackages/AIChat/Sources}/AIChat/AIChatWebViewController.swift (81%) create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift rename {DuckDuckGo/AIChat => LocalPackages/AIChat/Sources/AIChat/Public API}/AIChatViewController.swift (73%) rename {DuckDuckGo/AIChat => LocalPackages/AIChat/Sources/AIChat/Public API}/Logger+AIChat.swift (92%) create mode 100644 LocalPackages/AIChat/Sources/AIChat/UserText.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 3e07ff1f32..c04acf3777 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -140,8 +140,6 @@ 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 */; }; - 311C79E42CF790340021196A /* AIChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311C79E32CF7902F0021196A /* AIChatViewController.swift */; }; - 311C79E62CF790400021196A /* AIChatWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 311C79E52CF790380021196A /* AIChatWebViewController.swift */; }; 312E5746283BB04A00C18FA0 /* AutofillEmptySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312E5745283BB04A00C18FA0 /* AutofillEmptySearchView.swift */; }; 3132FA2627A0784600DD7A12 /* FilePreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3132FA2527A0784600DD7A12 /* FilePreviewHelper.swift */; }; 3132FA2827A0788400DD7A12 /* PassKitPreviewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3132FA2727A0788400DD7A12 /* PassKitPreviewHelper.swift */; }; @@ -159,14 +157,13 @@ 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 */; }; - 316AA4582CF8AABA00A2ED28 /* AIChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */; }; - 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */; }; - 316AA45C2CF8E82700A2ED28 /* Logger+AIChat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */; }; + 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.swift */; }; 3170048227A9504F00C03F35 /* DownloadMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3170048127A9504F00C03F35 /* DownloadMocks.swift */; }; 317045C02858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317045BF2858C6B90016ED1F /* AutofillInterfaceEmailTruncatorTests.swift */; }; 317F5F982C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317F5F972C94A9EB0081666F /* MarketplaceAdPostbackStorage.swift */; }; @@ -1492,8 +1489,6 @@ 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 = ""; }; - 311C79E32CF7902F0021196A /* AIChatViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewController.swift; sourceTree = ""; }; - 311C79E52CF790380021196A /* AIChatWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatWebViewController.swift; sourceTree = ""; }; 312E5745283BB04A00C18FA0 /* AutofillEmptySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutofillEmptySearchView.swift; sourceTree = ""; }; 3132FA2527A0784600DD7A12 /* FilePreviewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewHelper.swift; sourceTree = ""; }; 3132FA2727A0788400DD7A12 /* PassKitPreviewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassKitPreviewHelper.swift; sourceTree = ""; }; @@ -1511,14 +1506,13 @@ 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 = ""; }; - 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatViewModel.swift; sourceTree = ""; }; - 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettingsProvider.swift; sourceTree = ""; }; - 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AIChat.swift"; sourceTree = ""; }; + 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettings.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 = ""; }; @@ -3114,6 +3108,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 315C77822CFA41A400699683 /* AIChat in Frameworks */, 9F8FE9492BAE50E50071E372 /* Lottie in Frameworks */, 853273B624FFE0BB00E3C778 /* WidgetKit.framework in Frameworks */, 0238E44F29C0FAA100615E30 /* FindInPageIOSJSSupport in Frameworks */, @@ -3578,11 +3573,7 @@ 311C79E22CF790270021196A /* AIChat */ = { isa = PBXGroup; children = ( - 316AA45B2CF8E82300A2ED28 /* Logger+AIChat.swift */, - 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift */, - 316AA4572CF8AAA800A2ED28 /* AIChatViewModel.swift */, - 311C79E52CF790380021196A /* AIChatWebViewController.swift */, - 311C79E32CF7902F0021196A /* AIChatViewController.swift */, + 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.swift */, ); path = AIChat; sourceTree = ""; @@ -3767,6 +3758,7 @@ 85875B5F29912A2D00115F05 /* SyncUI */, 37FCAACB2993149A000E420A /* Waitlist */, 31794BFF2821DFB600F18633 /* DuckUI */, + 315C77802CFA414400699683 /* AIChat */, ); path = LocalPackages; sourceTree = ""; @@ -6710,6 +6702,7 @@ 9F8FE9482BAE50E50071E372 /* Lottie */, 9F96F73A2C9144D5009E45D5 /* Onboarding */, 1E5918462CA422A7008ED2B3 /* Navigation */, + 315C77812CFA41A400699683 /* AIChat */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -7587,7 +7580,6 @@ 3151F0F02735802800226F58 /* VoiceSearchViewController.swift in Sources */, D6FEB8B32B74990D00C3615F /* HeadlessWebViewNavCoordinator.swift in Sources */, 85BDC310243359040053DB07 /* FindInPageUserScript.swift in Sources */, - 311C79E62CF790400021196A /* AIChatWebViewController.swift in Sources */, F1DE78581E5CAE350058895A /* TabViewGridCell.swift in Sources */, F132D6A52C62239B00D85426 /* SubscriptionSettingsHeaderView.swift in Sources */, 984D035824ACCC6F0066CFB8 /* TabViewListCell.swift in Sources */, @@ -7771,7 +7763,7 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, - 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettingsProvider.swift in Sources */, + 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettings.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, @@ -7802,7 +7794,6 @@ 981FED6E22025151008488D7 /* BlankSnapshotViewController.swift in Sources */, D66F683D2BB333C100AE93E2 /* SubscriptionContainerView.swift in Sources */, 851B128822200575004781BC /* Onboarding.swift in Sources */, - 316AA4582CF8AABA00A2ED28 /* AIChatViewModel.swift in Sources */, 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */, 859DB8142CE6263C001F7210 /* TextZoomController.swift in Sources */, F1EFB0062C5B8B8E009AB44B /* StatusIndicatorView.swift in Sources */, @@ -7932,7 +7923,6 @@ 4B274F602AFEAECC003F0745 /* NetworkProtectionWidgetRefreshModel.swift in Sources */, 312E5746283BB04A00C18FA0 /* AutofillEmptySearchView.swift in Sources */, 8565A34B1FC8D96B00239327 /* LaunchTabNotification.swift in Sources */, - 311C79E42CF790340021196A /* AIChatViewController.swift in Sources */, 311BD1AD2836BB3900AEF6C1 /* AutofillItemsEmptyView.swift in Sources */, C1F341C52A6924000032057B /* EmailAddressPromptView.swift in Sources */, 316931D727BD10BB0095F5ED /* SaveToDownloadsAlert.swift in Sources */, @@ -7993,7 +7983,6 @@ F130D73A1E5776C500C45811 /* OmniBarDelegate.swift in Sources */, 85DFEDEF24C7EA3B00973FE7 /* SmallOmniBarState.swift in Sources */, 1E908BF129827C480008C8F3 /* AutoconsentUserScript.swift in Sources */, - 316AA45C2CF8E82700A2ED28 /* Logger+AIChat.swift in Sources */, 1D200C972BA3157A00108701 /* SettingsNextStepsView.swift in Sources */, 4B0295192537BC6700E00CEF /* ConfigurationDebugViewController.swift in Sources */, 7B1C892C2CF714AA0008224E /* VPNTipsModel.swift in Sources */, @@ -11392,6 +11381,10 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Common; }; + 315C77812CFA41A400699683 /* AIChat */ = { + isa = XCSwiftPackageProductDependency; + productName = AIChat; + }; 31E69A62280F4CB600478327 /* DuckUI */ = { isa = XCSwiftPackageProductDependency; productName = DuckUI; diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift similarity index 91% rename from DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift rename to DuckDuckGo/AIChat/AIChatRemoteSettings.swift index 81207a4649..b9f22243ab 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettingsProvider.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -1,5 +1,5 @@ // -// AIChatRemoteSettingsProvider.swift +// AIChatRemoteSettings.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -18,15 +18,9 @@ // import BrowserServicesKit +import AIChat import Foundation -protocol AIChatRemoteSettingsProvider { - var aiChatURL: URL { get } - var isAIChatEnabled: Bool { get } - var isBrowsingToolbarShortcutEnabled: Bool { get } - var isAddressBarShortcutEnabled: Bool { get } -} - /// This struct serves as a wrapper for PrivacyConfigurationManaging, enabling the retrieval of data relevant to AIChat. /// It also fire pixels when necessary data is missing. struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 98d2cb57bc..4d826e703c 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 { @@ -187,8 +188,8 @@ class MainViewController: UIViewController { private lazy var aiChatNavigationController: UINavigationController = { let remoteSettings = AIChatRemoteSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager) - let chatModel = AIChatViewModel(webViewConfiguration: WKWebViewConfiguration.persistent(), remoteSettings: remoteSettings) - let aiChatViewController = AIChatViewController(chatModel: chatModel) + let aiChatViewController = AIChatViewController(remoteSettings: remoteSettings, + webViewConfiguration: WKWebViewConfiguration.persistent()) aiChatViewController.delegate = self return UINavigationController(rootViewController: aiChatViewController) }() 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/DuckDuckGo/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift similarity index 84% rename from DuckDuckGo/AIChat/AIChatViewModel.swift rename to LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index a33eca596b..c7b7023e85 100644 --- a/DuckDuckGo/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -19,8 +19,17 @@ import WebKit import Combine +import os.log -final class AIChatViewModel { +protocol AIChatViewModeling { + var aiChatURL: URL { get } + var webViewConfiguration: WKWebViewConfiguration { get } + var cleanupPublisher: PassthroughSubject { get } + func cancelTimer() + func startCleanupTimer() +} + +final class AIChatViewModel: AIChatViewModeling { private let remoteSettings: AIChatRemoteSettingsProvider private var cleanupTimerCancellable: AnyCancellable? @@ -37,8 +46,6 @@ final class AIChatViewModel { cleanupTimerCancellable?.cancel() } - /// Starts a 10-minute timer to trigger cleanup after AI Chat is closed. - /// Cancels any existing timer before starting a new one. func startCleanupTimer() { Logger.aiChat.debug("Starting cleanup timer") cancelTimer() diff --git a/DuckDuckGo/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift similarity index 81% rename from DuckDuckGo/AIChat/AIChatWebViewController.swift rename to LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift index 6969425129..ec1231bea3 100644 --- a/DuckDuckGo/AIChat/AIChatWebViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -21,21 +21,21 @@ import UIKit import WebKit protocol AIChatWebViewControllerDelegate: AnyObject { - func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) + @MainActor func aiChatWebViewController(_ viewController: AIChatWebViewController, didRequestToLoad url: URL) } final class AIChatWebViewController: UIViewController { weak var delegate: AIChatWebViewControllerDelegate? private var didLoadAIChat = false - private let chatModel: AIChatViewModel + private let chatModel: AIChatViewModeling private lazy var webView: WKWebView = { let webView = WKWebView(frame: .zero, configuration: chatModel.webViewConfiguration) - webView.navigationDelegate = self // Set the navigation delegate + webView.navigationDelegate = self return webView }() - init(chatModel: AIChatViewModel) { + init(chatModel: AIChatViewModeling) { self.chatModel = chatModel super.init(nibName: nil, bundle: nil) } @@ -82,17 +82,16 @@ extension AIChatWebViewController { extension AIChatWebViewController: WKNavigationDelegate { - /// Allow loading only AI Chat-related requests; delegate others to the parent for handling. - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { if let url = navigationAction.request.url { if url == chatModel.aiChatURL || navigationAction.targetFrame?.isMainFrame == false { - decisionHandler(.allow) + return .allow } else { delegate?.aiChatWebViewController(self, didRequestToLoad: url) - decisionHandler(.cancel) + return .cancel } } else { - decisionHandler(.allow) + return .allow } } } diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift new file mode 100644 index 0000000000..ae2effd4df --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift @@ -0,0 +1,34 @@ +// +// AIChatRemoteSettingsProvider.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 AIChatRemoteSettingsProvider { + /// The URL used to open AI Chat in `AIChatViewController`. + var aiChatURL: URL { get } + + /// Indicates if AI Chat parent feature is enabled. + var isAIChatEnabled: Bool { get } + + /// Indicates if the AI Chat shortcut sub-feature is enabled in the browsing toolbar. + var isBrowsingToolbarShortcutEnabled: Bool { get } + + /// Indicates if the AI Chat shortcut sub-feature is enabled in the address bar. + var isAddressBarShortcutEnabled: Bool { get } +} diff --git a/DuckDuckGo/AIChat/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift similarity index 73% rename from DuckDuckGo/AIChat/AIChatViewController.swift rename to LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 09e47f8e84..45d7169f19 100644 --- a/DuckDuckGo/AIChat/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -18,47 +18,64 @@ // import UIKit import Combine - -protocol AIChatViewControllerDelegate: AnyObject { +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) } -final class AIChatViewController: UIViewController { - weak var delegate: AIChatViewControllerDelegate? - private let chatModel: AIChatViewModel +public final class AIChatViewController: UIViewController { + public weak var delegate: AIChatViewControllerDelegate? + private let chatModel: AIChatViewModeling private var webViewController: AIChatWebViewController? private var cleanupCancellable: AnyCancellable? - init(chatModel: AIChatViewModel) { + /// Initializes a new instance of `AIChatViewController` with the specified remote settings and web view configuration. + /// + /// - Parameters: + /// - remoteSettings: An object conforming to `AIChatRemoteSettingsProvider` that provides remote settings. + /// - webViewConfiguration: A `WKWebViewConfiguration` object used to configure the web view. + public convenience init(remoteSettings: AIChatRemoteSettingsProvider, webViewConfiguration: WKWebViewConfiguration) { + let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, remoteSettings: remoteSettings) + self.init(chatModel: chatModel) + } + + internal init(chatModel: AIChatViewModeling) { self.chatModel = chatModel super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + fatalError("init(coder:) has not been implemented") + } } // MARK: - Lifecycle extension AIChatViewController { - override func viewDidLoad() { + public override func viewDidLoad() { super.viewDidLoad() setupNavigationBar() subscribeToCleanupPublisher() } - override func viewWillAppear(_ animated: Bool) { + public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) addWebViewController() } - override func viewDidAppear(_ animated: Bool) { + public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) chatModel.cancelTimer() } - override func didReceiveMemoryWarning() { + public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() chatModel.cancelTimer() removeWebViewController() @@ -81,7 +98,7 @@ extension AIChatViewController { let titleLabel = UILabel() titleLabel.text = UserText.aiChatTitle - titleLabel.font = .semiBoldAppFont(ofSize: 17) + titleLabel.font = UIFont.systemFont(ofSize: 17, weight: .semibold) let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel]) stackView.axis = .horizontal diff --git a/DuckDuckGo/AIChat/Logger+AIChat.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift similarity index 92% rename from DuckDuckGo/AIChat/Logger+AIChat.swift rename to LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift index 184452f869..d928d98e2e 100644 --- a/DuckDuckGo/AIChat/Logger+AIChat.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/Logger+AIChat.swift @@ -21,5 +21,5 @@ import Foundation import os.log public extension Logger { - static var aiChat = { Logger(subsystem: "AI Chat", category: "") }() + static let aiChat = { Logger(subsystem: "AI Chat", category: "") }() } diff --git a/LocalPackages/AIChat/Sources/AIChat/UserText.swift b/LocalPackages/AIChat/Sources/AIChat/UserText.swift new file mode 100644 index 0000000000..929bab893d --- /dev/null +++ b/LocalPackages/AIChat/Sources/AIChat/UserText.swift @@ -0,0 +1,12 @@ +// +// UserText.swift +// AIChat +// +// Created by Fernando Bunn on 11/29/24. +// + +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") +} From 926c4868f56743fd949fe23efdbae28cf2843076 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 16:21:52 -0300 Subject: [PATCH 16/38] Improve code comment --- .../AIChat/Sources/AIChat/AIChatViewModel.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index c7b7023e85..a7f09fe2c5 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -22,13 +22,26 @@ 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 remoteSettings: AIChatRemoteSettingsProvider private var cleanupTimerCancellable: AnyCancellable? From 16b78c68c97870a50ef13e7b5d309082da62f53c Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 16:27:17 -0300 Subject: [PATCH 17/38] improve cleanup timer --- .../AIChat/Sources/AIChat/AIChatViewModel.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index a7f09fe2c5..4d003227f2 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -49,7 +49,10 @@ final class AIChatViewModel: AIChatViewModeling { let webViewConfiguration: WKWebViewConfiguration let cleanupPublisher = PassthroughSubject() - init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider) { + let cleanupTime: TimeInterval + + init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 600) { + self.cleanupTime = cleanupTime self.webViewConfiguration = webViewConfiguration self.remoteSettings = remoteSettings } @@ -60,11 +63,12 @@ final class AIChatViewModel: AIChatViewModeling { } func startCleanupTimer() { - Logger.aiChat.debug("Starting cleanup timer") cancelTimer() + Logger.aiChat.debug("Starting cleanup timer") + cleanupTimerCancellable = Just(()) - .delay(for: .seconds(600), scheduler: RunLoop.main) + .delay(for: .seconds(cleanupTime), scheduler: RunLoop.main) .sink { [weak self] in Logger.aiChat.debug("Cleanup timer done") self?.cleanupPublisher.send() From 6afa76221e0544f0cdc7c4e31079763c29753ed9 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 17:48:17 -0300 Subject: [PATCH 18/38] Fire pixels --- Core/PixelEvent.swift | 16 +++++- DuckDuckGo.xcodeproj/project.pbxproj | 4 ++ DuckDuckGo/AIChat/AIChatPixelHandler.swift | 32 +++++++++++ DuckDuckGo/AIChat/AIChatRemoteSettings.swift | 18 +----- DuckDuckGo/MainViewController.swift | 3 +- ...bViewControllerBrowsingMenuExtension.swift | 2 + .../Sources/AIChat/AIChatViewModel.swift | 2 +- .../Public API/AIChatPixelHandling.swift | 27 +++++++++ .../AIChatRemoteSettingsProvider.swift | 9 --- .../Public API/AIChatViewController.swift | 19 ++++--- .../Sources/AIChat/TimerPixelHandler.swift | 56 +++++++++++++++++++ .../AIChat/Sources/AIChat/UserText.swift | 16 +++++- 12 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 DuckDuckGo/AIChat/AIChatPixelHandler.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/Public API/AIChatPixelHandling.swift create mode 100644 LocalPackages/AIChat/Sources/AIChat/TimerPixelHandler.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index d7433366d2..8a687aeb6f 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 @@ -897,6 +899,10 @@ extension Pixel { case appDidShowUITime(time: BucketAggregation) case appDidBecomeActiveTime(time: BucketAggregation) + // MARK: AI Chat + case openAIChatBefore10min + case openAIChatAfter10min + case aiChatNoRemoteSettingsFound(settings: String) } } @@ -958,6 +964,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" @@ -967,6 +974,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" @@ -1788,6 +1797,11 @@ 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())" } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index c04acf3777..3476220de0 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 */; }; @@ -1477,6 +1478,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 = ""; }; @@ -3573,6 +3575,7 @@ 311C79E22CF790270021196A /* AIChat */ = { isa = PBXGroup; children = ( + 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */, 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.swift */, ); path = AIChat; @@ -7943,6 +7946,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 */, 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/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift index b9f22243ab..b069e729a2 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatRemoteSettings.swift @@ -20,6 +20,7 @@ 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. @@ -52,28 +53,13 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { return url } - var isAIChatEnabled: Bool { - privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat) - } - - // browsingToolbarShortcut - var isBrowsingToolbarShortcutEnabled: Bool { - privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.toolbarShortcut) - } - - // addressBarShortcut - var isAddressBarShortcutEnabled: 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 { - Logger.aiChat.debug("No remote settings found \(value.rawValue)") - // PixelKit.fire(GeneralPixel.aichatNoRemoteSettingsFound(value), includeAppVersionParameter: true) + Pixel.fire(pixel: .aiChatNoRemoteSettingsFound(settings: value.rawValue)) return value.defaultValue } } diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index 4d826e703c..e66413a371 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -189,7 +189,8 @@ class MainViewController: UIViewController { private lazy var aiChatNavigationController: UINavigationController = { let remoteSettings = AIChatRemoteSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager) let aiChatViewController = AIChatViewController(remoteSettings: remoteSettings, - webViewConfiguration: WKWebViewConfiguration.persistent()) + webViewConfiguration: WKWebViewConfiguration.persistent(), + pixelHandler: AIChatPixelHandler()) aiChatViewController.delegate = self return UINavigationController(rootViewController: aiChatViewController) }() diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index e88b5e1dec..986a78a2b3 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -70,6 +70,7 @@ extension TabViewController { }) let chatEntry = BrowsingMenuEntry.regular(name: UserText.actionOpenAIChat, image: UIImage(named: "AIChat-24")!, action: { [weak self] in + Pixel.fire(pixel: .browsingMenuAIChat) self?.openAIChat() }) @@ -110,6 +111,7 @@ extension TabViewController { accessibilityLabel: UserText.actionPrintSite, image: UIImage(named: "Print-16")!, action: { [weak self] in + Pixel.fire(pixel: .browsingMenuListPrint) self?.print() })) } diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index 4d003227f2..8c363fc97d 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -51,7 +51,7 @@ final class AIChatViewModel: AIChatViewModeling { let cleanupTime: TimeInterval - init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 600) { + init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 5) { self.cleanupTime = cleanupTime self.webViewConfiguration = webViewConfiguration self.remoteSettings = remoteSettings 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/AIChatRemoteSettingsProvider.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift index ae2effd4df..7182584e28 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift @@ -22,13 +22,4 @@ import Foundation public protocol AIChatRemoteSettingsProvider { /// The URL used to open AI Chat in `AIChatViewController`. var aiChatURL: URL { get } - - /// Indicates if AI Chat parent feature is enabled. - var isAIChatEnabled: Bool { get } - - /// Indicates if the AI Chat shortcut sub-feature is enabled in the browsing toolbar. - var isBrowsingToolbarShortcutEnabled: Bool { get } - - /// Indicates if the AI Chat shortcut sub-feature is enabled in the address bar. - var isAddressBarShortcutEnabled: Bool { get } } diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 45d7169f19..4ca404f857 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -35,19 +35,24 @@ public final class AIChatViewController: UIViewController { 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 `AIChatRemoteSettingsProvider` that provides remote settings. /// - webViewConfiguration: A `WKWebViewConfiguration` object used to configure the web view. - public convenience init(remoteSettings: AIChatRemoteSettingsProvider, webViewConfiguration: WKWebViewConfiguration) { + /// - pixelHandler: A `AIChatPixelHandling` object used to send pixel events. + public convenience init(remoteSettings: AIChatRemoteSettingsProvider, webViewConfiguration: WKWebViewConfiguration, pixelHandler: AIChatPixelHandling) { let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, remoteSettings: remoteSettings) - self.init(chatModel: chatModel) + self.init(chatModel: chatModel, pixelHandler: pixelHandler) } - internal init(chatModel: AIChatViewModeling) { + internal init(chatModel: AIChatViewModeling, pixelHandler: AIChatPixelHandling) { self.chatModel = chatModel + self.timerPixelHandler = TimerPixelHandler(pixelHandler: pixelHandler) + super.init(nibName: nil, bundle: nil) } @@ -72,6 +77,7 @@ extension AIChatViewController { public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + timerPixelHandler.sendOpenPixel() chatModel.cancelTimer() } @@ -119,10 +125,7 @@ extension AIChatViewController { } private func addWebViewController() { - guard webViewController == nil else { - print("WebViewController already exists, returning") - return - } + guard webViewController == nil else { return } let viewController = AIChatWebViewController(chatModel: chatModel) viewController.delegate = self @@ -157,6 +160,7 @@ extension AIChatViewController { .receive(on: DispatchQueue.main) .sink { [weak self] in self?.webViewController?.reload() + self?.timerPixelHandler.markCleanup() } } @@ -172,3 +176,4 @@ extension AIChatViewController: AIChatWebViewControllerDelegate { closeAIChat() } } + 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 index 929bab893d..d33e745d2d 100644 --- a/LocalPackages/AIChat/Sources/AIChat/UserText.swift +++ b/LocalPackages/AIChat/Sources/AIChat/UserText.swift @@ -1,8 +1,20 @@ // // UserText.swift -// AIChat +// DuckDuckGo // -// Created by Fernando Bunn on 11/29/24. +// 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 From 26c83d7d265903ddcafd025e49b3fbcfea620887 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 17:53:20 -0300 Subject: [PATCH 19/38] Fix feature flag --- Core/FeatureFlag.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 188c2f0b0b..61b7f2b14f 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -136,10 +136,9 @@ extension FeatureFlag: FeatureFlagDescribing { case .adAttributionReporting: return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: - return .internalOnly + return .internalOnly() case .aiChatBrowsingToolbarShortcut: return .remoteReleasable(.subfeature(AIChatSubfeature.browsingToolbarShortcut)) - return .internalOnly() case .isPrivacyProLaunchedROW: return .remoteReleasable(.subfeature(PrivacyProSubfeature.isLaunchedROW)) case .isPrivacyProLaunchedROWOverride: From d616bf9e2216d0b016a3c0c237e49927da52421d Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 17:55:19 -0300 Subject: [PATCH 20/38] change timer to 10min --- LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index 8c363fc97d..4d003227f2 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -51,7 +51,7 @@ final class AIChatViewModel: AIChatViewModeling { let cleanupTime: TimeInterval - init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 5) { + init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 600) { self.cleanupTime = cleanupTime self.webViewConfiguration = webViewConfiguration self.remoteSettings = remoteSettings From 06babe5438a9dbcd9e4eaac2cd8b21dd7f244cca Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 17:57:12 -0300 Subject: [PATCH 21/38] linter --- .../AIChat/Sources/AIChat/Public API/AIChatViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 4ca404f857..01603f0565 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -176,4 +176,3 @@ extension AIChatViewController: AIChatWebViewControllerDelegate { closeAIChat() } } - From 033fa694328f281ba6f5da59f73a9b702af06c26 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 29 Nov 2024 18:09:54 -0300 Subject: [PATCH 22/38] Fix test --- DuckDuckGoTests/MockTabDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/DuckDuckGoTests/MockTabDelegate.swift b/DuckDuckGoTests/MockTabDelegate.swift index acb7db0e53..63928aec8e 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) {} From 0ae71bb8a4cdc5920d6354bd932388063c475a07 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Sat, 30 Nov 2024 20:49:59 -0300 Subject: [PATCH 23/38] Fix memory warning issue --- .../Sources/AIChat/Public API/AIChatViewController.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 01603f0565..1346ef2a91 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -83,8 +83,11 @@ extension AIChatViewController { public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() - chatModel.cancelTimer() - removeWebViewController() + + if viewIfLoaded?.window == nil { + chatModel.cancelTimer() + removeWebViewController() + } } } From 657f60d5c052b0589caf5e1e37d29fe820e42e52 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 09:50:37 -0300 Subject: [PATCH 24/38] WIP: Remove bar background --- .../Public API/AIChatViewController.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 1346ef2a91..4733e138dc 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -66,6 +66,7 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() + self.view.backgroundColor = .systemBackground setupNavigationBar() subscribeToCleanupPublisher() } @@ -95,11 +96,26 @@ extension AIChatViewController { extension AIChatViewController { private func setupNavigationBar() { + guard let navigationController = navigationController else { return } + + // Make the navigation bar transparent + 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 + + // Set up the custom title view let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false - let imageSize: CGFloat = 32 + let imageSize: CGFloat = 28 NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: imageSize), imageView.heightAnchor.constraint(equalToConstant: imageSize) @@ -127,6 +143,7 @@ extension AIChatViewController { navigationItem.rightBarButtonItem = closeButton } + private func addWebViewController() { guard webViewController == nil else { return } @@ -139,7 +156,7 @@ extension AIChatViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + 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) From e56e3b69e35df42e4713c2947906a0e1f903e7f5 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 12:05:56 -0300 Subject: [PATCH 25/38] Revert "WIP: Remove bar background" This reverts commit 657f60d5c052b0589caf5e1e37d29fe820e42e52. --- .../Public API/AIChatViewController.swift | 21 ++----------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 4733e138dc..1346ef2a91 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -66,7 +66,6 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .systemBackground setupNavigationBar() subscribeToCleanupPublisher() } @@ -96,26 +95,11 @@ extension AIChatViewController { extension AIChatViewController { private func setupNavigationBar() { - guard let navigationController = navigationController else { return } - - // Make the navigation bar transparent - 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 - - // Set up the custom title view let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false - let imageSize: CGFloat = 28 + let imageSize: CGFloat = 32 NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: imageSize), imageView.heightAnchor.constraint(equalToConstant: imageSize) @@ -143,7 +127,6 @@ extension AIChatViewController { navigationItem.rightBarButtonItem = closeButton } - private func addWebViewController() { guard webViewController == nil else { return } @@ -156,7 +139,7 @@ extension AIChatViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + viewController.view.topAnchor.constraint(equalTo: view.topAnchor), viewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), viewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), viewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) From 251af3c294b86a6dde6245652a5b79be9161d7f1 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 13:45:24 -0300 Subject: [PATCH 26/38] Reapply "WIP: Remove bar background" This reverts commit e56e3b69e35df42e4713c2947906a0e1f903e7f5. --- .../Public API/AIChatViewController.swift | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 1346ef2a91..4733e138dc 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -66,6 +66,7 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() + self.view.backgroundColor = .systemBackground setupNavigationBar() subscribeToCleanupPublisher() } @@ -95,11 +96,26 @@ extension AIChatViewController { extension AIChatViewController { private func setupNavigationBar() { + guard let navigationController = navigationController else { return } + + // Make the navigation bar transparent + 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 + + // Set up the custom title view let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false - let imageSize: CGFloat = 32 + let imageSize: CGFloat = 28 NSLayoutConstraint.activate([ imageView.widthAnchor.constraint(equalToConstant: imageSize), imageView.heightAnchor.constraint(equalToConstant: imageSize) @@ -127,6 +143,7 @@ extension AIChatViewController { navigationItem.rightBarButtonItem = closeButton } + private func addWebViewController() { guard webViewController == nil else { return } @@ -139,7 +156,7 @@ extension AIChatViewController { viewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - viewController.view.topAnchor.constraint(equalTo: view.topAnchor), + 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) From 1ac4b3dd813b987e93578ca712471fe0e9c3c59b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 14:19:33 -0300 Subject: [PATCH 27/38] Use rounded corner webview --- .../Sources/AIChat/AIChatWebViewController.swift | 2 ++ .../AIChat/Public API/AIChatViewController.swift | 16 +++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift index ec1231bea3..3832d8935c 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -47,6 +47,8 @@ final class AIChatWebViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + view.backgroundColor = .black + setupWebView() loadWebsite() } diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 4733e138dc..6210744202 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -66,8 +66,10 @@ extension AIChatViewController { public override func viewDidLoad() { super.viewDidLoad() - self.view.backgroundColor = .systemBackground + self.view.backgroundColor = .black + setupNavigationBar() + subscribeToCleanupPublisher() } @@ -98,7 +100,6 @@ extension AIChatViewController { private func setupNavigationBar() { guard let navigationController = navigationController else { return } - // Make the navigation bar transparent let appearance = UINavigationBarAppearance() appearance.configureWithTransparentBackground() appearance.backgroundColor = .clear @@ -110,7 +111,6 @@ extension AIChatViewController { navigationController.navigationBar.compactAppearance = appearance navigationController.navigationBar.isTranslucent = true - // Set up the custom title view let imageView = UIImageView(image: UIImage(named: "Logo")) imageView.contentMode = .scaleAspectFit imageView.translatesAutoresizingMaskIntoConstraints = false @@ -124,7 +124,7 @@ extension AIChatViewController { 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 @@ -139,7 +139,8 @@ extension AIChatViewController { target: self, action: #selector(closeAIChat) ) - closeButton.tintColor = .label + closeButton.tintColor = .white + navigationItem.rightBarButtonItem = closeButton } @@ -162,6 +163,11 @@ extension AIChatViewController { 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) } From d463dc79a21832be2826173a96cc23b6bd9d9da6 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 14:30:13 -0300 Subject: [PATCH 28/38] Add loading view --- .../AIChat/AIChatWebViewController.swift | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift index 3832d8935c..dfbb119340 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -16,7 +16,6 @@ // See the License for the specific language governing permissions and // limitations under the License. // - import UIKit import WebKit @@ -26,7 +25,6 @@ protocol AIChatWebViewControllerDelegate: AnyObject { final class AIChatWebViewController: UIViewController { weak var delegate: AIChatWebViewControllerDelegate? - private var didLoadAIChat = false private let chatModel: AIChatViewModeling private lazy var webView: WKWebView = { @@ -35,6 +33,14 @@ final class AIChatWebViewController: UIViewController { 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) @@ -50,6 +56,7 @@ final class AIChatWebViewController: UIViewController { view.backgroundColor = .black setupWebView() + setupLoadingView() loadWebsite() } @@ -64,6 +71,15 @@ final class AIChatWebViewController: UIViewController { 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 @@ -96,4 +112,20 @@ extension AIChatWebViewController: WKNavigationDelegate { 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() + } } From 104e738adcba0df63eb679068590e27c12079e92 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 14:37:33 -0300 Subject: [PATCH 29/38] Webview color --- .../AIChat/Sources/AIChat/AIChatWebViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift index dfbb119340..ec0d1d5d2d 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatWebViewController.swift @@ -29,7 +29,10 @@ final class AIChatWebViewController: UIViewController { 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 }() @@ -61,7 +64,6 @@ final class AIChatWebViewController: UIViewController { } private func setupWebView() { - webView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(webView) NSLayoutConstraint.activate([ From f394ff5c5f1d5be7e97c3086fc0f7ee5ea47b729 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Mon, 2 Dec 2024 15:57:05 -0300 Subject: [PATCH 30/38] Enabled by default on internal users --- DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index 986a78a2b3..3981ad71d3 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -29,7 +29,7 @@ import PrivacyDashboard extension TabViewController { private var shouldShowAIChatInMenuHeader: Bool { - featureFlagger.isFeatureOn(.aiChatBrowsingToolbarShortcut) + featureFlagger.isFeatureOn(.aiChatBrowsingToolbarShortcut) || AppDependencyProvider.shared.internalUserDecider.isInternalUser } private var shouldShowPrintButtonInBrowsingMenu: Bool { shouldShowAIChatInMenuHeader } From 2f55110158d24143fa390d4e565b7348bb3728de Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 3 Dec 2024 19:44:24 -0300 Subject: [PATCH 31/38] Add AI Chat settings (#3665) Task/Issue URL: https://app.asana.com/0/1204167627774280/1208896297203534/f **Description**: Add AI Chat settings UI to enable/disable the feature --- Core/FeatureFlag.swift | 5 -- DuckDuckGo.xcodeproj/project.pbxproj | 12 ++-- ...oteSettings.swift => AIChatSettings.swift} | 52 +++++++++++++++-- DuckDuckGo/MainViewController+Segues.swift | 6 +- DuckDuckGo/MainViewController.swift | 5 +- .../SettingsAIChat.imageset/Contents.json | 12 ++++ .../SettingsAIChat.pdf | Bin 0 -> 2766 bytes DuckDuckGo/SettingsAIChatView.swift | 54 ++++++++++++++++++ DuckDuckGo/SettingsMainSettingsView.swift | 10 +++- DuckDuckGo/SettingsRootView.swift | 2 + DuckDuckGo/SettingsState.swift | 8 ++- DuckDuckGo/SettingsViewModel.swift | 24 ++++++-- ...bViewControllerBrowsingMenuExtension.swift | 13 +++-- DuckDuckGo/UserText.swift | 7 +++ .../Sources/AIChat/AIChatViewModel.swift | 8 +-- ...der.swift => AIChatSettingsProvider.swift} | 16 +++++- .../Public API/AIChatViewController.swift | 6 +- 17 files changed, 202 insertions(+), 38 deletions(-) rename DuckDuckGo/AIChat/{AIChatRemoteSettings.swift => AIChatSettings.swift} (50%) 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/SettingsAIChatView.swift rename LocalPackages/AIChat/Sources/AIChat/Public API/{AIChatRemoteSettingsProvider.swift => AIChatSettingsProvider.swift} (58%) diff --git a/Core/FeatureFlag.swift b/Core/FeatureFlag.swift index 9aa0fa9757..8bf183dafd 100644 --- a/Core/FeatureFlag.swift +++ b/Core/FeatureFlag.swift @@ -57,9 +57,6 @@ public enum FeatureFlag: String { /// https://app.asana.com/0/1208592102886666/1208613627589762/f case crashReportOptInStatusResetting - /// https://app.asana.com/0/1204167627774280/1208794395441049/f - case aiChatBrowsingToolbarShortcut - case isPrivacyProLaunchedROW case isPrivacyProLaunchedROWOverride @@ -140,8 +137,6 @@ extension FeatureFlag: FeatureFlagDescribing { return .remoteReleasable(.feature(.adAttributionReporting)) case .crashReportOptInStatusResetting: return .internalOnly() - case .aiChatBrowsingToolbarShortcut: - return .remoteReleasable(.subfeature(AIChatSubfeature.browsingToolbarShortcut)) case .isPrivacyProLaunchedROW: return .remoteReleasable(.subfeature(PrivacyProSubfeature.isLaunchedROW)) case .isPrivacyProLaunchedROWOverride: diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 90107bf941..229a66f4e7 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -164,9 +164,10 @@ 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 /* AIChatRemoteSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.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 */; }; @@ -1527,10 +1528,11 @@ 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 /* AIChatRemoteSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIChatRemoteSettings.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 = ""; }; @@ -3348,6 +3350,7 @@ 1DEAADEB2BA45B4400E25A97 /* SettingsAccessibilityView.swift */, 1DEAADED2BA45DFE00E25A97 /* SettingsDataClearingView.swift */, D65625A02C232F5E006EF297 /* SettingsDuckPlayerView.swift */, + 317CA3422CFF82DB00F88848 /* SettingsAIChatView.swift */, ); name = MainSettings; sourceTree = ""; @@ -3599,7 +3602,7 @@ isa = PBXGroup; children = ( 31043B152CFA5B890028A97F /* AIChatPixelHandler.swift */, - 316AA4592CF8E31F00A2ED28 /* AIChatRemoteSettings.swift */, + 316AA4592CF8E31F00A2ED28 /* AIChatSettings.swift */, ); path = AIChat; sourceTree = ""; @@ -7820,7 +7823,7 @@ D668D92B2B696840008E2FF2 /* IdentityTheftRestorationPagesFeature.swift in Sources */, 85F2FFCF2211F8E5006BB258 /* TabSwitcherViewController+KeyCommands.swift in Sources */, 3157B43327F497E90042D3D7 /* SaveLoginView.swift in Sources */, - 316AA45A2CF8E31F00A2ED28 /* AIChatRemoteSettings.swift in Sources */, + 316AA45A2CF8E31F00A2ED28 /* AIChatSettings.swift in Sources */, F17922E01E71BB59006E3D97 /* AutocompleteViewControllerDelegate.swift in Sources */, BDE91CDC2C62AA3A0005CB74 /* DefaultMetadataCollector.swift in Sources */, D664C7C82B289AA200CBFA76 /* SubscriptionFlowView.swift in Sources */, @@ -8132,6 +8135,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 */, diff --git a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift b/DuckDuckGo/AIChat/AIChatSettings.swift similarity index 50% rename from DuckDuckGo/AIChat/AIChatRemoteSettings.swift rename to DuckDuckGo/AIChat/AIChatSettings.swift index b069e729a2..6a0b1e3706 100644 --- a/DuckDuckGo/AIChat/AIChatRemoteSettings.swift +++ b/DuckDuckGo/AIChat/AIChatSettings.swift @@ -1,5 +1,5 @@ // -// AIChatRemoteSettings.swift +// AIChatSettings.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -24,7 +24,7 @@ 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 AIChatRemoteSettings: AIChatRemoteSettingsProvider { +struct AIChatSettings: AIChatSettingsProvider { enum SettingsValue: String { case aiChatURL @@ -36,12 +36,16 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { } private let privacyConfigurationManager: PrivacyConfigurationManaging - private var settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + private var remoteSettings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { privacyConfigurationManager.privacyConfig.settings(for: .aiChat) } + private let internalUserDecider: InternalUserDecider + private let userDefaults: UserDefaults - init(privacyConfigurationManager: PrivacyConfigurationManaging) { + init(privacyConfigurationManager: PrivacyConfigurationManaging, internalUserDecider: InternalUserDecider, userDefaults: UserDefaults = .standard) { + self.internalUserDecider = internalUserDecider self.privacyConfigurationManager = privacyConfigurationManager + self.userDefaults = userDefaults } // MARK: - Public @@ -53,10 +57,29 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { 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 = settings[value.rawValue] as? String { + if let value = remoteSettings[value.rawValue] as? String { return value } else { Pixel.fire(pixel: .aiChatNoRemoteSettingsFound(settings: value.rawValue)) @@ -64,3 +87,22 @@ struct AIChatRemoteSettings: AIChatRemoteSettingsProvider { } } } + +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/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 61f609df20..f4c0439fb2 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -188,8 +188,9 @@ class MainViewController: UIViewController { var appDidFinishLaunchingStartTime: CFAbsoluteTime? private lazy var aiChatNavigationController: UINavigationController = { - let remoteSettings = AIChatRemoteSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager) - let aiChatViewController = AIChatViewController(remoteSettings: remoteSettings, + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + let aiChatViewController = AIChatViewController(settings: settings, webViewConfiguration: WKWebViewConfiguration.persistent(), pixelHandler: AIChatPixelHandler()) aiChatViewController.delegate = self 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/SettingsAIChatView.swift b/DuckDuckGo/SettingsAIChatView.swift new file mode 100644 index 0000000000..2370bd4b63 --- /dev/null +++ b/DuckDuckGo/SettingsAIChatView.swift @@ -0,0 +1,54 @@ +// +// SettingsAIChatView.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 SwiftUI +import DesignResourcesKit + +struct SettingsAIChatView: View { + @EnvironmentObject var viewModel: SettingsViewModel + + var body: some View { + List { + + VStack(alignment: .center) { + Image("SettingsDuckPlayerHero") + .padding(.top, -20) // Change this to AI Chat image + + Text(UserText.aiChatFeatureName) + .daxTitle3() + + Text(.init(UserText.aiChatPreferencesCaptionWithLinkMarkdown)) + .tint(Color.init(designSystemColor: .accent)) + .daxBodyRegular() + .multilineTextAlignment(.center) + .foregroundColor(Color(designSystemColor: .textSecondary)) + .padding(.top, 12) + } + .frame(maxWidth: .infinity) + .listRowBackground(Color.clear) + + Section { + SettingsCellView(label: UserText.aiChatSettingsEnableBrowsingMenuToggle, + accessory: .toggle(isOn: viewModel.aiChatEnabledBinding)) + } + }.applySettingsListModifiers(title: UserText.aiChatFeatureName, + displayMode: .inline, + viewModel: viewModel) + } +} diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index 02b487031b..e1e598356f 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -67,7 +67,15 @@ struct SettingsMainSettingsView: View { SettingsCellView(label: UserText.dataClearing, image: Image("SettingsDataClearing")) } - + + // AI Chat + if viewModel.state.aiChatEnabled { + NavigationLink(destination: SettingsAIChatView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.aiChatFeatureName, + image: Image("SettingsAIChat")) + } + } + // Duck Player // We need to hide the settings until the user is enrolled in the experiment if viewModel.state.duckPlayerEnabled { diff --git a/DuckDuckGo/SettingsRootView.swift b/DuckDuckGo/SettingsRootView.swift index 0378363566..01ad7b6a60 100644 --- a/DuckDuckGo/SettingsRootView.swift +++ b/DuckDuckGo/SettingsRootView.swift @@ -129,6 +129,8 @@ struct SettingsRootView: View { SettingsDuckPlayerView().environmentObject(viewModel) case .netP: NetworkProtectionRootView() + case .aiChat: + SettingsAIChatView().environmentObject(viewModel) } } diff --git a/DuckDuckGo/SettingsState.swift b/DuckDuckGo/SettingsState.swift index 6d1fb7c43d..af6d1a27bb 100644 --- a/DuckDuckGo/SettingsState.swift +++ b/DuckDuckGo/SettingsState.swift @@ -102,7 +102,10 @@ struct SettingsState { var duckPlayerMode: DuckPlayerMode? var duckPlayerOpenInNewTab: Bool var duckPlayerOpenInNewTabEnabled: Bool - + + // AI Chat + var aiChatEnabled: Bool + static var defaults: SettingsState { return SettingsState( appTheme: .systemDefault, @@ -142,7 +145,8 @@ struct SettingsState { duckPlayerEnabled: false, duckPlayerMode: .alwaysAsk, duckPlayerOpenInNewTab: true, - duckPlayerOpenInNewTabEnabled: false + duckPlayerOpenInNewTabEnabled: false, + aiChatEnabled: false ) } } diff --git a/DuckDuckGo/SettingsViewModel.swift b/DuckDuckGo/SettingsViewModel.swift index a97700ad72..935eb18ca5 100644 --- a/DuckDuckGo/SettingsViewModel.swift +++ b/DuckDuckGo/SettingsViewModel.swift @@ -28,6 +28,7 @@ import DuckPlayer import Subscription import NetworkProtection +import AIChat final class SettingsViewModel: ObservableObject { @@ -44,6 +45,7 @@ final class SettingsViewModel: ObservableObject { private let historyManager: HistoryManaging let privacyProDataReporter: PrivacyProDataReporting? let textZoomCoordinator: TextZoomCoordinating + let aiChatSettings: AIChatSettingsProvider // Subscription Dependencies let subscriptionManager: SubscriptionManager @@ -259,6 +261,15 @@ final class SettingsViewModel: ObservableObject { ) } + var aiChatEnabledBinding: Binding { + 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/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index 3981ad71d3..7bb7182e8d 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -29,8 +29,11 @@ import PrivacyDashboard extension TabViewController { private var shouldShowAIChatInMenuHeader: Bool { - featureFlagger.isFeatureOn(.aiChatBrowsingToolbarShortcut) || AppDependencyProvider.shared.internalUserDecider.isInternalUser + let settings = AIChatSettings(privacyConfigurationManager: ContentBlocking.shared.privacyConfigurationManager, + internalUserDecider: AppDependencyProvider.shared.internalUserDecider) + return settings.isAIChatBrowsingToolbarShortcutFeatureEnabled } + private var shouldShowPrintButtonInBrowsingMenu: Bool { shouldShowAIChatInMenuHeader } func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { @@ -102,10 +105,6 @@ extension TabViewController { let linkEntries = buildLinkEntries(with: bookmarksInterface) entries.append(contentsOf: linkEntries) - if let domain = self.privacyInfo?.domain { - entries.append(self.buildToggleProtectionEntry(forDomain: domain)) - } - if shouldShowPrintButtonInBrowsingMenu { entries.append(.regular(name: UserText.actionPrintSite, accessibilityLabel: UserText.actionPrintSite, @@ -116,6 +115,10 @@ extension TabViewController { })) } + if let domain = self.privacyInfo?.domain { + entries.append(self.buildToggleProtectionEntry(forDomain: domain)) + } + if link != nil { let name = UserText.actionReportBrokenSite entries.append(BrowsingMenuEntry.regular(name: name, diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 0445113d7c..6396d579da 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1315,6 +1315,13 @@ But if you *do* want a peek under the hood, you can find more information about // 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") + public static let aiChatSettingsEnableBrowsingMenuToggle = NSLocalizedString("aichat.settings.enable.browsing-menu-toggle", value: "Show AI Chat in Browsing Menu", comment: "Toggle text to enable/disable AI Chat in the browsing menu") + + 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 ()") + // MARK: - New Tab Page diff --git a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift index 4d003227f2..36c326dd8a 100644 --- a/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift +++ b/LocalPackages/AIChat/Sources/AIChat/AIChatViewModel.swift @@ -43,7 +43,7 @@ protocol AIChatViewModeling { final class AIChatViewModel: AIChatViewModeling { - private let remoteSettings: AIChatRemoteSettingsProvider + private let settings: AIChatSettingsProvider private var cleanupTimerCancellable: AnyCancellable? let webViewConfiguration: WKWebViewConfiguration @@ -51,10 +51,10 @@ final class AIChatViewModel: AIChatViewModeling { let cleanupTime: TimeInterval - init(webViewConfiguration: WKWebViewConfiguration, remoteSettings: AIChatRemoteSettingsProvider, cleanupTime: TimeInterval = 600) { + init(webViewConfiguration: WKWebViewConfiguration, settings: AIChatSettingsProvider, cleanupTime: TimeInterval = 600) { self.cleanupTime = cleanupTime self.webViewConfiguration = webViewConfiguration - self.remoteSettings = remoteSettings + self.settings = settings } func cancelTimer() { @@ -76,6 +76,6 @@ final class AIChatViewModel: AIChatViewModeling { } var aiChatURL: URL { - remoteSettings.aiChatURL + settings.aiChatURL } } diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift similarity index 58% rename from LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift rename to LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift index 7182584e28..afd76aaf9b 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatRemoteSettingsProvider.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatSettingsProvider.swift @@ -1,5 +1,5 @@ // -// AIChatRemoteSettingsProvider.swift +// AIChatSettingsProvider.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -19,7 +19,19 @@ import Foundation -public protocol AIChatRemoteSettingsProvider { +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 index 6210744202..4ec1ddba54 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -41,11 +41,11 @@ public final class AIChatViewController: UIViewController { /// Initializes a new instance of `AIChatViewController` with the specified remote settings and web view configuration. /// /// - Parameters: - /// - remoteSettings: An object conforming to `AIChatRemoteSettingsProvider` that provides remote settings. + /// - 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(remoteSettings: AIChatRemoteSettingsProvider, webViewConfiguration: WKWebViewConfiguration, pixelHandler: AIChatPixelHandling) { - let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, remoteSettings: remoteSettings) + public convenience init(settings: AIChatSettingsProvider, webViewConfiguration: WKWebViewConfiguration, pixelHandler: AIChatPixelHandling) { + let chatModel = AIChatViewModel(webViewConfiguration: webViewConfiguration, settings: settings) self.init(chatModel: chatModel, pixelHandler: pixelHandler) } From 924f0841348d722c7792097ace214e0e618a99c2 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 3 Dec 2024 20:30:52 -0300 Subject: [PATCH 32/38] Fix user text --- DuckDuckGo/SettingsAIChatView.swift | 2 +- DuckDuckGo/UserText.swift | 12 +++++++----- DuckDuckGo/en.lproj/Localizable.strings | 15 ++++++++++++++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/DuckDuckGo/SettingsAIChatView.swift b/DuckDuckGo/SettingsAIChatView.swift index 2370bd4b63..a235a9f7d1 100644 --- a/DuckDuckGo/SettingsAIChatView.swift +++ b/DuckDuckGo/SettingsAIChatView.swift @@ -33,7 +33,7 @@ struct SettingsAIChatView: View { Text(UserText.aiChatFeatureName) .daxTitle3() - Text(.init(UserText.aiChatPreferencesCaptionWithLinkMarkdown)) + Text(.init(UserText.aiChatSettingsCaptionWithLinkMarkdown)) .tint(Color.init(designSystemColor: .accent)) .daxBodyRegular() .multilineTextAlignment(.center) diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 6396d579da..2bf70519c1 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -1296,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") @@ -1318,9 +1318,11 @@ But if you *do* want a peek under the hood, you can find more information about 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") - public static let aiChatSettingsEnableBrowsingMenuToggle = NSLocalizedString("aichat.settings.enable.browsing-menu-toggle", value: "Show AI Chat in Browsing Menu", comment: "Toggle text to enable/disable AI Chat in the browsing menu") - - 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 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 diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 9dda5c6463..9162459f60 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -142,6 +142,19 @@ /* 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"; @@ -1092,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"; From e84cb78be1d42b0ec1d2c5f140e348b0f03775cc Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Tue, 3 Dec 2024 21:04:06 -0300 Subject: [PATCH 33/38] Add settings tests --- DuckDuckGo.xcodeproj/project.pbxproj | 20 +++ .../AIChat/AIChatSettingsTests.swift | 131 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 DuckDuckGoTests/AIChat/AIChatSettingsTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 229a66f4e7..b4d015e475 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -138,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 */; }; @@ -1502,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 = ""; }; @@ -3598,6 +3600,21 @@ 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 = ( @@ -6544,6 +6561,7 @@ F1E092B31E92A6B900732CCC /* Core */ = { isa = PBXGroup; children = ( + 310EEA2C2CFFCD9B0043CA1A /* New Group */, 316790E32C9350980090B0A2 /* MarketplaceAdPostback */, 858479CA2B8795BF00D156C1 /* History */, EA7EFE662677F5BD0075464E /* PrivacyReferenceTests */, @@ -6554,6 +6572,7 @@ EE3B226929DE0EE10082298A /* FeatureFlags */, F1134EC91F40E74800B73467 /* Statistics */, F198D78F1E3976300088DA8A /* Utilities */, + 310EEA2D2CFFCDB60043CA1A /* AIChat */, ); name = Core; sourceTree = ""; @@ -8338,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 */, 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 + } +} From b6b0ded2e38e8d1f11c557cdde60d508e6023035 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 4 Dec 2024 09:20:24 -0300 Subject: [PATCH 34/38] Update AI Chat icon --- .../SettingsAIChatHero.imageset/Contents.json | 12 ++++++++++++ .../SettingsAIChatHero.pdf | Bin 0 -> 3779 bytes DuckDuckGo/SettingsAIChatView.swift | 4 ++-- 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/Contents.json create mode 100644 DuckDuckGo/Settings.xcassets/Images/SettingsAIChatHero.imageset/SettingsAIChatHero.pdf 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 Date: Wed, 4 Dec 2024 09:24:08 -0300 Subject: [PATCH 35/38] Prevent appDidShowUITime from being fired more than once --- DuckDuckGo/MainViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/DuckDuckGo/MainViewController.swift b/DuckDuckGo/MainViewController.swift index f4c0439fb2..3e09cddf25 100644 --- a/DuckDuckGo/MainViewController.swift +++ b/DuckDuckGo/MainViewController.swift @@ -362,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 } } From befdb1865d246a530df57ade92e66b471f7a1220 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 4 Dec 2024 09:30:10 -0300 Subject: [PATCH 36/38] Update variable name --- DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift index 7bb7182e8d..8cba18608b 100644 --- a/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift +++ b/DuckDuckGo/TabViewControllerBrowsingMenuExtension.swift @@ -34,7 +34,7 @@ extension TabViewController { return settings.isAIChatBrowsingToolbarShortcutFeatureEnabled } - private var shouldShowPrintButtonInBrowsingMenu: Bool { shouldShowAIChatInMenuHeader } + private var shouldShowPrintButtonInBrowsingMenuList: Bool { shouldShowAIChatInMenuHeader } func buildBrowsingMenuHeaderContent() -> [BrowsingMenuEntry] { var entries = [BrowsingMenuEntry]() @@ -105,7 +105,7 @@ extension TabViewController { let linkEntries = buildLinkEntries(with: bookmarksInterface) entries.append(contentsOf: linkEntries) - if shouldShowPrintButtonInBrowsingMenu { + if shouldShowPrintButtonInBrowsingMenuList { entries.append(.regular(name: UserText.actionPrintSite, accessibilityLabel: UserText.actionPrintSite, image: UIImage(named: "Print-16")!, From 28f7896e7116bc22f399265beb4a1104a5d3438b Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 4 Dec 2024 11:43:05 -0300 Subject: [PATCH 37/38] Add identifier --- .../AIChat/Sources/AIChat/Public API/AIChatViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift index 4ec1ddba54..436ec008c8 100644 --- a/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift +++ b/LocalPackages/AIChat/Sources/AIChat/Public API/AIChatViewController.swift @@ -139,6 +139,7 @@ extension AIChatViewController { target: self, action: #selector(closeAIChat) ) + closeButton.accessibilityIdentifier = "aichat.close.button" closeButton.tintColor = .white navigationItem.rightBarButtonItem = closeButton From 906775c3ef91ba8bf00eaf0dc7b9882a79004c63 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Wed, 4 Dec 2024 15:32:42 -0300 Subject: [PATCH 38/38] Ship review feedback --- DuckDuckGo/SettingsMainSettingsView.swift | 16 ++++++++-------- DuckDuckGo/UserText.swift | 2 +- DuckDuckGo/en.lproj/Localizable.strings | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/DuckDuckGo/SettingsMainSettingsView.swift b/DuckDuckGo/SettingsMainSettingsView.swift index e1e598356f..9fe96cd819 100644 --- a/DuckDuckGo/SettingsMainSettingsView.swift +++ b/DuckDuckGo/SettingsMainSettingsView.swift @@ -68,14 +68,6 @@ struct SettingsMainSettingsView: View { image: Image("SettingsDataClearing")) } - // AI Chat - if viewModel.state.aiChatEnabled { - NavigationLink(destination: SettingsAIChatView().environmentObject(viewModel)) { - SettingsCellView(label: UserText.aiChatFeatureName, - image: Image("SettingsAIChat")) - } - } - // Duck Player // We need to hide the settings until the user is enrolled in the experiment if viewModel.state.duckPlayerEnabled { @@ -84,6 +76,14 @@ struct SettingsMainSettingsView: View { image: Image("SettingsDuckPlayer")) } } + + // AI Chat + if viewModel.state.aiChatEnabled { + NavigationLink(destination: SettingsAIChatView().environmentObject(viewModel)) { + SettingsCellView(label: UserText.aiChatFeatureName, + image: Image("SettingsAIChat")) + } + } } } diff --git a/DuckDuckGo/UserText.swift b/DuckDuckGo/UserText.swift index 2bf70519c1..ff3430d987 100644 --- a/DuckDuckGo/UserText.swift +++ b/DuckDuckGo/UserText.swift @@ -50,7 +50,7 @@ public struct UserText { 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 in the menu header") - public static let actionPrintSite = NSLocalizedString("action.title.print.site", value: "Print Site", comment: "Print action in the menu list") + 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") diff --git a/DuckDuckGo/en.lproj/Localizable.strings b/DuckDuckGo/en.lproj/Localizable.strings index 9162459f60..f65a3bac1a 100644 --- a/DuckDuckGo/en.lproj/Localizable.strings +++ b/DuckDuckGo/en.lproj/Localizable.strings @@ -86,7 +86,7 @@ "action.title.print" = "Print"; /* Print action in the menu list */ -"action.title.print.site" = "Print Site"; +"action.title.print.site" = "Print"; /* Refresh action - button shown in alert */ "action.title.refresh" = "Refresh";