Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AI Chat toolbar #3470

Merged
merged 41 commits into from
Nov 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
adf7945
WIP: Add AIChat popover
Bunn Oct 21, 2024
b103228
WIP: Tab extension to show toolbar popover
Bunn Oct 21, 2024
3917339
Merge branch 'main' into bunn/aichat/popup-icon
Bunn Oct 22, 2024
3f7a493
WIP: Use remote settings for data
Bunn Oct 22, 2024
d3b6021
Remove URL from AIChatMenuVisibilityConfigurable
Bunn Oct 22, 2024
39e23ef
Hide button when dismissing popover
Bunn Oct 22, 2024
eadfe31
Add debug menu
Bunn Oct 22, 2024
7ae62f3
Validate query params
Bunn Oct 22, 2024
e1fc373
Fix issue identifying AI Chat url
Bunn Oct 23, 2024
cb8044f
improve settings code
Bunn Oct 23, 2024
d2b0672
WIP: Confirmation popover
Bunn Oct 23, 2024
577d88a
Use PopoverMessageViewController
Bunn Oct 23, 2024
651d604
Fix callbacks
Bunn Oct 23, 2024
d922706
Add text to userText
Bunn Oct 23, 2024
3070e3f
Add settings tests
Bunn Oct 23, 2024
dbfc798
Fix configuration tests
Bunn Oct 23, 2024
a2bb436
Bubble up dependency
Bunn Oct 23, 2024
ed7dcea
Add extension tests
Bunn Oct 23, 2024
2ce137f
Merge branch 'main' into bunn/aichat/popup-icon
Bunn Oct 23, 2024
da0aba6
Linter
Bunn Oct 23, 2024
d1d5c78
Fix header
Bunn Oct 23, 2024
c59b611
Add documentation to protocol
Bunn Oct 24, 2024
bfb68f3
Fix tooltip messaging.
samsymons Oct 28, 2024
7e92c0d
Show AI chat in the menus by default.
samsymons Oct 29, 2024
c341548
Merge branch 'release/1.112.0' into bunn/aichat/branch-from-head-pop-…
Bunn Oct 29, 2024
7ec0bdc
Fix issue with navigation from same document
Bunn Oct 29, 2024
329c9f0
Add pixels
Bunn Oct 29, 2024
0dc7b8d
Add translation
Bunn Oct 29, 2024
4bd9c0d
linter
Bunn Oct 29, 2024
da330e4
Do not display if the icon is visible
Bunn Oct 29, 2024
a2a3621
WIP: Ship review feedback
Bunn Oct 30, 2024
acd73c3
Update bookmarks bar copy
Bunn Oct 30, 2024
87ed70b
Add autofill password modal
Bunn Oct 30, 2024
866c9f6
Fix text with markdown
Bunn Oct 30, 2024
ddeaf85
Linter
Bunn Oct 30, 2024
5d58311
Update copy for AI Chat preferences
Bunn Oct 30, 2024
eb5c440
Fix fallback string
Bunn Oct 31, 2024
7d23316
Add extra tests
Bunn Oct 31, 2024
545618f
Add debug pixel
Bunn Nov 1, 2024
1fca336
lowercase pixel
Bunn Nov 1, 2024
107464c
Merge branch 'main' into bunn/aichat/branch-from-head-pop-icon
Bunn Nov 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions DuckDuckGo/AIChat/AIChatDebugMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// AIChatDebugMenu.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import AppKit

