diff --git a/Core/DailyPixelFiring.swift b/Core/DailyPixelFiring.swift new file mode 100644 index 0000000000..51b065e6e9 --- /dev/null +++ b/Core/DailyPixelFiring.swift @@ -0,0 +1,37 @@ +// +// DailyPixelFiring.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 DailyPixelFiring { + static func fireDaily(_ pixel: Pixel.Event, + withAdditionalParameters params: [String: String]) + + static func fireDaily(_ pixel: Pixel.Event) +} + +extension DailyPixel: DailyPixelFiring { + public static func fireDaily(_ pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { + fire(pixel: pixel, withAdditionalParameters: params) + } + + public static func fireDaily(_ pixel: Pixel.Event) { + fire(pixel: pixel) + } +} diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 7c1cc45b99..406592e880 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -730,6 +730,27 @@ extension Pixel { case bookmarkLaunchedDaily case newTabPageDisplayedDaily + // MARK: New Tab Page + case newTabPageMessageDisplayed + case newTabPageMessageDismissed + + case newTabPageFavoritesPlaceholderTapped + case newTabPageFavoritesInfoTooltip + + case newTabPageFavoritesSeeMore + case newTabPageFavoritesSeeLess + + case newTabPageCustomize + + case newTabPageShortcutClicked(_ shortcutName: String) + + case newTabPageCustomizeSectionOff(_ sectionName: String) + case newTabPageCustomizeSectionOn(_ sectionName: String) + case newTabPageSectionReordered + + case newTabPageCustomizeShortcutRemoved(_ shortcutName: String) + case newTabPageCustomizeShortcutAdded(_ shortcutName: String) + // MARK: DuckPlayer case duckPlayerDailyUniqueView case duckPlayerViewFromYoutubeViaMainOverlay @@ -1461,7 +1482,33 @@ extension Pixel.Event { case .favoriteLaunchedNTPDaily: return "m_favorite_launched_ntp_daily" case .bookmarkLaunchedDaily: return "m_bookmark_launched_daily" case .newTabPageDisplayedDaily: return "m_new_tab_page_displayed_daily" - + + // MARK: New Tab Page + case .newTabPageMessageDisplayed: return "m_new_tab_page_message_displayed" + case .newTabPageMessageDismissed: return "m_new_tab_page_message_dismissed" + + case .newTabPageFavoritesPlaceholderTapped: return "m_new_tab_page_favorites_placeholder_click" + case .newTabPageFavoritesInfoTooltip: return "m_new_tab_page_favorites_info_tooltip" + + case .newTabPageFavoritesSeeMore: return "m_new_tab_page_favorites_see_more" + case .newTabPageFavoritesSeeLess: return "m_new_tab_page_favorites_see_less" + + case .newTabPageShortcutClicked(let name): + return "m_new_tab_page_shortcut_clicked_\(name)" + + case .newTabPageCustomize: return "m_new_tab_page_customize" + + case .newTabPageCustomizeSectionOff(let sectionName): + return "m_new_tab_page_customize_section_off_\(sectionName)" + case .newTabPageCustomizeSectionOn(let sectionName): + return "m_new_tab_page_customize_section_on_\(sectionName)" + case .newTabPageSectionReordered: return "m_new_tab_page_customize_section_reordered" + + case .newTabPageCustomizeShortcutRemoved(let shortcutName): + return "m_new_tab_page_customize_shortcut_removed_\(shortcutName)" + case .newTabPageCustomizeShortcutAdded(let shortcutName): + return "m_new_tab_page_customize_shortcut_added_\(shortcutName)" + // MARK: DuckPlayer case .duckPlayerDailyUniqueView: return "m_duck-player_daily-unique-view" case .duckPlayerViewFromYoutubeViaMainOverlay: return "m_duck-player_view-from_youtube_main-overlay" diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 5ddfb55b4b..9faaf9e090 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -291,6 +291,10 @@ 6F64AA5F2C49463C00CF4489 /* ShortcutsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F64AA5E2C49463C00CF4489 /* ShortcutsModel.swift */; }; 6F655BE22BAB289E00AC3597 /* DefaultTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */; }; 6F691CCA2C4979EC002E9553 /* FavoritesTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F691CC92C4979EC002E9553 /* FavoritesTooltip.swift */; }; + 6F7FB8E12C660B3E00867DA7 /* NewTabPageFavoritesModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7FB8DF2C660B1A00867DA7 /* NewTabPageFavoritesModelTests.swift */; }; + 6F7FB8E32C660BF300867DA7 /* DailyPixelFiring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7FB8E22C660BF300867DA7 /* DailyPixelFiring.swift */; }; + 6F7FB8E52C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7FB8E42C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift */; }; + 6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7FB8E62C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift */; }; 6F8496412BC3D8EE00ADA54E /* OnboardingButtonsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */; }; 6F934F862C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */; }; 6F96FF102C2B128500162692 /* NewTabPageCustomizeButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */; }; @@ -1495,6 +1499,10 @@ 6F64AA5E2C49463C00CF4489 /* ShortcutsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsModel.swift; sourceTree = ""; }; 6F655BE12BAB289E00AC3597 /* DefaultTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultTheme.swift; sourceTree = ""; }; 6F691CC92C4979EC002E9553 /* FavoritesTooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesTooltip.swift; sourceTree = ""; }; + 6F7FB8DF2C660B1A00867DA7 /* NewTabPageFavoritesModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageFavoritesModelTests.swift; sourceTree = ""; }; + 6F7FB8E22C660BF300867DA7 /* DailyPixelFiring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyPixelFiring.swift; sourceTree = ""; }; + 6F7FB8E42C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageShortcutsSettingsModelTests.swift; sourceTree = ""; }; + 6F7FB8E62C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSectionsSettingsModelTests.swift; sourceTree = ""; }; 6F8496402BC3D8EE00ADA54E /* OnboardingButtonsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingButtonsView.swift; sourceTree = ""; }; 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSettingsPersistentStorageTests.swift; sourceTree = ""; }; 6F96FF0F2C2B128500162692 /* NewTabPageCustomizeButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageCustomizeButtonView.swift; sourceTree = ""; }; @@ -3619,6 +3627,9 @@ 6F934F852C58DB00008364E4 /* NewTabPageSettingsPersistentStorageTests.swift */, 6FD0C41E2C5BF097000561C9 /* NewTabPageIntroMessageSetupTests.swift */, 6FD0C4202C5BF774000561C9 /* NewTabPageModelTests.swift */, + 6F7FB8DF2C660B1A00867DA7 /* NewTabPageFavoritesModelTests.swift */, + 6F7FB8E42C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift */, + 6F7FB8E62C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift */, 564DE4562C4150E600D23241 /* HomeViewControllerDaxDialogTests.swift */, 6FABAA682C6116FD003762EC /* NewTabPageShortcutsSettingsStorageTests.swift */, ); @@ -5234,6 +5245,7 @@ 853A717520F62FE800FE60BC /* Pixel.swift */, 6F03CB062C32F173004179A8 /* PixelFiring.swift */, 6F03CB082C32F331004179A8 /* PixelFiringAsync.swift */, + 6F7FB8E22C660BF300867DA7 /* DailyPixelFiring.swift */, 1E05D1D729C46EDA00BF9A1F /* TimedPixel.swift */, 1E05D1D529C46EBB00BF9A1F /* DailyPixel.swift */, 85E242162AB1B54D000F3E28 /* ReturnUserMeasurement.swift */, @@ -7510,6 +7522,7 @@ 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, + 6F7FB8E12C660B3E00867DA7 /* NewTabPageFavoritesModelTests.swift in Sources */, F1BDDBFE2C340D9C00459306 /* SubscriptionFlowViewModelTests.swift in Sources */, F1BDDC022C340DDF00459306 /* SyncManagementViewModelTests.swift in Sources */, D625AAEC2BBEF27600BC189A /* TabURLInterceptorTests.swift in Sources */, @@ -7534,6 +7547,7 @@ 98AAF8E4292EB46000DBDF06 /* BookmarksMigrationTests.swift in Sources */, 85D2187224BF24F2004373D2 /* NotFoundCachingDownloaderTests.swift in Sources */, C111B26927F579EF006558B1 /* BookmarkOrFolderTests.swift in Sources */, + 6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */, 851CD674244D7E6000331B98 /* UserDefaultsExtension.swift in Sources */, 569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */, 850559D223CF710C0055C0D5 /* WebCacheManagerTests.swift in Sources */, @@ -7547,6 +7561,7 @@ C1D21E2F293A599C006E5A05 /* AutofillLoginSessionTests.swift in Sources */, 85D2187924BF6B8B004373D2 /* FaviconSourcesProviderTests.swift in Sources */, 9F69331B2C5A16E200CD6A5D /* OnboardingDaxFavouritesTests.swift in Sources */, + 6F7FB8E52C66158D00867DA7 /* NewTabPageShortcutsSettingsModelTests.swift in Sources */, 983BD6B52B34760600AAC78E /* MockPrivacyConfiguration.swift in Sources */, 1E8146AD28C8ABF000D1AF63 /* TrackerAnimationLogicTests.swift in Sources */, C1CDA31E2AFBF811006D1476 /* AutofillNeverPromptWebsitesManagerTests.swift in Sources */, @@ -7756,6 +7771,7 @@ 85A1B3B220C6CD9900C18F15 /* CookieStorage.swift in Sources */, 9856A1992933D2EB00ACB44F /* BookmarksModelsErrorHandling.swift in Sources */, 850559D023CF647C0055C0D5 /* PreserveLogins.swift in Sources */, + 6F7FB8E32C660BF300867DA7 /* DailyPixelFiring.swift in Sources */, C1CCCBA7283E101500CF3791 /* FaviconsHelper.swift in Sources */, 9813F79822BA71AA00A80EDB /* StorageCache.swift in Sources */, B603974929C19F6F00902A34 /* Assertions.swift in Sources */, diff --git a/DuckDuckGo/FavoritesDefaultModel.swift b/DuckDuckGo/FavoritesDefaultModel.swift index eae3da8301..794bb34d1a 100644 --- a/DuckDuckGo/FavoritesDefaultModel.swift +++ b/DuckDuckGo/FavoritesDefaultModel.swift @@ -24,11 +24,12 @@ import SwiftUI import Core import WidgetKit -final class FavoritesDefaultModel: FavoritesModel { +final class FavoritesDefaultModel: FavoritesModel, FavoritesEmptyStateModel { @Published private(set) var allFavorites: [Favorite] = [] @Published private(set) var isCollapsed: Bool = true - + @Published private(set) var isShowingTooltip: Bool = false + private(set) lazy var faviconLoader: FavoritesFaviconLoading? = { FavoritesFaviconLoader(onFaviconMissing: { [weak self] in guard let self else { return } @@ -42,13 +43,19 @@ final class FavoritesDefaultModel: FavoritesModel { private var cancellables = Set() private let interactionModel: FavoritesListInteracting + private let pixelFiring: PixelFiring.Type + private let dailyPixelFiring: DailyPixelFiring.Type var isEmpty: Bool { allFavorites.isEmpty } - init(interactionModel: FavoritesListInteracting) { + init(interactionModel: FavoritesListInteracting, + pixelFiring: PixelFiring.Type = Pixel.self, + dailyPixelFiring: DailyPixelFiring.Type = DailyPixel.self) { self.interactionModel = interactionModel + self.pixelFiring = pixelFiring + self.dailyPixelFiring = dailyPixelFiring interactionModel.externalUpdates.sink { [weak self] _ in try? self?.updateData() @@ -63,6 +70,12 @@ final class FavoritesDefaultModel: FavoritesModel { func toggleCollapse() { isCollapsed.toggle() + + if isCollapsed { + pixelFiring.fire(.newTabPageFavoritesSeeLess, withAdditionalParameters: [:]) + } else { + pixelFiring.fire(.newTabPageFavoritesSeeMore, withAdditionalParameters: [:]) + } } func prefixedFavorites(for columnsCount: Int) -> FavoritesSlice { @@ -84,8 +97,8 @@ final class FavoritesDefaultModel: FavoritesModel { func favoriteSelected(_ favorite: Favorite) { guard let url = favorite.urlObject else { return } - Pixel.fire(pixel: .favoriteLaunchedNTP) - DailyPixel.fire(pixel: .favoriteLaunchedNTPDaily) + pixelFiring.fire(.favoriteLaunchedNTP, withAdditionalParameters: [:]) + dailyPixelFiring.fireDaily(.favoriteLaunchedNTPDaily) Favicons.shared.loadFavicon(forDomain: url.host, intoCache: .fireproof, fromCache: .tabs) onFavoriteURLSelected?(url) @@ -95,8 +108,8 @@ final class FavoritesDefaultModel: FavoritesModel { func deleteFavorite(_ favorite: Favorite) { guard let entity = lookupEntity(for: favorite) else { return } - Pixel.fire(pixel: .homeScreenDeleteFavorite) - + pixelFiring.fire(.homeScreenDeleteFavorite, withAdditionalParameters: [:]) + interactionModel.removeFavorite(entity) WidgetCenter.shared.reloadAllTimelines() @@ -109,7 +122,7 @@ final class FavoritesDefaultModel: FavoritesModel { func editFavorite(_ favorite: Favorite) { guard let entity = lookupEntity(for: favorite) else { return } - Pixel.fire(pixel: .homeScreenEditFavorite) + pixelFiring.fire(.homeScreenEditFavorite, withAdditionalParameters: [:]) onFavoriteEdit?(entity) } @@ -127,6 +140,21 @@ final class FavoritesDefaultModel: FavoritesModel { allFavorites.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: index) } + // MARK: - Empty state model + + func placeholderTapped() { + pixelFiring.fire(.newTabPageFavoritesPlaceholderTapped, withAdditionalParameters: [:]) + } + + func toggleTooltip() { + isShowingTooltip.toggle() + if isShowingTooltip { + pixelFiring.fire(.newTabPageFavoritesInfoTooltip, withAdditionalParameters: [:]) + } + } + + // MARK: - + private func lookupEntity(for favorite: Favorite) -> BookmarkEntity? { interactionModel.favorites.first { $0.uuid == favorite.id diff --git a/DuckDuckGo/FavoritesEmptyStateView.swift b/DuckDuckGo/FavoritesEmptyStateView.swift index 02c2beae1b..60d528104c 100644 --- a/DuckDuckGo/FavoritesEmptyStateView.swift +++ b/DuckDuckGo/FavoritesEmptyStateView.swift @@ -19,18 +19,18 @@ import SwiftUI -struct FavoritesEmptyStateView: View { +struct FavoritesEmptyStateView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.isLandscapeOrientation) var isLandscape - @State private var headerPadding: CGFloat = 10 + @ObservedObject var model: Model - @Binding var isShowingTooltip: Bool + @State private var headerPadding: CGFloat = 10 var body: some View { ZStack(alignment: .topTrailing) { VStack(spacing: 16) { - FavoritesSectionHeader(isShowingTooltip: $isShowingTooltip) + FavoritesSectionHeader(model: model) .padding(.horizontal, headerPadding) NewTabPageGridView { placeholdersCount in @@ -38,6 +38,10 @@ struct FavoritesEmptyStateView: View { ForEach(placeholders, id: \.self) { _ in FavoriteEmptyStateItem() .frame(width: NewTabPageGrid.Item.edgeSize, height: NewTabPageGrid.Item.edgeSize) + .contentShape(.capsule) + .onTapGesture { + model.placeholderTapped() + } } }.overlay( GeometryReader(content: { geometry in @@ -53,7 +57,7 @@ struct FavoritesEmptyStateView: View { }) } - if isShowingTooltip { + if model.isShowingTooltip { FavoritesTooltip() .offset(x: -headerPadding + 18, y: 24) .frame(maxWidth: .infinity, alignment: .bottomTrailing) @@ -63,8 +67,7 @@ struct FavoritesEmptyStateView: View { } #Preview { - @State var isShowingTooltip = false - return FavoritesEmptyStateView(isShowingTooltip: $isShowingTooltip) + return FavoritesEmptyStateView(model: FavoritesPreviewModel()) } private struct WidthKey: PreferenceKey { @@ -73,3 +76,13 @@ private struct WidthKey: PreferenceKey { } static var defaultValue: CGFloat = .zero } + +private final class PreviewEmptyStateModel: FavoritesEmptyStateModel { + @Published var isShowingTooltip: Bool = true + + func toggleTooltip() { + } + + func placeholderTapped() { + } +} diff --git a/DuckDuckGo/FavoritesModel.swift b/DuckDuckGo/FavoritesModel.swift index 427df29d1a..ce1804fce2 100644 --- a/DuckDuckGo/FavoritesModel.swift +++ b/DuckDuckGo/FavoritesModel.swift @@ -40,6 +40,14 @@ protocol FavoritesModel: AnyObject, ObservableObject { func moveFavorites(from indexSet: IndexSet, to index: Int) } +protocol FavoritesEmptyStateModel: AnyObject, ObservableObject { + + var isShowingTooltip: Bool { get } + + func placeholderTapped() + func toggleTooltip() +} + struct FavoritesSlice { let items: [Favorite] let isCollapsible: Bool diff --git a/DuckDuckGo/FavoritesPreviewModel.swift b/DuckDuckGo/FavoritesPreviewModel.swift index 4e59572887..fc17ec9c60 100644 --- a/DuckDuckGo/FavoritesPreviewModel.swift +++ b/DuckDuckGo/FavoritesPreviewModel.swift @@ -20,7 +20,9 @@ import Bookmarks import Foundation -final class FavoritesPreviewModel: FavoritesModel { +final class FavoritesPreviewModel: FavoritesModel, FavoritesEmptyStateModel { + + @Published var isShowingTooltip: Bool = false var isCollapsed: Bool = true @Published var allFavorites: [Favorite] @@ -78,6 +80,14 @@ final class FavoritesPreviewModel: FavoritesModel { func loadFavicon(for favorite: Favorite, size: CGFloat) async { } + + func placeholderTapped() { + + } + + func toggleTooltip() { + + } } struct EmptyFaviconLoading: FavoritesFaviconLoading { diff --git a/DuckDuckGo/FavoritesSectionHeader.swift b/DuckDuckGo/FavoritesSectionHeader.swift index ce13182c1c..10a0779460 100644 --- a/DuckDuckGo/FavoritesSectionHeader.swift +++ b/DuckDuckGo/FavoritesSectionHeader.swift @@ -19,10 +19,11 @@ import SwiftUI import DesignResourcesKit +import Core struct FavoritesSectionHeader: View { - @Binding var isShowingTooltip: Bool + let model: any FavoritesEmptyStateModel var body: some View { HStack(spacing: 16, content: { @@ -34,7 +35,7 @@ struct FavoritesSectionHeader: View { Spacer() Button(action: { - isShowingTooltip.toggle() + model.toggleTooltip() }, label: { Image(.info12) .foregroundStyle(Color(designSystemColor: .textPrimary)) @@ -44,6 +45,5 @@ struct FavoritesSectionHeader: View { } #Preview { - @State var isShowingTooltip = true - return FavoritesSectionHeader(isShowingTooltip: $isShowingTooltip) + return FavoritesSectionHeader(model: FavoritesPreviewModel()) } diff --git a/DuckDuckGo/NewTabPageModel.swift b/DuckDuckGo/NewTabPageModel.swift index fc002768d5..f9d826133b 100644 --- a/DuckDuckGo/NewTabPageModel.swift +++ b/DuckDuckGo/NewTabPageModel.swift @@ -18,22 +18,30 @@ // import Foundation +import Core final class NewTabPageModel: ObservableObject { @Published private(set) var isIntroMessageVisible: Bool @Published private(set) var isOnboarding: Bool + @Published var isShowingSettings: Bool private let appSettings: AppSettings + private let pixelFiring: PixelFiring.Type - init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings) { + init(appSettings: AppSettings = AppDependencyProvider.shared.appSettings, + pixelFiring: PixelFiring.Type = Pixel.self) { self.appSettings = appSettings - + self.pixelFiring = pixelFiring + isIntroMessageVisible = appSettings.newTabPageIntroMessageEnabled ?? false isOnboarding = false + isShowingSettings = false } - func increaseIntroMessageCounter() { + func introMessageDisplayed() { + pixelFiring.fire(.newTabPageMessageDisplayed, withAdditionalParameters: [:]) + appSettings.newTabPageIntroMessageSeenCount += 1 if appSettings.newTabPageIntroMessageSeenCount >= 3 { appSettings.newTabPageIntroMessageEnabled = false @@ -41,10 +49,17 @@ final class NewTabPageModel: ObservableObject { } func dismissIntroMessage() { + pixelFiring.fire(.newTabPageMessageDismissed, withAdditionalParameters: [:]) + appSettings.newTabPageIntroMessageEnabled = false isIntroMessageVisible = false } + func customizeNewTabPage() { + pixelFiring.fire(.newTabPageCustomize, withAdditionalParameters: [:]) + isShowingSettings = true + } + func startOnboarding() { isOnboarding = true } diff --git a/DuckDuckGo/NewTabPageSectionsSettingsModel.swift b/DuckDuckGo/NewTabPageSectionsSettingsModel.swift index 37f27339ef..9f7d3d4a56 100644 --- a/DuckDuckGo/NewTabPageSectionsSettingsModel.swift +++ b/DuckDuckGo/NewTabPageSectionsSettingsModel.swift @@ -18,11 +18,36 @@ // import Foundation +import Core typealias NewTabPageSectionsSettingsModel = NewTabPageSettingsModel extension NewTabPageSectionsSettingsModel { - convenience init(storage: NewTabPageSectionsSettingsStorage = NewTabPageSectionsSettingsStorage()) { - self.init(settingsStorage: storage) + convenience init(storage: NewTabPageSectionsSettingsStorage = NewTabPageSectionsSettingsStorage(), + pixelFiring: PixelFiring.Type = Pixel.self) { + self.init(settingsStorage: storage, + onItemEnabled: { Self.onEnabled($0, isEnabled: $1, pixelFiring: pixelFiring) }, + onItemReordered: { Self.onReordered(pixelFiring: pixelFiring) }) + } + + private static func onEnabled(_ section: SettingItem, isEnabled: Bool, pixelFiring: PixelFiring.Type) { + if isEnabled { + pixelFiring.fire(.newTabPageCustomizeSectionOn(section.nameForPixel), withAdditionalParameters: [:]) + } else { + pixelFiring.fire(.newTabPageCustomizeSectionOff(section.nameForPixel), withAdditionalParameters: [:]) + } + } + + private static func onReordered(pixelFiring: PixelFiring.Type) { + pixelFiring.fire(.newTabPageSectionReordered, withAdditionalParameters: [:]) + } +} + +private extension NewTabPageSection { + var nameForPixel: String { + switch self { + case .favorites: return "favorites" + case .shortcuts: return "shortcuts" + } } } diff --git a/DuckDuckGo/NewTabPageSettingsModel.swift b/DuckDuckGo/NewTabPageSettingsModel.swift index dad394bd4f..9c36910613 100644 --- a/DuckDuckGo/NewTabPageSettingsModel.swift +++ b/DuckDuckGo/NewTabPageSettingsModel.swift @@ -18,8 +18,11 @@ // import Foundation +import Core import SwiftUI +typealias SettingItemEnabledFunction = (_ item: I, _ isEnabled: Bool) -> Void + final class NewTabPageSettingsModel: ObservableObject where Storage.SettingItem == SettingItem { /// Settings page settings collection with bindings @@ -34,10 +37,17 @@ final class NewTabPageSettingsModel Bool) + private let onItemEnabled: SettingItemEnabledFunction? + private let onItemReordered: (() -> Void)? - init(settingsStorage: Storage, visibilityFilter: @escaping ((SettingItem) -> Bool) = { _ in true }) { + init(settingsStorage: Storage, + onItemEnabled: SettingItemEnabledFunction? = nil, + onItemReordered: (() -> Void)? = nil, + visibilityFilter: @escaping ((SettingItem) -> Bool) = { _ in true }) { self.settingsStorage = settingsStorage self.visibilityFilter = visibilityFilter + self.onItemEnabled = onItemEnabled + self.onItemReordered = onItemReordered updatePublishedValues() } @@ -51,6 +61,8 @@ final class NewTabPageSettingsModel extension NewTabPageShortcutsSettingsModel { convenience init(storage: NewTabPageShortcutsSettingsStorage = NewTabPageShortcutsSettingsStorage(), - featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger) { - self.init(settingsStorage: storage) { shortcut in + featureFlagger: FeatureFlagger = AppDependencyProvider.shared.featureFlagger, + pixelFiring: PixelFiring.Type = Pixel.self) { + self.init(settingsStorage: storage, + onItemEnabled: { Self.onEnabled($0, isEnabled: $1, pixelFiring: pixelFiring) }, + onItemReordered: nil, + visibilityFilter: { shortcut in switch shortcut { case .aiChat, .bookmarks, .downloads, .settings: return true case .passwords: return featureFlagger.isFeatureOn(.autofillAccessCredentialManagement) } + }) + } + + private static func onEnabled(_ shortcut: SettingItem, isEnabled: Bool, pixelFiring: PixelFiring.Type) { + if isEnabled { + pixelFiring.fire(.newTabPageCustomizeShortcutAdded(shortcut.nameForPixel), withAdditionalParameters: [:]) + } else { + pixelFiring.fire( .newTabPageCustomizeShortcutRemoved(shortcut.nameForPixel), withAdditionalParameters: [:]) } } } diff --git a/DuckDuckGo/NewTabPageView.swift b/DuckDuckGo/NewTabPageView.swift index 90d6ea6331..ccf007ff5c 100644 --- a/DuckDuckGo/NewTabPageView.swift +++ b/DuckDuckGo/NewTabPageView.swift @@ -21,7 +21,7 @@ import SwiftUI import DuckUI import RemoteMessaging -struct NewTabPageView: View { +struct NewTabPageView: View { @Environment(\.horizontalSizeClass) var horizontalSizeClass @ObservedObject private var newTabPageModel: NewTabPageModel @@ -30,9 +30,6 @@ struct NewTabPageView: View { @ObservedObject private var shortcutsModel: ShortcutsModel @ObservedObject private var shortcutsSettingsModel: NewTabPageShortcutsSettingsModel @ObservedObject private var sectionsSettingsModel: NewTabPageSectionsSettingsModel - - @State var isShowingTooltip: Bool = false - @State private var isShowingSettings: Bool = false init(newTabPageModel: NewTabPageModel, messagesModel: NewTabPageMessagesModel, @@ -61,7 +58,7 @@ struct NewTabPageView: View { private var favoritesSectionView: some View { Group { if favoritesModel.isEmpty { - FavoritesEmptyStateView(isShowingTooltip: $isShowingTooltip) + FavoritesEmptyStateView(model: favoritesModel) } else { FavoritesView(model: favoritesModel) } @@ -82,7 +79,7 @@ struct NewTabPageView: View { Spacer() Button(action: { - isShowingSettings = true + newTabPageModel.customizeNewTabPage() }, label: { NewTabPageCustomizeButtonView() // Needed to reduce default button margins @@ -102,7 +99,7 @@ struct NewTabPageView: View { }) .sectionPadding() .onFirstAppear { - newTabPageModel.increaseIntroMessageCounter() + newTabPageModel.introMessageDisplayed() } } } @@ -147,12 +144,12 @@ struct NewTabPageView: View { } } .background(Color(designSystemColor: .background)) - .if(isShowingTooltip) { + .if(favoritesModel.isShowingTooltip) { $0.highPriorityGesture(DragGesture(minimumDistance: 0, coordinateSpace: .global).onEnded { _ in - isShowingTooltip = false + favoritesModel.toggleTooltip() }) } - .sheet(isPresented: $isShowingSettings, onDismiss: { + .sheet(isPresented: $newTabPageModel.isShowingSettings, onDismiss: { shortcutsSettingsModel.save() sectionsSettingsModel.save() }, content: { diff --git a/DuckDuckGo/NewTabPageViewController.swift b/DuckDuckGo/NewTabPageViewController.swift index a7a490ef64..1a71aeacd6 100644 --- a/DuckDuckGo/NewTabPageViewController.swift +++ b/DuckDuckGo/NewTabPageViewController.swift @@ -85,6 +85,9 @@ final class NewTabPageViewController: UIHostingController Void)? + let pixelFiring: PixelFiring.Type + + init(pixelFiring: PixelFiring.Type = Pixel.self) { + self.pixelFiring = pixelFiring + } func openShortcut(_ shortcut: NewTabPageShortcut) { + pixelFiring.fire(.newTabPageShortcutClicked(shortcut.nameForPixel), withAdditionalParameters: [:]) onShortcutOpened?(shortcut) } } diff --git a/DuckDuckGoTests/MockPixelFiring.swift b/DuckDuckGoTests/MockPixelFiring.swift index ca37898710..5794dbe0b5 100644 --- a/DuckDuckGoTests/MockPixelFiring.swift +++ b/DuckDuckGoTests/MockPixelFiring.swift @@ -20,19 +20,26 @@ import Foundation import Core -final actor PixelFiringMock: PixelFiring, PixelFiringAsync { +struct PixelInfo { + let pixel: Pixel.Event? + let params: [String: String]? + let includedParams: [Pixel.QueryParameters]? +} + +final actor PixelFiringMock: PixelFiring, PixelFiringAsync, DailyPixelFiring { static var expectedFireError: Error? - static var lastParams: [String: String]? - static var lastPixel: Pixel.Event? - static var lastIncludedParams: [Pixel.QueryParameters]? - + static var lastPixelInfo: PixelInfo? + static var lastDailyPixelInfo: PixelInfo? + + static var lastParams: [String: String]? { lastPixelInfo?.params } + static var lastPixel: Pixel.Event? { lastPixelInfo?.pixel } + static var lastIncludedParams: [Pixel.QueryParameters]? { lastPixelInfo?.includedParams } + static func fire(pixel: Pixel.Event, withAdditionalParameters params: [String: String], includedParameters: [Pixel.QueryParameters]) async throws { - lastParams = params - lastPixel = pixel - lastIncludedParams = includedParameters + lastPixelInfo = PixelInfo(pixel: pixel, params: params, includedParams: includedParameters) if let expectedFireError { throw expectedFireError @@ -43,9 +50,7 @@ final actor PixelFiringMock: PixelFiring, PixelFiringAsync { withAdditionalParameters params: [String: String], includedParameters: [Pixel.QueryParameters], onComplete: @escaping (Error?) -> Void) { - lastParams = params - lastPixel = pixel - lastIncludedParams = includedParameters + lastPixelInfo = PixelInfo(pixel: pixel, params: params, includedParams: includedParameters) if let expectedFireError { onComplete(expectedFireError) @@ -54,14 +59,20 @@ final actor PixelFiringMock: PixelFiring, PixelFiringAsync { static func fire(_ pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { - lastParams = params - lastPixel = pixel + lastPixelInfo = PixelInfo(pixel: pixel, params: params, includedParams: nil) + } + + static func fireDaily(_ pixel: Pixel.Event) { + lastDailyPixelInfo = PixelInfo(pixel: pixel, params: nil, includedParams: nil) + } + + static func fireDaily(_ pixel: Pixel.Event, withAdditionalParameters params: [String: String]) { + lastDailyPixelInfo = PixelInfo(pixel: pixel, params: params, includedParams: nil) } static func tearDown() { - lastParams = nil - lastPixel = nil - lastIncludedParams = nil + lastPixelInfo = nil + lastDailyPixelInfo = nil expectedFireError = nil } diff --git a/DuckDuckGoTests/NewTabPageFavoritesModelTests.swift b/DuckDuckGoTests/NewTabPageFavoritesModelTests.swift new file mode 100644 index 0000000000..8e4aff669b --- /dev/null +++ b/DuckDuckGoTests/NewTabPageFavoritesModelTests.swift @@ -0,0 +1,112 @@ +// +// NewTabPageFavoritesModelTests.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 +import Bookmarks +import BrowserServicesKit +@testable import DuckDuckGo + +final class NewTabPageFavoritesModelTests: XCTestCase { + private let favoritesListInteracting = MockFavoritesListInteracting() + + override func tearDown() { + PixelFiringMock.tearDown() + } + + func testFiresPixelWhenExpandingList() { + let sut = createSUT() + + XCTAssertTrue(sut.isCollapsed) + sut.toggleCollapse() + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageFavoritesSeeMore) + } + + func testFiresPixelWhenCollapsingList() { + let sut = createSUT() + + sut.toggleCollapse() + + XCTAssertFalse(sut.isCollapsed) + sut.toggleCollapse() + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageFavoritesSeeLess) + } + + func testFiresPixelsOnFavoriteSelected() { + let sut = createSUT() + + sut.favoriteSelected(Favorite(id: "", title: "", domain: "", urlObject: URL(string: "https://foo.bar"))) + + XCTAssertEqual(PixelFiringMock.lastPixel, .favoriteLaunchedNTP) + XCTAssertEqual(PixelFiringMock.lastDailyPixelInfo?.pixel, .favoriteLaunchedNTPDaily) + } + + func testFiresPixelOnFavoriteDeleted() { + let bookmark = createStubBookmark() + favoritesListInteracting.favorites = [bookmark] + + let sut = createSUT() + + sut.deleteFavorite(Favorite(id: bookmark.uuid!, title: "", domain: "")) + + XCTAssertEqual(PixelFiringMock.lastPixel, .homeScreenDeleteFavorite) + } + + func testFiresPixelOnFavoriteEdited() { + let bookmark = createStubBookmark() + favoritesListInteracting.favorites = [bookmark] + + let sut = createSUT() + + sut.editFavorite(Favorite(id: bookmark.uuid!, title: "", domain: "")) + + XCTAssertEqual(PixelFiringMock.lastPixel, .homeScreenEditFavorite) + } + + func testFiresPixelOnTappingPlaceholder() { + let sut = createSUT() + + sut.placeholderTapped() + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageFavoritesPlaceholderTapped) + } + + func testFiresPixelOnShowingTooltip() { + let sut = createSUT() + + XCTAssertFalse(sut.isShowingTooltip) + sut.toggleTooltip() + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageFavoritesInfoTooltip) + } + + private func createStubBookmark() -> BookmarkEntity { + let bookmarksDB = MockBookmarksDatabase.make() + let context = bookmarksDB.makeContext(concurrencyType: .mainQueueConcurrencyType) + let root = BookmarkUtils.fetchRootFolder(context)! + return BookmarkEntity.makeBookmark(title: "foo", url: "", parent: root, context: context) + } + + private func createSUT() -> FavoritesDefaultModel { + FavoritesDefaultModel(interactionModel: favoritesListInteracting, + pixelFiring: PixelFiringMock.self, + dailyPixelFiring: PixelFiringMock.self) + } +} diff --git a/DuckDuckGoTests/NewTabPageModelTests.swift b/DuckDuckGoTests/NewTabPageModelTests.swift index 283cca4dd4..bf71f38f22 100644 --- a/DuckDuckGoTests/NewTabPageModelTests.swift +++ b/DuckDuckGoTests/NewTabPageModelTests.swift @@ -24,6 +24,10 @@ final class NewTabPageModelTests: XCTestCase { let appSettings = AppSettingsMock() + override func tearDown() { + PixelFiringMock.tearDown() + } + func testDoesNotShowIntroIfSettingUndefined() { let sut = NewTabPageModel(appSettings: appSettings) @@ -51,11 +55,35 @@ final class NewTabPageModelTests: XCTestCase { appSettings.newTabPageIntroMessageEnabled = true let sut = NewTabPageModel(appSettings: appSettings) - for i in 1...3 { - sut.increaseIntroMessageCounter() + for _ in 1...3 { + sut.introMessageDisplayed() } XCTAssertTrue(sut.isIntroMessageVisible) // We want to keep the message visible on last occurence XCTAssertEqual(appSettings.newTabPageIntroMessageEnabled, false) } + + func testFiresPixelWhenIntroMessageDismissed() { + let sut = NewTabPageModel(pixelFiring: PixelFiringMock.self) + + sut.dismissIntroMessage() + + XCTAssertEqual(.newTabPageMessageDismissed, PixelFiringMock.lastPixel) + } + + func testFiresPixelWhenIntroMessageDisplayed() { + let sut = NewTabPageModel(pixelFiring: PixelFiringMock.self) + + sut.introMessageDisplayed() + + XCTAssertEqual(.newTabPageMessageDisplayed, PixelFiringMock.lastPixel) + } + + func testFiresPixelOnNewTabPageCustomize() { + let sut = NewTabPageModel(pixelFiring: PixelFiringMock.self) + + sut.customizeNewTabPage() + + XCTAssertEqual(.newTabPageCustomize, PixelFiringMock.lastPixel) + } } diff --git a/DuckDuckGoTests/NewTabPageSectionsSettingsModelTests.swift b/DuckDuckGoTests/NewTabPageSectionsSettingsModelTests.swift new file mode 100644 index 0000000000..4a7fa31fae --- /dev/null +++ b/DuckDuckGoTests/NewTabPageSectionsSettingsModelTests.swift @@ -0,0 +1,71 @@ +// +// NewTabPageSectionsSettingsModelTests.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 DuckDuckGo + +final class NewTabPageSectionsSettingsModelTests: XCTestCase { + + override func tearDown() { + PixelFiringMock.tearDown() + } + + func testFiresPixelWhenItemEnabled() { + let sut = createSUT() + + let setting = sut.itemsSettings.first { setting in + setting.item == .favorites + } + + setting?.isEnabled.wrappedValue = true + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageCustomizeSectionOn("favorites")) + } + + func testFiresPixelWhenItemDisabled() { + let sut = createSUT() + + let setting = sut.itemsSettings.first { setting in + setting.item == .favorites + } + + setting?.isEnabled.wrappedValue = false + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageCustomizeSectionOff("favorites")) + } + + func testFiresPixelWhenItemReordered() { + let sut = createSUT() + + sut.moveItems(from: IndexSet(integer: 0), to: 1) + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageSectionReordered) + } + + private func createSUT() -> NewTabPageSectionsSettingsModel { + let storage = NewTabPageSectionsSettingsStorage( + appSettings: AppSettingsMock(), + keyPath: \.newTabPageSectionsSettings, + defaultOrder: NewTabPageSection.allCases, + defaultEnabledItems: NewTabPageSection.allCases + ) + + return NewTabPageSectionsSettingsModel(storage: storage, pixelFiring: PixelFiringMock.self) + } +} diff --git a/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift b/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift new file mode 100644 index 0000000000..463dc48c07 --- /dev/null +++ b/DuckDuckGoTests/NewTabPageShortcutsSettingsModelTests.swift @@ -0,0 +1,73 @@ +// +// NewTabPageShortcutsSettingsModelTests.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 +import BrowserServicesKit + +@testable import DuckDuckGo + +final class NewTabPageShortcutsSettingsModelTests: XCTestCase { + + override func tearDown() { + PixelFiringMock.tearDown() + } + + func testFiresPixelWhenItemEnabled() throws { + let sut = createSUT() + + let passwordsSettings = try XCTUnwrap(sut.itemsSettings.first { setting in + setting.item == .passwords + }) + + passwordsSettings.isEnabled.wrappedValue = true + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageCustomizeShortcutAdded("passwords")) + } + + func testFiresPixelWhenItemDisabled() throws { + let sut = createSUT() + + let passwordsSettings = try XCTUnwrap(sut.itemsSettings.first { setting in + setting.item == .passwords + }) + + passwordsSettings.isEnabled.wrappedValue = false + + XCTAssertEqual(PixelFiringMock.lastPixel, .newTabPageCustomizeShortcutRemoved("passwords")) + } + + private func createSUT() -> NewTabPageShortcutsSettingsModel { + let storage = NewTabPageShortcutsSettingsStorage( + appSettings: AppSettingsMock(), + keyPath: \.newTabPageShortcutsSettings, + defaultOrder: NewTabPageShortcut.allCases, + defaultEnabledItems: NewTabPageShortcut.allCases + ) + + return NewTabPageShortcutsSettingsModel(storage: storage, + featureFlagger: AlwaysTrueFeatureFlagger(), + pixelFiring: PixelFiringMock.self) + } +} + +private final class AlwaysTrueFeatureFlagger: FeatureFlagger { + func isFeatureOn(forProvider: F) -> Bool where F: FeatureFlagSourceProviding { + true + } +}