Skip to content

[MOB-3582] AI Shortcut Access Point on NTP #932

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

Merged
Merged
16 changes: 16 additions & 0 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@
2CC246692D520EF90098467A /* EcosiaColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2465C2D520EF90098467A /* EcosiaColor.swift */; };
2CC2466B2D520EF90098467A /* EcosiaDarkTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2465D2D520EF90098467A /* EcosiaDarkTheme.swift */; };
2CC24C752D52478E0098467A /* MockTabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A36AC2B2886F27F00CDC0AD /* MockTabManager.swift */; };
2CE9E8072E43927000141C6D /* NTPMultiPurposeEcosiaHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE9E8052E43927000141C6D /* NTPMultiPurposeEcosiaHeaderViewModel.swift */; };
2CE9E8082E43927000141C6D /* NTPMultiPurposeEcosiaHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CE9E8042E43927000141C6D /* NTPMultiPurposeEcosiaHeader.swift */; };
2CFE9FBF2D45348200B25CE0 /* SnowplowTracker in Frameworks */ = {isa = PBXBuildFile; productRef = 2CFE9FBE2D45348200B25CE0 /* SnowplowTracker */; };
2CFE9FC02D45348700B25CE0 /* RustMozillaAppServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43BE578A278BA4D900491291 /* RustMozillaAppServices.framework */; };
2CFE9FC22D4535FD00B25CE0 /* Ecosia.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2CFE99662D45329200B25CE0 /* Ecosia.framework */; };
Expand Down Expand Up @@ -2802,6 +2804,8 @@
2CC2465E2D520EF90098467A /* EcosiaLightTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EcosiaLightTheme.swift; sourceTree = "<group>"; };
2CCB296620A99C9500121DD8 /* LoginsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginsTests.swift; sourceTree = "<group>"; };
2CCF17522105E4FD00705AE5 /* DisplaySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettingsTests.swift; sourceTree = "<group>"; };
2CE9E8042E43927000141C6D /* NTPMultiPurposeEcosiaHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NTPMultiPurposeEcosiaHeader.swift; sourceTree = "<group>"; };
2CE9E8052E43927000141C6D /* NTPMultiPurposeEcosiaHeaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NTPMultiPurposeEcosiaHeaderViewModel.swift; sourceTree = "<group>"; };
2CEA6F781E93E3A600D4100E /* SearchSettingsUITest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchSettingsUITest.swift; sourceTree = "<group>"; };
2CEDADA120207EC400223A89 /* SyncFAUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncFAUITests.swift; sourceTree = "<group>"; };
2CF21D0820A4A163000D08B7 /* PocketTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PocketTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -10519,6 +10523,15 @@
name = "Recovered References";
sourceTree = "<group>";
};
2CE9E8062E43927000141C6D /* MultiPurposeHeader */ = {
isa = PBXGroup;
children = (
2CE9E8042E43927000141C6D /* NTPMultiPurposeEcosiaHeader.swift */,
2CE9E8052E43927000141C6D /* NTPMultiPurposeEcosiaHeaderViewModel.swift */,
);
path = MultiPurposeHeader;
sourceTree = "<group>";
};
2CFE9FEC2D4557EF00B25CE0 /* Metrics */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -10643,6 +10656,7 @@
2CFEA0382D455BD500B25CE0 /* NTP */ = {
isa = PBXGroup;
children = (
2CE9E8062E43927000141C6D /* MultiPurposeHeader */,
2CFEA0192D455BD500B25CE0 /* ClimateImpactCounter */,
2CFEA01D2D455BD500B25CE0 /* Customization */,
2CFEA0242D455BD500B25CE0 /* Impact */,
Expand Down Expand Up @@ -16613,6 +16627,8 @@
8A19ACAB2A32895E001C2147 /* BrowserNavigationHandler.swift in Sources */,
8AE80BB82891BE0700BC12EA /* JumpBackInDataAdaptor.swift in Sources */,
8A01891C275E9C2A00923EFE /* ClearHistorySheetProvider.swift in Sources */,
2CE9E8072E43927000141C6D /* NTPMultiPurposeEcosiaHeaderViewModel.swift in Sources */,
2CE9E8082E43927000141C6D /* NTPMultiPurposeEcosiaHeader.swift in Sources */,
8A4EA0DD2C0117F200E4E4F1 /* MicrosurveyModel.swift in Sources */,
8C44A9D22A6A99FE009A1AA7 /* ShoppingProduct.swift in Sources */,
63F7A9AA2C7529ED005846F5 /* NativeErrorPageModel.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ extension AppSettingsTableViewController {
UnleashAPNConsent(settings: self),
AnalyticsIdentifierSetting(settings: self),
UnleashNativeSRPVAnalyticsSetting(settings: self),
UnleashAISearchMVPSetting(settings: self)
]

if Environment.current == .staging {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,10 @@ extension LegacyHomepageViewController: NTPSeedCounterDelegate {
SeedCounterNTPExperiment.trackTapOnSeedCounter()
}
}

extension LegacyHomepageViewController: NTPMultiPurposeEcosiaHeaderDelegate {
func multiPurposeEcosiaHeaderDidRequestAISearch() {
let aiSearchURL = Environment.current.urlProvider.aiSearch
openLink(url: aiSearchURL)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import Foundation
import Common

enum HomepageSectionType: Int, CaseIterable {
case multiPurposeEcosiaHeader // Ecosia: Multi-purpose header with AI search and other actions
case climateImpactCounter
case homepageHeader
case libraryShortcuts
Expand All @@ -21,6 +22,12 @@ enum HomepageSectionType: Int, CaseIterable {

var cellIdentifier: String {
switch self {
case .multiPurposeEcosiaHeader:
if #available(iOS 16.0, *) {
return NTPMultiPurposeEcosiaHeader.cellIdentifier
} else {
return "" // Fallback for iOS < 16.0
}
case .climateImpactCounter: return NTPSeedCounterCell.cellIdentifier
case .homepageHeader: return NTPLogoCell.cellIdentifier
case .libraryShortcuts: return NTPLibraryCell.cellIdentifier
Expand All @@ -32,7 +39,13 @@ enum HomepageSectionType: Int, CaseIterable {
}

static var cellTypes: [ReusableCell.Type] {
return [
var types: [ReusableCell.Type] = []

if #available(iOS 16.0, *) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you check with Product if no problem not supporting iOS 15? I see no issue and to be honest will likely soon be the case for the FF base, just checking.

types.append(NTPMultiPurposeEcosiaHeader.self)
}

types.append(contentsOf: [
NTPSeedCounterCell.self,
NTPLogoCell.self,
TopSiteItemCell.self,
Expand All @@ -41,7 +54,9 @@ enum HomepageSectionType: Int, CaseIterable {
NTPImpactCell.self,
NTPNewsCell.self,
NTPCustomizationCell.self
]
])

return types
}

init(_ section: Int) {
Expand All @@ -54,7 +69,7 @@ private let MinimumInsets: CGFloat = 16
extension HomepageSectionType {
var customizableConfig: CustomizableNTPSettingConfig? {
switch self {
case .homepageHeader, .libraryShortcuts, .ntpCustomization, .climateImpactCounter: return nil
case .multiPurposeEcosiaHeader, .homepageHeader, .libraryShortcuts, .ntpCustomization, .climateImpactCounter: return nil
case .topSites: return .topSites
case .impact: return .climateImpact
case .news: return .ecosiaNews
Expand Down Expand Up @@ -86,7 +101,7 @@ extension HomepageSectionType {
leading: horizontal,
bottom: bottomSpacing,
trailing: horizontal)
case .homepageHeader, .climateImpactCounter:
case .homepageHeader, .climateImpactCounter, .multiPurposeEcosiaHeader:
return .init(top: 0, leading: 0, bottom: 0, trailing: 0)
}
}
Expand Down
14 changes: 14 additions & 0 deletions firefox-ios/Client/Ecosia/Settings/EcosiaDebugSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,20 @@ final class UnleashNativeSRPVAnalyticsSetting: UnleashVariantResetSetting {
}
}

final class UnleashAISearchMVPSetting: UnleashVariantResetSetting {
override var titleName: String? {
"AI Search MVP"
}

override var status: NSAttributedString? {
return NSAttributedString(string: "Is enabled: \(Unleash.isEnabled(.aiSearchMVP).description)", attributes: [:])
}

override var unleashEnabled: Bool? {
Unleash.isEnabled(.aiSearchMVP)
}
}

final class AnalyticsIdentifierSetting: HiddenSetting {
override var title: NSAttributedString? {
return NSAttributedString(string: "Debug: Analytics Identifier", attributes: [:])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import UIKit
import SwiftUI
import Common
import Ecosia

protocol NTPMultiPurposeEcosiaHeaderDelegate: AnyObject {
func multiPurposeEcosiaHeaderDidRequestAISearch()
}

/// NTP header cell containing multiple Ecosia-specific actions like AI search
@available(iOS 16.0, *)
final class NTPMultiPurposeEcosiaHeader: UICollectionViewCell, ThemeApplicable, ReusableCell {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the descriptiveness, but to be honest I think NTPHeader might be a good enough name and the rest just making it longer 😅 Any specific reason for the longer name? I see as a pattern we also don't include Ecosia in others, not sure if you experience any conflicts.


// MARK: - Properties
private var hostingController: UIHostingController<AnyView>?
private var viewModel: NTPMultiPurposeEcosiaHeaderViewModel?

// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
setup()
}

// MARK: - Setup

private func setup() {
// Create a placeholder hosting controller - will be configured later
let hostingController = UIHostingController(rootView: AnyView(EmptyView()))
hostingController.view.backgroundColor = UIColor.clear
hostingController.view.translatesAutoresizingMaskIntoConstraints = false

contentView.addSubview(hostingController.view)
self.hostingController = hostingController

NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: contentView.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
}

// MARK: - Public Methods

func configure(with viewModel: NTPMultiPurposeEcosiaHeaderViewModel, windowUUID: WindowUUID) {
self.viewModel = viewModel

// Update the SwiftUI view with the new view model
let swiftUIView = NTPMultiPurposeEcosiaHeaderView(
viewModel: viewModel,
windowUUID: windowUUID
)

hostingController?.rootView = AnyView(swiftUIView)
}

// MARK: - Theming
func applyTheme(theme: Theme) {
// Theme is handled by the SwiftUI view
}
}

// MARK: - SwiftUI Multi-Purpose Header View
@available(iOS 16.0, *)
struct NTPMultiPurposeEcosiaHeaderView: View {
@ObservedObject var viewModel: NTPMultiPurposeEcosiaHeaderViewModel
let windowUUID: WindowUUID

var body: some View {
HStack {
Spacer()

// AI Search Button positioned on the right
// Ecosia: Use theme colors from view model (following FeedbackView pattern)
EcosiaAISearchButton(
backgroundColor: viewModel.buttonBackgroundColor,
iconColor: viewModel.buttonIconColor,
onTap: handleAISearchTap
)
}
.padding(.leading, .ecosia.space._m)
.padding(.trailing, .ecosia.space._m)
.onReceive(NotificationCenter.default.publisher(for: .ThemeDidChange)) { notification in
// Ecosia: Listen to theme changes (following FeedbackView pattern)
guard let uuid = notification.windowUUID, uuid == windowUUID else { return }
let themeManager = AppContainer.shared.resolve() as ThemeManager
viewModel.applyTheme(theme: themeManager.getCurrentTheme(for: windowUUID))
}
}

private func handleAISearchTap() {
viewModel.openAISearch()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Common
import Foundation
import Shared
import SwiftUI
import Ecosia

final class NTPMultiPurposeEcosiaHeaderViewModel: ObservableObject {
struct UX {
static let topInset: CGFloat = 24
}

// MARK: - Properties
private let windowUUID: WindowUUID
internal weak var delegate: NTPMultiPurposeEcosiaHeaderDelegate?
internal var theme: Theme

// MARK: - Theme Properties (following FeedbackView pattern)
@Published var buttonBackgroundColor = Color.gray.opacity(0.2)
@Published var buttonIconColor = Color.primary

// MARK: - Initialization
init(theme: Theme,
windowUUID: WindowUUID,
delegate: NTPMultiPurposeEcosiaHeaderDelegate? = nil) {
self.theme = theme
self.windowUUID = windowUUID
self.delegate = delegate

// Apply initial theme
applyTheme(theme: theme)
}

// MARK: - Theme Handling
func applyTheme(theme: Theme) {
self.theme = theme
buttonBackgroundColor = Color(theme.colors.ecosia.backgroundElevation1)
buttonIconColor = Color(theme.colors.ecosia.textPrimary)
}
Comment on lines +38 to +42
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels a bit bad to me that this is on the view model, but I remember this was already the case for the latest feedback view implementation. Can you remind me why that is? Couldn't we leave handling colors as a responsibility of the view similar to how SeedCounterView is?


// MARK: - Public Methods

func openAISearch() {
delegate?.multiPurposeEcosiaHeaderDidRequestAISearch()
Analytics.shared.aiSearchNTPButtonTapped()
}
}

// MARK: HomeViewModelProtocol
extension NTPMultiPurposeEcosiaHeaderViewModel: HomepageViewModelProtocol, FeatureFlaggable {
var sectionType: HomepageSectionType {
return .multiPurposeEcosiaHeader
}

var headerViewModel: LabelButtonHeaderViewModel {
return .emptyHeader
}

func section(for traitCollection: UITraitCollection, size: CGSize) -> NSCollectionLayoutSection {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .estimated(64))
let item = NSCollectionLayoutItem(layoutSize: itemSize)

let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .estimated(64))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: 1)

let section = NSCollectionLayoutSection(group: group)

section.contentInsets = NSDirectionalEdgeInsets(
top: UX.topInset,
leading: 0,
bottom: 0,
trailing: 0)

return section
}

func numberOfItemsInSection() -> Int {
return 1
}

var isEnabled: Bool {
AISearchMVPExperiment.isEnabled
}

func setTheme(theme: Theme) {
// Ecosia: Use applyTheme for consistency (following FeedbackView pattern)
applyTheme(theme: theme)
}

func refreshData(for traitCollection: UITraitCollection, size: CGSize, isPortrait: Bool, device: UIUserInterfaceIdiom) {
// No data refresh needed for multi-purpose header
}
}

extension NTPMultiPurposeEcosiaHeaderViewModel: HomepageSectionHandler {

func configure(_ cell: UICollectionViewCell, at indexPath: IndexPath) -> UICollectionViewCell {
if #available(iOS 16.0, *) {
guard let multiPurposeHeaderCell = cell as? NTPMultiPurposeEcosiaHeader else { return cell }
multiPurposeHeaderCell.configure(with: self, windowUUID: windowUUID)
return multiPurposeHeaderCell
}
return cell
}

func didSelectItem(at indexPath: IndexPath, homePanelDelegate: HomePanelDelegate?, libraryPanelDelegate: LibraryPanelDelegate?) {
// This cell handles its own button actions, no cell selection needed
}
}
Loading
Loading