Skip to content

Commit

Permalink
Add AI Chat toolbar (#3470)
Browse files Browse the repository at this point in the history
Task/Issue URL: https://app.asana.com/0/1204167627774280/1208607376873996/f

**Description**:
Add AI Chat shortcuts
  • Loading branch information
Bunn authored Nov 4, 2024
1 parent 118b648 commit f21203d
Show file tree
Hide file tree
Showing 35 changed files with 1,580 additions and 87 deletions.
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 {
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

0 comments on commit f21203d

Please sign in to comment.