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 icon onboarding #3445

Open
wants to merge 26 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
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
62 changes: 62 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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

import AppKit

final class AIChatDebugMenu: NSMenu {
init() {
super.init(title: "")

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

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

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

@objc func showToolbarOnboarding() {
var storage = DefaultAIChatPreferencesStorage()
storage.didDisplayAIChatToolbarOnboarding = false
NotificationCenter.default.post(name: .AIChatOpenedForReturningUser, object: nil)
}
}
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 {
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()
}
}
98 changes: 98 additions & 0 deletions DuckDuckGo/AIChat/AIChatRemoteSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// 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

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 {
// Fire unique pixel for value.rawValue
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