diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 8251645953..4fe5ed36af 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1107,6 +1107,10 @@ 372217822B33380700B8E9C2 /* TestUtils in Frameworks */ = {isa = PBXBuildFile; productRef = 372217812B33380700B8E9C2 /* TestUtils */; }; 3723A94F2CE7400D00A0C59A /* NewTabPageRMFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */; }; 3723A9502CE7400D00A0C59A /* NewTabPageRMFClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */; }; + 3724ED8B2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */; }; + 3724ED8C2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */; }; + 3724ED8E2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */; }; + 3724ED8F2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */; }; 37269EFB2B332F9E005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFA2B332F9E005E8E46 /* Common */; }; 37269EFD2B332FAC005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFC2B332FAC005E8E46 /* Common */; }; 37269EFF2B332FBB005E8E46 /* Common in Frameworks */ = {isa = PBXBuildFile; productRef = 37269EFE2B332FBB005E8E46 /* Common */; }; @@ -1248,6 +1252,8 @@ 37BF3F22286F0A7A00BD9014 /* PinnedTabsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BF3F1F286F0A7A00BD9014 /* PinnedTabsView.swift */; }; 37C9F78C2CF1C776004D73A1 /* PrivacyStatsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */; }; 37C9F78D2CF1C776004D73A1 /* PrivacyStatsTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */; }; + 37CB368B2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */; }; + 37CB368C2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */; }; 37CBCA9A2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */; }; 37CBCA9B2A8966E60050218F /* SyncSettingsAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */; }; 37CC53EC27E8A4D10028713D /* PreferencesDataClearingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CC53EB27E8A4D10028713D /* PreferencesDataClearingView.swift */; }; @@ -3664,6 +3670,8 @@ 37219B392CBFD4F300C9D7A8 /* NewTabPageSearchBoxExperiment+Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NewTabPageSearchBoxExperiment+Logger.swift"; sourceTree = ""; }; 37219B3C2CC27DB300C9D7A8 /* NewTabPageSearchBoxExperimentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageSearchBoxExperimentTests.swift; sourceTree = ""; }; 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageRMFClient.swift; sourceTree = ""; }; + 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsProviding.swift; sourceTree = ""; }; + 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsClientTests.swift; sourceTree = ""; }; 372A0FEB2B2379310033BF7F /* SyncMetricsEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMetricsEventsHandler.swift; sourceTree = ""; }; 372BC2A02A4AFA47001D8FD5 /* SyncCredentialsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCredentialsAdapter.swift; sourceTree = ""; }; 372ED7C12CDD4815002287EC /* NewTabPageUserContentController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageUserContentController.swift; sourceTree = ""; }; @@ -3760,6 +3768,7 @@ 37BF3F1E286F0A7A00BD9014 /* PinnedTabsViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedTabsViewModel.swift; sourceTree = ""; }; 37BF3F1F286F0A7A00BD9014 /* PinnedTabsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PinnedTabsView.swift; sourceTree = ""; }; 37C9F78B2CF1C770004D73A1 /* PrivacyStatsTabExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyStatsTabExtension.swift; sourceTree = ""; }; + 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewTabPageNextStepsCardsClient.swift; sourceTree = ""; }; 37CBCA992A8966E60050218F /* SyncSettingsAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSettingsAdapter.swift; sourceTree = ""; }; 37CC53EB27E8A4D10028713D /* PreferencesDataClearingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataClearingView.swift; sourceTree = ""; }; 37CC53F327E8D4620028713D /* NSPathControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSPathControlView.swift; sourceTree = ""; }; @@ -5855,6 +5864,7 @@ children = ( 37F8E2322CEE363D002F0141 /* NewTabPageContextMenuPresenting.swift */, 3723A94E2CE73FDD00A0C59A /* NewTabPageRMFClient.swift */, + 37CB36892CFA5ADA00E5E5FA /* NextStepsCards */, 37F1E32D2CEF2DDD00130142 /* Favorites */, 37DF370B2CF3CEF5005ED34B /* PrivacyStats */, 379B5AEB2CEA26C600B9F5D7 /* NewTabPageConfigurationClient.swift */, @@ -5873,6 +5883,7 @@ 3767880E2CECD5A200F59D83 /* NewTabPageUserScriptTests.swift */, 3767880B2CECCB6C00F59D83 /* NewTabPageActionsManagerTests.swift */, 376788112CECF03000F59D83 /* NewTabPageRMFClientTests.swift */, + 3724ED8D2CFE6B330043626A /* NewTabPageNextStepsCardsClientTests.swift */, 37F1E32E2CEF4A2C00130142 /* NewTabPageFavoritesClientTests.swift */, 378D62562CEF801A0056BBD8 /* NewTabPageFavoritesModelTests.swift */, 37FC2A1A2CF903A70048E226 /* NewTabPagePrivacyStatsClientTests.swift */, @@ -6037,6 +6048,15 @@ path = PrivacyStats; sourceTree = ""; }; + 37CB36892CFA5ADA00E5E5FA /* NextStepsCards */ = { + isa = PBXGroup; + children = ( + 3724ED8A2CFE6A120043626A /* NewTabPageNextStepsCardsProviding.swift */, + 37CB368A2CFA5AEC00E5E5FA /* NewTabPageNextStepsCardsClient.swift */, + ); + path = NextStepsCards; + sourceTree = ""; + }; 37CD54C027F2FDD100F1F7B9 /* Model */ = { isa = PBXGroup; children = ( @@ -11698,6 +11718,7 @@ 3706FB80293F65D500E42796 /* NSAlert+ActiveDownloadsTermination.swift in Sources */, B677FC552B064A9C0099EB04 /* DataImportViewModel.swift in Sources */, D64A5FF92AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, + 3724ED8B2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */, 4BE3A6C22C16BEB1003FC378 /* VPNRedditSessionWorkaround.swift in Sources */, 3707C717294B5D0F00682A9F /* FindInPageTabExtension.swift in Sources */, 3706FB81293F65D500E42796 /* IndexPathExtension.swift in Sources */, @@ -12158,6 +12179,7 @@ 3706FC77293F65D500E42796 /* PageObserverUserScript.swift in Sources */, 4BF0E5132AD25A2600FFEC9E /* DuckDuckGoUserAgent.swift in Sources */, 3706FC78293F65D500E42796 /* SecureVaultReporter.swift in Sources */, + 37CB368C2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */, 3706FC79293F65D500E42796 /* NSImageExtensions.swift in Sources */, 3706FEBD293F6EFF00E42796 /* BWCommand.swift in Sources */, C172E7302C9329D300521D9A /* FlippedView.swift in Sources */, @@ -12585,6 +12607,7 @@ CD33012A2C887B1C009AA127 /* URLTokenValidatorTests.swift in Sources */, 9F0660742BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, 9FBD847B2BB3EC3300220859 /* MockAttributionOriginProvider.swift in Sources */, + 3724ED8F2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */, 56A214B02CB583BF00E5BC0E /* TrackerMessageProviderTests.swift in Sources */, 3706FE82293F661700E42796 /* MockStatisticsStore.swift in Sources */, 9FBD84712BB3DD8400220859 /* MockAttributionsPixelHandler.swift in Sources */, @@ -13072,6 +13095,7 @@ B62B483E2ADE48DE000DECE5 /* ArrayBuilder.swift in Sources */, 4B92929B26670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift in Sources */, 56D145EB29E6C99B00E3488A /* DataImportStatusProviding.swift in Sources */, + 37CB368B2CFA5AF100E5E5FA /* NewTabPageNextStepsCardsClient.swift in Sources */, 843965122C6F2FFE004C8899 /* NSDragOperationExtension.swift in Sources */, 31D5375C291D944100407A95 /* PasswordManagementBitwardenItemView.swift in Sources */, 1D9297BF2C1B062900A38521 /* ApplicationUpdateDetector.swift in Sources */, @@ -13089,6 +13113,7 @@ B6E3E5542BBFCEE300A41922 /* NoDownloadsCellView.swift in Sources */, 3768D8382C24BFF5004120AE /* RemoteMessageView.swift in Sources */, 4BBDEE9428FC14760092FAA6 /* ConnectBitwardenViewController.swift in Sources */, + 3724ED8C2CFE6A290043626A /* NewTabPageNextStepsCardsProviding.swift in Sources */, 1DDF076428F815AD00EDFBE3 /* BWManager.swift in Sources */, 9833912F27AAA3CE00DAF119 /* AppTrackerDataSetProvider.swift in Sources */, B65211252B29A42C00B30633 /* BookmarkStoreMock.swift in Sources */, @@ -14105,6 +14130,7 @@ B6106BAF26A7C6180013B453 /* PermissionStoreMock.swift in Sources */, 4B98D27A28D95F1A003C2B6F /* ChromiumFaviconsReaderTests.swift in Sources */, 9F0660732BECC71200B8EEF1 /* SubscriptionAttributionPixelHandlerTests.swift in Sources */, + 3724ED8E2CFE6B3F0043626A /* NewTabPageNextStepsCardsClientTests.swift in Sources */, BDCB66D82C7CE1A600E8ABC9 /* VPNFeedbackFormViewModelTests.swift in Sources */, 567A23E12C89B1EE0010F66C /* BrowserTabViewControllerOnboardingTests.swift in Sources */, 986189E62A7CFB3E001B4519 /* LocalBookmarkStoreSavingTests.swift in Sources */, diff --git a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift index c457c86b13..47c5ba9665 100644 --- a/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift +++ b/DuckDuckGo/HomePage/Model/HomePageContinueSetUpModel.swift @@ -18,6 +18,7 @@ import AppKit import BrowserServicesKit +import Combine import Common import Foundation import PixelKit @@ -26,6 +27,20 @@ import Subscription import NetworkProtection import NetworkProtectionUI +protocol ContinueSetUpModelTabOpening { + @MainActor + func openTab(_ tab: Tab) +} + +struct TabCollectionViewModelTabOpener: ContinueSetUpModelTabOpening { + let tabCollectionViewModel: TabCollectionViewModel + + @MainActor + func openTab(_ tab: Tab) { + tabCollectionViewModel.insertOrAppend(tab: tab, selected: true) + } +} + extension HomePage.Models { static let newHomePageTabOpen = Notification.Name("newHomePageAppOpen") @@ -49,7 +64,7 @@ extension HomePage.Models { private let defaultBrowserProvider: DefaultBrowserProvider private let dockCustomizer: DockCustomization private let dataImportProvider: DataImportStatusProviding - private let tabCollectionViewModel: TabCollectionViewModel + private let tabOpener: ContinueSetUpModelTabOpening private let emailManager: EmailManager private let duckPlayerPreferences: DuckPlayerPreferencesPersistor private let subscriptionManager: SubscriptionManager @@ -58,9 +73,13 @@ extension HomePage.Models { var shouldShowAllFeatures: Bool { didSet { updateVisibleMatrix() + shouldShowAllFeaturesSubject.send(shouldShowAllFeatures) } } + let shouldShowAllFeaturesPublisher: AnyPublisher + private let shouldShowAllFeaturesSubject = PassthroughSubject() + struct Settings { @UserDefaultsWrapper(key: .homePageShowMakeDefault, defaultValue: true) var shouldShowMakeDefaultSetting: Bool @@ -102,7 +121,7 @@ extension HomePage.Models { lazy var listOfFeatures = settings.isFirstSession ? firstRunFeatures : randomisedFeatures - private var featuresMatrix: [[FeatureType]] = [[]] { + @Published var featuresMatrix: [[FeatureType]] = [[]] { didSet { updateVisibleMatrix() } @@ -110,28 +129,35 @@ extension HomePage.Models { @Published var visibleFeaturesMatrix: [[FeatureType]] = [[]] - init(defaultBrowserProvider: DefaultBrowserProvider, - dockCustomizer: DockCustomization, - dataImportProvider: DataImportStatusProviding, - tabCollectionViewModel: TabCollectionViewModel, + init(defaultBrowserProvider: DefaultBrowserProvider = SystemDefaultBrowserProvider(), + dockCustomizer: DockCustomization = DockCustomizer(), + dataImportProvider: DataImportStatusProviding = BookmarksAndPasswordsImportStatusProvider(), + tabOpener: ContinueSetUpModelTabOpening, emailManager: EmailManager = EmailManager(), - duckPlayerPreferences: DuckPlayerPreferencesPersistor, + duckPlayerPreferences: DuckPlayerPreferencesPersistor = DuckPlayerPreferencesUserDefaultsPersistor(), privacyConfigurationManager: PrivacyConfigurationManaging = AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager, subscriptionManager: SubscriptionManager = Application.appDelegate.subscriptionManager) { + self.defaultBrowserProvider = defaultBrowserProvider self.dockCustomizer = dockCustomizer self.dataImportProvider = dataImportProvider - self.tabCollectionViewModel = tabCollectionViewModel + self.tabOpener = tabOpener self.emailManager = emailManager self.duckPlayerPreferences = duckPlayerPreferences self.privacyConfigurationManager = privacyConfigurationManager self.subscriptionManager = subscriptionManager self.settings = .init() + shouldShowAllFeaturesPublisher = shouldShowAllFeaturesSubject.removeDuplicates().eraseToAnyPublisher() + refreshFeaturesMatrix() NotificationCenter.default.addObserver(self, selector: #selector(newTabOpenNotification(_:)), name: HomePage.Models.newHomePageTabOpen, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeKey(_:)), name: NSWindow.didBecomeKeyNotification, object: nil) + + // HTML NTP doesn't refresh on appear so we have to connect to the appear signal + // (the notification in this case) to trigger a refresh. + NotificationCenter.default.addObserver(self, selector: #selector(refreshFeaturesForHTMLNewTabPage(_:)), name: .newTabPageWebViewDidAppear, object: nil) } @MainActor func performAction(for featureType: FeatureType) { @@ -166,14 +192,14 @@ extension HomePage.Models { private func performDuckPlayerAction() { if let videoUrl = URL(string: duckPlayerURL) { let tab = Tab(content: .url(videoUrl, source: .link), shouldLoadInBackground: true) - tabCollectionViewModel.append(tab: tab) + tabOpener.openTab(tab) } } @MainActor private func performEmailProtectionAction() { let tab = Tab(content: .url(EmailUrls().emailProtectionLink, source: .ui), shouldLoadInBackground: true) - tabCollectionViewModel.append(tab: tab) + tabOpener.openTab(tab) } func performDockAction() { @@ -243,6 +269,14 @@ extension HomePage.Models { } @objc private func windowDidBecomeKey(_ notification: Notification) { + // Async dispatch allows default browser setting to propagate + // after being changed in the system dialog + DispatchQueue.main.async { + self.refreshFeaturesMatrix() + } + } + + @objc private func refreshFeaturesForHTMLNewTabPage(_ notification: Notification) { refreshFeaturesMatrix() } diff --git a/DuckDuckGo/HomePage/View/ContinueSetUpView.swift b/DuckDuckGo/HomePage/View/ContinueSetUpView.swift index 33fa94a8f1..3c48f090b6 100644 --- a/DuckDuckGo/HomePage/View/ContinueSetUpView.swift +++ b/DuckDuckGo/HomePage/View/ContinueSetUpView.swift @@ -292,7 +292,7 @@ extension HomePage.Views { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) } diff --git a/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift b/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift index 2ea65f9003..cf1086c16a 100644 --- a/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift +++ b/DuckDuckGo/HomePage/View/HomePageSettings/HomePageSettingsView.swift @@ -247,7 +247,7 @@ extension HomePage.Views.BackgroundCategoryView { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) .environmentObject(HomePage.Models.FavoritesModel( @@ -276,7 +276,7 @@ extension HomePage.Views.BackgroundCategoryView { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() )) .environmentObject(HomePage.Models.FavoritesModel( diff --git a/DuckDuckGo/HomePage/View/HomePageViewController.swift b/DuckDuckGo/HomePage/View/HomePageViewController.swift index 04635afce2..7651f0b5a5 100644 --- a/DuckDuckGo/HomePage/View/HomePageViewController.swift +++ b/DuckDuckGo/HomePage/View/HomePageViewController.swift @@ -171,7 +171,7 @@ final class HomePageViewController: NSViewController { defaultBrowserProvider: SystemDefaultBrowserProvider(), dockCustomizer: DockCustomizer(), dataImportProvider: BookmarksAndPasswordsImportStatusProvider(), - tabCollectionViewModel: tabCollectionViewModel, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionViewModel), duckPlayerPreferences: DuckPlayerPreferencesUserDefaultsPersistor() ) } diff --git a/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift b/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift index 90fd7de527..474eda2e8b 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageActionsManager.swift @@ -130,8 +130,16 @@ extension NewTabPageActionsManager { self.init(scriptClients: [ NewTabPageConfigurationClient(appearancePreferences: appearancePreferences), NewTabPageRMFClient(remoteMessageProvider: activeRemoteMessageModel, openURLHandler: openURLHandler), + NewTabPageNextStepsCardsClient(model: HomePage.Models.ContinueSetUpModel(tabOpener: NewTabPageTabOpener())), NewTabPageFavoritesClient(favoritesModel: NewTabPageFavoritesModel()), NewTabPagePrivacyStatsClient(model: privacyStatsModel) ]) } } + +struct NewTabPageTabOpener: ContinueSetUpModelTabOpening { + @MainActor + func openTab(_ tab: Tab) { + WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel.insertOrAppend(tab: tab, selected: true) + } +} diff --git a/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift b/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift index 8eb5c7b7d8..41f88e5f84 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageConfigurationClient.swift @@ -131,6 +131,7 @@ final class NewTabPageConfigurationClient: NewTabPageScriptClient { return NewTabPageUserScript.NewTabPageConfiguration( widgets: [ .init(id: .rmf), + .init(id: .nextSteps), .init(id: .favorites), .init(id: .privacyStats) ], @@ -174,7 +175,7 @@ final class NewTabPageConfigurationClient: NewTabPageScriptClient { extension NewTabPageUserScript { enum WidgetId: String, Codable { - case rmf, favorites, privacyStats + case rmf, nextSteps, favorites, privacyStats } struct ContextMenuParams: Codable { diff --git a/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift b/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift index 73f368ca0a..0d3fcb3294 100644 --- a/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift +++ b/DuckDuckGo/NewTabPage/NewTabPageWebViewModel.swift @@ -52,7 +52,12 @@ final class NewTabPageWebViewModel: NSObject { windowCancellable = webView.publisher(for: \.window) .map { $0 != nil } - .assign(to: \.isViewOnScreen, on: activeRemoteMessageModel) + .sink { [weak activeRemoteMessageModel] isOnScreen in + activeRemoteMessageModel?.isViewOnScreen = isOnScreen + if isOnScreen { + NotificationCenter.default.post(name: .newTabPageWebViewDidAppear, object: nil) + } + } } } @@ -61,3 +66,7 @@ extension NewTabPageWebViewModel: WKNavigationDelegate { navigationAction.request.url == .newtab ? .allow : .cancel } } + +extension Notification.Name { + static var newTabPageWebViewDidAppear = Notification.Name("newTabPageWebViewDidAppear") +} diff --git a/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift new file mode 100644 index 0000000000..f377d1dbde --- /dev/null +++ b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsClient.swift @@ -0,0 +1,197 @@ +// +// NewTabPageNextStepsCardsClient.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 Common +import Combine +import UserScript + +final class NewTabPageNextStepsCardsClient: NewTabPageScriptClient { + + let model: NewTabPageNextStepsCardsProviding + let willDisplayCardsPublisher: AnyPublisher<[CardID], Never> + weak var userScriptsSource: NewTabPageUserScriptsSource? + + private let willDisplayCardsSubject = PassthroughSubject<[CardID], Never>() + private let getDataSubject = PassthroughSubject<[CardID], Never>() + private let getConfigSubject = PassthroughSubject() + private let notifyDataUpdatedSubject = PassthroughSubject<[CardID], Never>() + private let notifyConfigUpdatedSubject = PassthroughSubject() + private var cancellables: Set = [] + + init(model: NewTabPageNextStepsCardsProviding) { + self.model = model + willDisplayCardsPublisher = willDisplayCardsSubject.eraseToAnyPublisher() + connectWillDisplayCardsPublisher() + + model.cardsPublisher + .sink { [weak self] cardIDs in + Task { @MainActor in + self?.notifyDataUpdated(cardIDs) + } + } + .store(in: &cancellables) + + model.isViewExpandedPublisher + .sink { [weak self] showAllCards in + Task { @MainActor in + self?.notifyConfigUpdated(showAllCards) + } + } + .store(in: &cancellables) + + willDisplayCardsPublisher + .sink { cards in + model.willDisplayCards(cards) + } + .store(in: &cancellables) + } + + private func connectWillDisplayCardsPublisher() { + let initialCards = Publishers.CombineLatest(getDataSubject, getConfigSubject) + .map { cards, isViewExpanded in + isViewExpanded ? cards : Array(cards.prefix(2)) + } + .share() + + let firstInitialCards = initialCards.first() + + // only notify about visible cards (i.e. if collapsed, only the first 2) + let cardsOnDataUpdated = notifyDataUpdatedSubject + .drop(untilOutputFrom: firstInitialCards) + .map { [weak self] cards in + self?.model.isViewExpanded == true ? cards: Array(cards.prefix(2)) + } + + // only notify about cards revealed by expanding the view (i.e. other than the first 2) + let cardsOnConfigUpdated = notifyConfigUpdatedSubject + .drop(untilOutputFrom: firstInitialCards) + .compactMap { [weak self] isViewExpanded -> [CardID]? in + guard let self, isViewExpanded, model.cards.count > 2 else { + return nil + } + return Array(self.model.cards.suffix(from: 2)) + } + + Publishers.Merge3(initialCards, cardsOnDataUpdated, cardsOnConfigUpdated) + .filter { !$0.isEmpty } + .sink { [weak self] cards in + self?.willDisplayCardsSubject.send(cards) + } + .store(in: &cancellables) + } + + enum MessageName: String, CaseIterable { + case action = "nextSteps_action" + case dismiss = "nextSteps_dismiss" + case getConfig = "nextSteps_getConfig" + case getData = "nextSteps_getData" + case onConfigUpdate = "nextSteps_onConfigUpdate" + case onDataUpdate = "nextSteps_onDataUpdate" + case setConfig = "nextSteps_setConfig" + } + + func registerMessageHandlers(for userScript: any SubfeatureWithExternalMessageHandling) { + userScript.registerMessageHandlers([ + MessageName.action.rawValue: { [weak self] in try await self?.action(params: $0, original: $1) }, + MessageName.dismiss.rawValue: { [weak self] in try await self?.dismiss(params: $0, original: $1) }, + MessageName.getConfig.rawValue: { [weak self] in try await self?.getConfig(params: $0, original: $1) }, + MessageName.getData.rawValue: { [weak self] in try await self?.getData(params: $0, original: $1) }, + MessageName.setConfig.rawValue: { [weak self] in try await self?.setConfig(params: $0, original: $1) } + ]) + } + + @MainActor + func action(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let card: NewTabPageNextStepsCardsClient.Card = DecodableHelper.decode(from: params) else { + return nil + } + model.handleAction(for: card.id) + return nil + } + + @MainActor + func dismiss(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let card: Card = DecodableHelper.decode(from: params) else { + return nil + } + model.dismiss(card.id) + return nil + } + + func getConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let expansion: NewTabPageUserScript.WidgetConfig.Expansion = model.isViewExpanded ? .expanded : .collapsed + + getConfigSubject.send(model.isViewExpanded) + return NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: expansion) + } + + @MainActor + func setConfig(params: Any, original: WKScriptMessage) async throws -> Encodable? { + guard let config: NewTabPageUserScript.WidgetConfig = DecodableHelper.decode(from: params) else { + return nil + } + model.isViewExpanded = config.expansion == .expanded + return nil + } + + @MainActor + func getData(params: Any, original: WKScriptMessage) async throws -> Encodable? { + let cardIDs = model.cards + let cards = cardIDs.map(Card.init(id:)) + + getDataSubject.send(cardIDs) + return NextStepsData(content: cards.isEmpty ? nil : cards) + } + + @MainActor + private func notifyDataUpdated(_ cardIDs: [CardID]) { + let cards = cardIDs.map(Card.init(id:)) + let params = NextStepsData(content: cards.isEmpty ? nil : cards) + + notifyDataUpdatedSubject.send(cardIDs) + pushMessage(named: MessageName.onDataUpdate.rawValue, params: params) + } + + @MainActor + private func notifyConfigUpdated(_ showAllCards: Bool) { + let expansion: NewTabPageUserScript.WidgetConfig.Expansion = showAllCards ? .expanded : .collapsed + let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: expansion) + + notifyConfigUpdatedSubject.send(showAllCards) + pushMessage(named: MessageName.onConfigUpdate.rawValue, params: config) + } +} + +extension NewTabPageNextStepsCardsClient { + + enum CardID: String, Codable { + case bringStuff + case defaultApp + case emailProtection + case duckplayer + case addAppToDockMac + } + + struct Card: Codable, Equatable { + let id: CardID + } + + struct NextStepsData: Codable, Equatable { + let content: [Card]? + } +} diff --git a/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift new file mode 100644 index 0000000000..d508f01d2c --- /dev/null +++ b/DuckDuckGo/NewTabPage/NextStepsCards/NewTabPageNextStepsCardsProviding.swift @@ -0,0 +1,118 @@ +// +// NewTabPageNextStepsCardsProviding.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 Common +import Combine +import UserScript +import PixelKit + +protocol NewTabPageNextStepsCardsProviding: AnyObject { + var isViewExpanded: Bool { get set } + var isViewExpandedPublisher: AnyPublisher { get } + + var cards: [NewTabPageNextStepsCardsClient.CardID] { get } + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { get } + + @MainActor + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) + + @MainActor + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) +} + +extension HomePage.Models.ContinueSetUpModel: NewTabPageNextStepsCardsProviding { + var isViewExpanded: Bool { + get { + shouldShowAllFeatures + } + set { + shouldShowAllFeatures = newValue + } + } + + var isViewExpandedPublisher: AnyPublisher { + shouldShowAllFeaturesPublisher.eraseToAnyPublisher() + } + + var cards: [NewTabPageNextStepsCardsClient.CardID] { + featuresMatrix.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + } + + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + $featuresMatrix.dropFirst().removeDuplicates() + .map { matrix in + matrix.flatMap { $0.map(NewTabPageNextStepsCardsClient.CardID.init) } + } + .eraseToAnyPublisher() + } + + @MainActor + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + performAction(for: .init(card)) + } + + @MainActor + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + removeItem(for: .init(card)) + } + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + guard cards.contains(.addAppToDockMac) else { + return + } + PixelKit.fire(GeneralPixel.addToDockNewTabPageCardPresented, + frequency: .unique, + includeAppVersionParameter: false) + } +} + +extension HomePage.Models.FeatureType { + init(_ card: NewTabPageNextStepsCardsClient.CardID) { + switch card { + case .bringStuff: + self = .importBookmarksAndPasswords + case .defaultApp: + self = .defaultBrowser + case .emailProtection: + self = .emailProtection + case .duckplayer: + self = .duckplayer + case .addAppToDockMac: + self = .dock + } + } +} + +extension NewTabPageNextStepsCardsClient.CardID { + init(_ feature: HomePage.Models.FeatureType) { + switch feature { + case .duckplayer: + self = .duckplayer + case .emailProtection: + self = .emailProtection + case .defaultBrowser: + self = .defaultApp + case .dock: + self = .addAppToDockMac + case .importBookmarksAndPasswords: + self = .bringStuff + } + } +} diff --git a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift index 43fc227272..8f87ad4e1c 100644 --- a/DuckDuckGo/Preferences/Model/AppearancePreferences.swift +++ b/DuckDuckGo/Preferences/Model/AppearancePreferences.swift @@ -16,10 +16,12 @@ // limitations under the License. // -import Foundation import AppKit import Bookmarks +import BrowserServicesKit import Common +import FeatureFlags +import Foundation import PixelKit import os.log @@ -165,7 +167,7 @@ enum ThemeName: String, Equatable, CaseIterable { } } -extension FavoritesDisplayMode: LosslessStringConvertible { +extension FavoritesDisplayMode: @retroactive LosslessStringConvertible { static let `default` = FavoritesDisplayMode.displayNative(.desktop) public init?(_ description: String) { @@ -232,6 +234,11 @@ final class AppearancePreferences: ObservableObject { } } + var isContinueSetUpCardsVisibilityControlAvailable: Bool { + // HTML NTP doesn't allow for hiding Next Steps Cards section + !featureFlagger().isFeatureOn(.htmlNewTabPage) + } + var isContinueSetUpVisible: Bool { get { return persistor.isContinueSetUpVisible && !persistor.continueSetUpCardsClosed && !isContinueSetUpCardsViewOutdated @@ -337,12 +344,14 @@ final class AppearancePreferences: ObservableObject { init( persistor: AppearancePreferencesPersistor = AppearancePreferencesUserDefaultsPersistor(), homePageNavigator: HomePageNavigator = DefaultHomePageNavigator(), + featureFlagger: @autoclosure @escaping () -> FeatureFlagger = NSApp.delegateTyped.featureFlagger, dateTimeProvider: @escaping () -> Date = Date.init ) { self.persistor = persistor self.homePageNavigator = homePageNavigator self.dateTimeProvider = dateTimeProvider self.isContinueSetUpCardsViewOutdated = persistor.continueSetUpCardsNumberOfDaysDemonstrated >= Constants.dismissNextStepsCardsAfterDays + self.featureFlagger = featureFlagger self.continueSetUpCardsClosed = persistor.continueSetUpCardsClosed currentThemeName = .init(rawValue: persistor.currentThemeName) ?? .systemDefault showFullURL = persistor.showFullURL @@ -359,6 +368,7 @@ final class AppearancePreferences: ObservableObject { private var persistor: AppearancePreferencesPersistor private var homePageNavigator: HomePageNavigator + private let featureFlagger: () -> FeatureFlagger private let dateTimeProvider: () -> Date private func requestSync() { diff --git a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift index 606e3955ab..d8e77d151b 100644 --- a/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift +++ b/DuckDuckGo/Preferences/View/PreferencesAppearanceView.swift @@ -108,7 +108,7 @@ extension Preferences { if addressBarModel.shouldShowAddressBar { ToggleMenuItem(UserText.newTabSearchBarSectionTitle, isOn: $model.isSearchBarVisible) } - if model.isContinueSetUpAvailable && !model.isContinueSetUpCardsViewOutdated && !model.continueSetUpCardsClosed { + if model.isContinueSetUpCardsVisibilityControlAvailable && model.isContinueSetUpAvailable && !model.isContinueSetUpCardsViewOutdated && !model.continueSetUpCardsClosed { ToggleMenuItem(UserText.newTabSetUpSectionTitle, isOn: $model.isContinueSetUpVisible) } ToggleMenuItem(UserText.newTabFavoriteSectionTitle, isOn: $model.isFavoriteVisible).accessibilityIdentifier("Preferences.AppearanceView.showFavoritesToggle") diff --git a/UnitTests/HomePage/ContinueSetUpModelTests.swift b/UnitTests/HomePage/ContinueSetUpModelTests.swift index 8e9393accd..cc3ff92925 100644 --- a/UnitTests/HomePage/ContinueSetUpModelTests.swift +++ b/UnitTests/HomePage/ContinueSetUpModelTests.swift @@ -53,7 +53,7 @@ final class ContinueSetUpModelTests: XCTestCase { defaultBrowserProvider: capturingDefaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: capturingDataImportProvider, - tabCollectionViewModel: tabCollectionVM, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionVM), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences, privacyConfigurationManager: privacyConfigManager @@ -95,7 +95,7 @@ final class ContinueSetUpModelTests: XCTestCase { defaultBrowserProvider: capturingDefaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: capturingDataImportProvider, - tabCollectionViewModel: tabCollectionVM, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionVM), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences ) @@ -111,7 +111,7 @@ final class ContinueSetUpModelTests: XCTestCase { defaultBrowserProvider: capturingDefaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: capturingDataImportProvider, - tabCollectionViewModel: tabCollectionVM, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionVM), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences ) @@ -315,7 +315,7 @@ final class ContinueSetUpModelTests: XCTestCase { defaultBrowserProvider: capturingDefaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: capturingDataImportProvider, - tabCollectionViewModel: tabCollectionVM, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionVM), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences ) @@ -330,7 +330,7 @@ final class ContinueSetUpModelTests: XCTestCase { defaultBrowserProvider: capturingDefaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: capturingDataImportProvider, - tabCollectionViewModel: tabCollectionVM, + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: tabCollectionVM), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences ) @@ -427,7 +427,7 @@ extension HomePage.Models.ContinueSetUpModel { defaultBrowserProvider: defaultBrowserProvider, dockCustomizer: dockCustomizer, dataImportProvider: dataImportProvider, - tabCollectionViewModel: TabCollectionViewModel(), + tabOpener: TabCollectionViewModelTabOpener(tabCollectionViewModel: TabCollectionViewModel()), emailManager: emailManager, duckPlayerPreferences: duckPlayerPreferences, privacyConfigurationManager: manager) diff --git a/UnitTests/NewTabPage/NewTabPageConfigurationClientTests.swift b/UnitTests/NewTabPage/NewTabPageConfigurationClientTests.swift index 9b115de48b..3659c80511 100644 --- a/UnitTests/NewTabPage/NewTabPageConfigurationClientTests.swift +++ b/UnitTests/NewTabPage/NewTabPageConfigurationClientTests.swift @@ -88,6 +88,7 @@ final class NewTabPageConfigurationClientTests: XCTestCase { let configuration: NewTabPageUserScript.NewTabPageConfiguration = try await sendMessage(named: .initialSetup) XCTAssertEqual(configuration.widgets, [ .init(id: .rmf), + .init(id: .nextSteps), .init(id: .favorites), .init(id: .privacyStats) ]) diff --git a/UnitTests/NewTabPage/NewTabPageNextStepsCardsClientTests.swift b/UnitTests/NewTabPage/NewTabPageNextStepsCardsClientTests.swift new file mode 100644 index 0000000000..a8ee7acec4 --- /dev/null +++ b/UnitTests/NewTabPage/NewTabPageNextStepsCardsClientTests.swift @@ -0,0 +1,294 @@ +// +// NewTabPageNextStepsCardsClientTests.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 Combine +import TestUtils +import XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class CapturingNewTabPageNextStepsCardsProvider: NewTabPageNextStepsCardsProviding { + + @Published var isViewExpanded: Bool = false + var isViewExpandedPublisher: AnyPublisher { + $isViewExpanded.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + @Published var cards: [NewTabPageNextStepsCardsClient.CardID] = [] + var cardsPublisher: AnyPublisher<[NewTabPageNextStepsCardsClient.CardID], Never> { + $cards.dropFirst().removeDuplicates().eraseToAnyPublisher() + } + + func handleAction(for card: NewTabPageNextStepsCardsClient.CardID) { + handleActionCalls.append(card) + } + + func dismiss(_ card: NewTabPageNextStepsCardsClient.CardID) { + dismissCalls.append(card) + } + + func willDisplayCards(_ cards: [NewTabPageNextStepsCardsClient.CardID]) { + willDisplayCardsCalls.append(cards) + willDisplayCardsImpl?(cards) + } + + var handleActionCalls: [NewTabPageNextStepsCardsClient.CardID] = [] + var dismissCalls: [NewTabPageNextStepsCardsClient.CardID] = [] + var willDisplayCardsCalls: [[NewTabPageNextStepsCardsClient.CardID]] = [] + var willDisplayCardsImpl: (([NewTabPageNextStepsCardsClient.CardID]) -> Void)? +} + +final class NewTabPageNextStepsCardsClientTests: XCTestCase { + var client: NewTabPageNextStepsCardsClient! + var model: CapturingNewTabPageNextStepsCardsProvider! + var userScript: NewTabPageUserScript! + + @MainActor + override func setUpWithError() throws { + try super.setUpWithError() + model = CapturingNewTabPageNextStepsCardsProvider() + client = NewTabPageNextStepsCardsClient(model: model) + + userScript = NewTabPageUserScript() + client.registerMessageHandlers(for: userScript) + } + + // MARK: - action + + func testThatActionCallsHandleAction() async throws { + try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .defaultApp)) + try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .duckplayer)) + try await handleMessageExpectingNilResponse(named: .action, parameters: NewTabPageNextStepsCardsClient.Card(id: .bringStuff)) + XCTAssertEqual(model.handleActionCalls, [.defaultApp, .duckplayer, .bringStuff]) + } + + // MARK: - dismiss + + func testThatDismissCallsDismissHandler() async throws { + try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .defaultApp)) + try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .duckplayer)) + try await handleMessageExpectingNilResponse(named: .dismiss, parameters: NewTabPageNextStepsCardsClient.Card(id: .bringStuff)) + XCTAssertEqual(model.dismissCalls, [.defaultApp, .duckplayer, .bringStuff]) + } + + // MARK: - getConfig + + func testWhenNextStepsViewIsExpandedThenGetConfigReturnsExpandedState() async throws { + model.isViewExpanded = true + let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + XCTAssertEqual(config.animation, .auto) + XCTAssertEqual(config.expansion, .expanded) + } + + func testWhenNextStepsViewIsCollapsedThenGetConfigReturnsCollapsedState() async throws { + model.isViewExpanded = false + let config: NewTabPageUserScript.WidgetConfig = try await handleMessage(named: .getConfig) + XCTAssertEqual(config.animation, .auto) + XCTAssertEqual(config.expansion, .collapsed) + } + + // MARK: - setConfig + + func testWhenSetConfigContainsExpandedStateThenModelSettingIsSetToExpanded() async throws { + model.isViewExpanded = false + let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .expanded) + try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + XCTAssertEqual(model.isViewExpanded, true) + } + + func testWhenSetConfigContainsCollapsedStateThenModelSettingIsSetToCollapsed() async throws { + model.isViewExpanded = true + let config = NewTabPageUserScript.WidgetConfig(animation: .auto, expansion: .collapsed) + try await handleMessageExpectingNilResponse(named: .setConfig, parameters: config) + XCTAssertEqual(model.isViewExpanded, false) + } + + // MARK: - getData + + func testThatGetDataReturnsCardsFromTheModel() async throws { + model.cards = [ + .addAppToDockMac, + .duckplayer, + .bringStuff + ] + let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + XCTAssertEqual(data, .init(content: [ + .init(id: .addAppToDockMac), + .init(id: .duckplayer), + .init(id: .bringStuff) + ])) + } + + func testWhenCardsAreEmptyThenGetDataReturnsNilContent() async throws { + model.cards = [] + let data: NewTabPageNextStepsCardsClient.NextStepsData = try await handleMessage(named: .getData) + XCTAssertEqual(data, .init(content: nil)) + } + + // MARK: - willDisplayCardsPublisher + + func testThatWillDisplayCardsPublisherIsSentAfterGetDataAndGetConfigAreCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsNotSentBeforeGetConfigIsCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, []) + + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsNotSentBeforeGetDataIsCalled() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getConfig(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, []) + + _ = try await client.getData(params: [], original: .init()) + + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer]]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterUpdatingCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = true + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [[.addAppToDockMac, .duckplayer, .bringStuff]]) + } + + func testWhenCardsAreUpdatedThenWillDisplayCardsEventOnlyContainsCurrentlyVisibleCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.expectedFulfillmentCount = 3 + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + model.cards = [.duckplayer, .addAppToDockMac, .bringStuff] + model.cards = [.addAppToDockMac, .emailProtection, .duckplayer] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [ + [.addAppToDockMac, .duckplayer], + [.duckplayer, .addAppToDockMac], + [.addAppToDockMac, .emailProtection] + ]) + } + + func testThatWillDisplayCardsEventIsNotPublishedWhenCardsIsEmpty() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.expectedFulfillmentCount = 2 + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.cards = [.addAppToDockMac, .duckplayer, .bringStuff] + model.cards = [] + model.cards = [.addAppToDockMac, .emailProtection, .duckplayer] + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [ + [.addAppToDockMac, .duckplayer], + [.addAppToDockMac, .emailProtection] + ]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterExpandingViewToRevealMoreCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer, .emailProtection, .bringStuff, .defaultApp] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = true + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, [[.emailProtection, .bringStuff, .defaultApp]]) + } + + func testThatWillDisplayCardsPublisherIsSentAfterExpandingViewAndNotRevealingMoreCards() async throws { + model.cards = [.addAppToDockMac, .duckplayer] + model.isViewExpanded = false + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.isInverted = true + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = true + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, []) + } + + func testThatWillDisplayCardsPublisherIsNotSentAfterCollapsingView() async throws { + model.cards = [.addAppToDockMac, .duckplayer, .emailProtection] + model.isViewExpanded = true + try await triggerInitialCardsEventAndResetMockState() + + let expectation = self.expectation(description: "willDisplayCards") + expectation.isInverted = true + model.willDisplayCardsImpl = { _ in expectation.fulfill() } + + model.isViewExpanded = false + await fulfillment(of: [expectation], timeout: 0.1) + XCTAssertEqual(model.willDisplayCardsCalls, []) + } + + // MARK: - Helper functions + + func triggerInitialCardsEventAndResetMockState() async throws { + _ = try await client.getConfig(params: [], original: .init()) + _ = try await client.getData(params: [], original: .init()) + model.willDisplayCardsCalls = [] + } + + func handleMessage(named methodName: NewTabPageNextStepsCardsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws -> Response { + let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) + let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) + return try XCTUnwrap(response as? Response, file: file, line: line) + } + + func handleMessageExpectingNilResponse(named methodName: NewTabPageNextStepsCardsClient.MessageName, parameters: Any = [], file: StaticString = #file, line: UInt = #line) async throws { + let handler = try XCTUnwrap(userScript.handler(forMethodNamed: methodName.rawValue), file: file, line: line) + let response = try await handler(NewTabPageTestsHelper.asJSON(parameters), .init()) + XCTAssertNil(response, file: file, line: line) + } +}