final class AIChatDebugMenu: NSMenu {
Bunn marked this conversation as resolved.
Show resolved Hide resolved
private var storage = DefaultAIChatPreferencesStorage()

init() {
super.init(title: "")

buildItems {
NSMenuItem(title: "Reset toolbar onboarding", action: #selector(resetToolbarOnboarding), target: self)
NSMenuItem(title: "Show toolbar onboarding", action: #selector(showToolbarOnboarding), target: self)
}
}

required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

@objc func resetToolbarOnboarding() {
storage.reset()
}

@objc func showToolbarOnboarding() {
storage.didDisplayAIChatToolbarOnboarding = false
NotificationCenter.default.post(name: .AIChatOpenedForReturningUser, object: nil)
}
}
68 changes: 60 additions & 8 deletions DuckDuckGo/AIChat/AIChatMenuVisibilityConfigurable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,51 @@
//

import Combine
import BrowserServicesKit

protocol AIChatMenuVisibilityConfigurable {

/// This property validates remote feature flags and user settings to determine if the shortcut
/// should be presented to the user.
///
/// - Returns: `true` if the application menu shortcut should be displayed; otherwise, `false`.
var shouldDisplayApplicationMenuShortcut: Bool { get }

/// This property checks the relevant settings to decide if the toolbar shortcut is to be shown.
///
/// - Returns: `true` if the toolbar shortcut should be displayed; otherwise, `false`.
var shouldDisplayToolbarShortcut: Bool { get }

/// This property reflects the current state of the feature flag for the application menu shortcut.
///
/// - Returns: `true` if the remote feature for the application menu shortcut is enabled; otherwise, `false`.
var isFeatureEnabledForApplicationMenuShortcut: Bool { get }

/// This property reflects the current state of the feature flag for the toolbar shortcut.
///
/// - Returns: `true` if the remote feature for the toolbar shortcut is enabled; otherwise, `false`.
var isFeatureEnabledForToolbarShortcut: Bool { get }

var shortcutURL: URL { get }
/// A publisher that emits a value when either the `shouldDisplayApplicationMenuShortcut` or
/// `shouldDisplayToolbarShortcut` settings, backed by storage, are changed.
///
/// This allows subscribers to react to changes in the visibility settings of the application menu
/// and toolbar shortcuts.
///
/// - Returns: A `PassthroughSubject` that emits `Void` when the values change.
var valuesChangedPublisher: PassthroughSubject<Void, Never> { get }

/// A publisher that is triggered when it is validated that the onboarding should be displayed.
///
/// This property listens to `AIChatOnboardingTabExtension` and triggers the publisher when a
/// notification `AIChatOpenedForReturningUser` is posted.
///
/// - Returns: A `PassthroughSubject` that emits `Void` when the onboarding popover should be displayed.
var shouldDisplayToolbarOnboardingPopover: PassthroughSubject<Void, Never> { get }

/// Marks the toolbar onboarding popover as shown, preventing it from being displayed more than once.
/// This method should be called after the onboarding popover has been presented to the user.
func markToolbarOnboardingPopoverAsShown()
}

final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable {
Expand All @@ -37,8 +72,11 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable {

private var cancellables = Set<AnyCancellable>()
private var storage: AIChatPreferencesStorage
private let notificationCenter: NotificationCenter
private let remoteSettings: AIChatRemoteSettingsProvider

var valuesChangedPublisher = PassthroughSubject<Void, Never>()
var shouldDisplayToolbarOnboardingPopover = PassthroughSubject<Void, Never>()

var isFeatureEnabledForApplicationMenuShortcut: Bool {
isFeatureEnabledFor(shortcutType: .applicationMenu)
Expand All @@ -56,13 +94,29 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable {
return isFeatureEnabledForApplicationMenuShortcut && storage.showShortcutInApplicationMenu
}

var shortcutURL: URL {
URL(string: "https://duckduckgo.com/?q=DuckDuckGo+AI+Chat&ia=chat&duckai=2")!
func markToolbarOnboardingPopoverAsShown() {
storage.didDisplayAIChatToolbarOnboarding = true
}

init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage()) {
init(storage: AIChatPreferencesStorage = DefaultAIChatPreferencesStorage(),
notificationCenter: NotificationCenter = .default,
remoteSettings: AIChatRemoteSettingsProvider = AIChatRemoteSettings()) {
self.storage = storage
self.notificationCenter = notificationCenter
self.remoteSettings = remoteSettings

self.subscribeToValuesChanged()
self.subscribeToAIChatLoadedNotification()
}

private func subscribeToAIChatLoadedNotification() {
notificationCenter.publisher(for: .AIChatOpenedForReturningUser)
.sink { [weak self] _ in
guard let self = self else { return }
if !self.storage.didDisplayAIChatToolbarOnboarding && !storage.shouldDisplayToolbarShortcut {
self.shouldDisplayToolbarOnboardingPopover.send()
}
}.store(in: &cancellables)
}

private func subscribeToValuesChanged() {
Expand All @@ -82,11 +136,9 @@ final class AIChatMenuConfiguration: AIChatMenuVisibilityConfigurable {
private func isFeatureEnabledFor(shortcutType: ShortcutType) -> Bool {
switch shortcutType {
case .applicationMenu:
// Use privacy config here
return true
return remoteSettings.isApplicationMenuShortcutEnabled
case .toolbar:
// Use privacy config here
return true
return remoteSettings.isToolbarShortcutEnabled
}
}
}
60 changes: 45 additions & 15 deletions DuckDuckGo/AIChat/AIChatPreferencesStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,25 @@ import Combine
protocol AIChatPreferencesStorage {
var showShortcutInApplicationMenu: Bool { get set }
var shouldDisplayToolbarShortcut: Bool { get set }
var didDisplayAIChatToolbarOnboarding: Bool { get set }

var showShortcutInApplicationMenuPublisher: AnyPublisher<Bool, Never> { get }
var shouldDisplayToolbarShortcutPublisher: AnyPublisher<Bool, Never> { get }

func reset()
}

struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage {
private let userDefaults: UserDefaults
private let pinningManager: PinningManager
private let notificationCenter: NotificationCenter

var showShortcutInApplicationMenuPublisher: AnyPublisher<Bool, Never> {
userDefaults.showAIChatShortcutInApplicationMenuPublisher
}

var shouldDisplayToolbarShortcutPublisher: AnyPublisher<Bool, Never> {
NotificationCenter.default.publisher(for: .PinnedViewsChanged)
notificationCenter.publisher(for: .PinnedViewsChanged)
.compactMap { notification -> PinnableView? in
guard let userInfo = notification.userInfo as? [String: Any],
let viewType = userInfo[LocalPinningManager.pinnedViewChangedNotificationViewTypeKey] as? String,
Expand All @@ -47,13 +54,12 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage {
.eraseToAnyPublisher()
}

private let userDefaults: UserDefaults
private let pinningManager: PinningManager

init(userDefaults: UserDefaults = .standard,
pinningManager: PinningManager = LocalPinningManager.shared) {
pinningManager: PinningManager = LocalPinningManager.shared,
notificationCenter: NotificationCenter = .default) {
self.userDefaults = userDefaults
self.pinningManager = pinningManager
self.notificationCenter = notificationCenter
}

var shouldDisplayToolbarShortcut: Bool {
Expand All @@ -71,31 +77,55 @@ struct DefaultAIChatPreferencesStorage: AIChatPreferencesStorage {
get { userDefaults.showAIChatShortcutInApplicationMenu }
set { userDefaults.showAIChatShortcutInApplicationMenu = newValue }
}

var didDisplayAIChatToolbarOnboarding: Bool {
get { userDefaults.didDisplayAIChatToolbarOnboarding }
set { userDefaults.didDisplayAIChatToolbarOnboarding = newValue }
}

func reset() {
userDefaults.showAIChatShortcutInApplicationMenu = UserDefaults.showAIChatShortcutInApplicationMenuDefaultValue
userDefaults.didDisplayAIChatToolbarOnboarding = UserDefaults.didDisplayAIChatToolbarOnboardingDefaultValue
pinningManager.unpin(.aiChat)
}
}

private extension UserDefaults {
private var showAIChatShortcutInApplicationMenuKey: String {
"aichat.showAIChatShortcutInApplicationMenu"
enum Keys {
static let showAIChatShortcutInApplicationMenuKey = "aichat.showAIChatShortcutInApplicationMenu"
static let didDisplayAIChatToolbarOnboardingKey = "aichat.didDisplayAIChatToolbarOnboarding"
}

static let showAIChatShortcutInApplicationMenuDefaultValue = false
static let showAIChatShortcutInApplicationMenuDefaultValue = true
static let didDisplayAIChatToolbarOnboardingDefaultValue = false

@objc
dynamic var showAIChatShortcutInApplicationMenu: Bool {
@objc dynamic var showAIChatShortcutInApplicationMenu: Bool {
get {
value(forKey: showAIChatShortcutInApplicationMenuKey) as? Bool ?? Self.showAIChatShortcutInApplicationMenuDefaultValue
value(forKey: Keys.showAIChatShortcutInApplicationMenuKey) as? Bool ?? Self.showAIChatShortcutInApplicationMenuDefaultValue
}

set {
guard newValue != showAIChatShortcutInApplicationMenu else {
return
}
guard newValue != showAIChatShortcutInApplicationMenu else { return }
set(newValue, forKey: Keys.showAIChatShortcutInApplicationMenuKey)
}
}

@objc dynamic var didDisplayAIChatToolbarOnboarding: Bool {
get {
value(forKey: Keys.didDisplayAIChatToolbarOnboardingKey) as? Bool ?? Self.didDisplayAIChatToolbarOnboardingDefaultValue
}

set(newValue, forKey: showAIChatShortcutInApplicationMenuKey)
set {
guard newValue != didDisplayAIChatToolbarOnboarding else { return }
set(newValue, forKey: Keys.didDisplayAIChatToolbarOnboardingKey)
}
}

var showAIChatShortcutInApplicationMenuPublisher: AnyPublisher<Bool, Never> {
publisher(for: \.showAIChatShortcutInApplicationMenu).eraseToAnyPublisher()
}

var didDisplayAIChatToolbarOnboardingPublisher: AnyPublisher<Bool, Never> {
publisher(for: \.didDisplayAIChatToolbarOnboarding).eraseToAnyPublisher()
}
}
99 changes: 99 additions & 0 deletions DuckDuckGo/AIChat/AIChatRemoteSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// AIChatRemoteSettings.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import BrowserServicesKit
import PixelKit

protocol AIChatRemoteSettingsProvider {
var onboardingCookieName: String { get }
var onboardingCookieDomain: String { get }
var aiChatURLIdentifiableQuery: String { get }
var aiChatURLIdentifiableQueryValue: String { get }
var aiChatURL: URL { get }
var isAIChatEnabled: Bool { get }
var isToolbarShortcutEnabled: Bool { get }
var isApplicationMenuShortcutEnabled: Bool { get }
}

/// This struct serves as a wrapper for PrivacyConfigurationManaging, enabling the retrieval of data relevant to AIChat.
/// It also fire pixels when necessary data is missing.
struct AIChatRemoteSettings: AIChatRemoteSettingsProvider {
enum SettingsValue: String {
case cookieName = "onboardingCookieName"
case cookieDomain = "onboardingCookieDomain"
case aiChatURL = "aiChatURL"
case aiChatURLIdentifiableQuery = "aiChatURLIdentifiableQuery"
case aiChatURLIdentifiableQueryValue = "aiChatURLIdentifiableQueryValue"

var defaultValue: String {
switch self {
case .cookieName: return "dcm"
case .cookieDomain: return "duckduckgo.com"
case .aiChatURL: return "https://duck.ai"
case .aiChatURLIdentifiableQuery: return "ia"
case .aiChatURLIdentifiableQueryValue: return "chat"
}
}
}

private let privacyConfigurationManager: PrivacyConfigurationManaging
private var settings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings {
privacyConfigurationManager.privacyConfig.settings(for: .aiChat)
}

init(privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager) {
self.privacyConfigurationManager = privacyConfigurationManager
}

// MARK: - Public

var onboardingCookieName: String { getSettingsData(.cookieName) }
var onboardingCookieDomain: String { getSettingsData(.cookieDomain) }
var aiChatURLIdentifiableQuery: String { getSettingsData(.aiChatURLIdentifiableQuery) }
var aiChatURLIdentifiableQueryValue: String { getSettingsData(.aiChatURLIdentifiableQueryValue) }

var aiChatURL: URL {
guard let url = URL(string: getSettingsData(.aiChatURL)) else {
return URL(string: SettingsValue.aiChatURL.defaultValue)!
}
return url
}

var isAIChatEnabled: Bool {
privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .aiChat)
}

var isToolbarShortcutEnabled: Bool {
privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.toolbarShortcut)
}

var isApplicationMenuShortcutEnabled: Bool {
privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(AIChatSubfeature.applicationMenuShortcut)
}

// MARK: - Private

private func getSettingsData(_ value: SettingsValue) -> String {
if let value = settings[value.rawValue] as? String {
return value
} else {
PixelKit.fire(GeneralPixel.aichatNoRemoteSettingsFound(value), includeAppVersionParameter: true)
return value.defaultValue
}
}
}
2 changes: 1 addition & 1 deletion DuckDuckGo/AIChat/AIChatTabOpener.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@

struct AIChatTabOpener {
@MainActor static func openAIChatTab() {
WindowControllersManager.shared.showTab(with: .url(AIChatMenuConfiguration().shortcutURL, credential: nil, source: .ui))
WindowControllersManager.shared.showTab(with: .url(AIChatRemoteSettings().aiChatURL, credential: nil, source: .ui))
}
}
Loading
Loading