From e21b32839382020dc384562233b8b43200f57720 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 30 Aug 2024 17:49:25 +0100 Subject: [PATCH] Modal for Duck Player experiment (#3163) Task/Issue URL: https://app.asana.com/0/1204167627774280/1208057428394427/f **Description**: Implement the onboarding screen for the Duck Player experiment on macOS --- DuckDuckGo.xcodeproj/project.pbxproj | 90 ++++++ .../Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../Images/DuckPlayer/Contents.json | 6 + .../Contents.json | 12 + .../DuckPlayerConsentModal.pdf | Bin 0 -> 4094 bytes .../Contents.json | 12 + .../DuckPlayerConsentModalDax.pdf | Bin 0 -> 16938 bytes DuckDuckGo/Common/Localizables/UserText.swift | 9 + DuckDuckGo/Menus/MainMenu.swift | 3 + DuckDuckGo/Menus/MainMenuActions.swift | 8 + .../Model/DuckPlayerPreferences.swift | 8 + DuckDuckGo/Tab/Model/Tab+Navigation.swift | 3 + .../DuckPlayerOnboardingTabExtension.swift | 76 +++++ .../DuckPlayerTabExtension.swift | 25 +- .../Tab/TabExtensions/TabExtensions.swift | 8 +- .../Tab/View/BrowserTabViewController.swift | 18 ++ DuckDuckGo/YoutubePlayer/DuckPlayer.swift | 36 ++- ...uckPlayerOnboardingLocationValidator.swift | 52 ++++ .../DuckPlayerOnboardingDecider.swift | 143 +++++++++ .../DuckPlayerOnboardingModalManager.swift | 29 ++ .../DuckPlayerOnboardingModalView.swift | 293 ++++++++++++++++++ .../DuckPlayerOnboardingViewController.swift | 72 +++++ .../DuckPlayerOnboardingViewModel.swift | 57 ++++ .../YoutubePlayer/TabModal/TabModal.swift | 140 +++++++++ .../TabModal/TabModalManageable.swift | 82 +++++ .../YoutubeOverlayUserScript.swift | 10 + ...aultDuckPlayerOnboardingDeciderTests.swift | 146 +++++++++ ...ayerOnboardingLocationValidatorTests.swift | 93 ++++++ 31 files changed, 1420 insertions(+), 11 deletions(-) rename DuckDuckGo/Assets.xcassets/Colors/{CookieConsentPrimaryButton.colorset => DuckPlayerOnboardingPrimaryButton.colorset}/Contents.json (100%) rename DuckDuckGo/Assets.xcassets/Colors/{CookieConsentPrimaryButtonPressed.colorset => DuckPlayerOnboardingPrimaryButtonPressed.colorset}/Contents.json (100%) rename DuckDuckGo/Assets.xcassets/Colors/{CookieConsentSecondaryButton.colorset => DuckPlayerOnboardingSecondaryButton.colorset}/Contents.json (100%) rename DuckDuckGo/Assets.xcassets/Colors/{CookieConsentSecondaryButtonPressed.colorset => DuckPlayerOnboardingSecondaryButtonPressed.colorset}/Contents.json (100%) create mode 100644 DuckDuckGo/Assets.xcassets/Images/DuckPlayer/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/DuckPlayerConsentModal.pdf create mode 100644 DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/Contents.json create mode 100644 DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/DuckPlayerConsentModalDax.pdf create mode 100644 DuckDuckGo/Tab/TabExtensions/DuckPlayerOnboardingTabExtension.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingLocationValidator.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingDecider.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalManager.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalView.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewController.swift create mode 100644 DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewModel.swift create mode 100644 DuckDuckGo/YoutubePlayer/TabModal/TabModal.swift create mode 100644 DuckDuckGo/YoutubePlayer/TabModal/TabModalManageable.swift create mode 100644 UnitTests/YoutubePlayer/DefaultDuckPlayerOnboardingDeciderTests.swift create mode 100644 UnitTests/YoutubePlayer/DuckPlayerOnboardingLocationValidatorTests.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index afbe6a0a02..7742eb8eaa 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -220,6 +220,28 @@ 317295D52AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 317295D12AF058D3002C3206 /* MockWaitlistFeatureSetupHandler.swift */; }; 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3184AC6C288F29D800C35E4B /* BadgeNotificationAnimationModel.swift */; }; 3184AC6F288F2A1100C35E4B /* CookieNotificationAnimationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3184AC6E288F2A1100C35E4B /* CookieNotificationAnimationModel.swift */; }; + 3199AF6F2C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF632C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift */; }; + 3199AF702C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF632C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift */; }; + 3199AF732C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF652C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift */; }; + 3199AF742C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF652C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift */; }; + 3199AF752C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF662C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift */; }; + 3199AF762C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF662C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift */; }; + 3199AF772C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF672C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift */; }; + 3199AF782C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF672C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift */; }; + 3199AF792C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF682C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift */; }; + 3199AF7A2C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF682C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift */; }; + 3199AF7B2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6A2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift */; }; + 3199AF7C2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6A2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift */; }; + 3199AF7D2C80734A003AEBDC /* TabModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6C2C80734A003AEBDC /* TabModal.swift */; }; + 3199AF7E2C80734A003AEBDC /* TabModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6C2C80734A003AEBDC /* TabModal.swift */; }; + 3199AF7F2C80734A003AEBDC /* TabModalManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6D2C80734A003AEBDC /* TabModalManageable.swift */; }; + 3199AF802C80734A003AEBDC /* TabModalManageable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF6D2C80734A003AEBDC /* TabModalManageable.swift */; }; + 3199AF832C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */; }; + 3199AF842C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */; }; + 3199AF852C80736C003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF822C80736B003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift */; }; + 3199AF862C80736C003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF822C80736B003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift */; }; + 3199AF882C8073CB003AEBDC /* DuckPlayerOnboardingTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF872C8073CA003AEBDC /* DuckPlayerOnboardingTabExtension.swift */; }; + 3199AF892C8073CB003AEBDC /* DuckPlayerOnboardingTabExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3199AF872C8073CA003AEBDC /* DuckPlayerOnboardingTabExtension.swift */; }; 31A2FD172BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */; }; 31A2FD182BAB43BA00D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */; }; 31A3A4E32B0C115F0021063C /* DataBrokerProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 31A3A4E22B0C115F0021063C /* DataBrokerProtection */; }; @@ -3130,6 +3152,17 @@ 3184AC6E288F2A1100C35E4B /* CookieNotificationAnimationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookieNotificationAnimationModel.swift; sourceTree = ""; }; 3192A2702A4C4E330084EA89 /* DataBrokerProtection */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = DataBrokerProtection; sourceTree = ""; }; 3192EC872A4DCF21001E97A5 /* DBPHomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBPHomeViewController.swift; sourceTree = ""; }; + 3199AF632C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingDecider.swift; sourceTree = ""; }; + 3199AF652C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingModalManager.swift; sourceTree = ""; }; + 3199AF662C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingModalView.swift; sourceTree = ""; }; + 3199AF672C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingViewController.swift; sourceTree = ""; }; + 3199AF682C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingViewModel.swift; sourceTree = ""; }; + 3199AF6A2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingLocationValidator.swift; sourceTree = ""; }; + 3199AF6C2C80734A003AEBDC /* TabModal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabModal.swift; sourceTree = ""; }; + 3199AF6D2C80734A003AEBDC /* TabModalManageable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabModalManageable.swift; sourceTree = ""; }; + 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingLocationValidatorTests.swift; sourceTree = ""; }; + 3199AF822C80736B003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultDuckPlayerOnboardingDeciderTests.swift; sourceTree = ""; }; + 3199AF872C8073CA003AEBDC /* DuckPlayerOnboardingTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DuckPlayerOnboardingTabExtension.swift; sourceTree = ""; }; 3199C6F82AF94F5B002A7BA1 /* DataBrokerProtectionFeatureDisabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionFeatureDisabler.swift; sourceTree = ""; }; 3199C6FC2AF97367002A7BA1 /* DataBrokerProtectionAppEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionAppEvents.swift; sourceTree = ""; }; 31A2FD162BAB41C500D0E741 /* DataBrokerProtectionFeatureGatekeeperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataBrokerProtectionFeatureGatekeeperTests.swift; sourceTree = ""; }; @@ -4945,6 +4978,36 @@ path = DBP; sourceTree = ""; }; + 3199AF692C80734A003AEBDC /* DuckPlayerOnboardingModal */ = { + isa = PBXGroup; + children = ( + 3199AF632C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift */, + 3199AF652C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift */, + 3199AF662C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift */, + 3199AF672C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift */, + 3199AF682C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift */, + ); + path = DuckPlayerOnboardingModal; + sourceTree = ""; + }; + 3199AF6B2C80734A003AEBDC /* Onboarding */ = { + isa = PBXGroup; + children = ( + 3199AF692C80734A003AEBDC /* DuckPlayerOnboardingModal */, + 3199AF6A2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; + 3199AF6E2C80734A003AEBDC /* TabModal */ = { + isa = PBXGroup; + children = ( + 3199AF6C2C80734A003AEBDC /* TabModal.swift */, + 3199AF6D2C80734A003AEBDC /* TabModalManageable.swift */, + ); + path = TabModal; + sourceTree = ""; + }; 31A2FD152BAB419400D0E741 /* DBP */ = { isa = PBXGroup; children = ( @@ -4976,6 +5039,8 @@ 31F28C4B28C8EE9000119F70 /* YoutubePlayer */ = { isa = PBXGroup; children = ( + 3199AF6B2C80734A003AEBDC /* Onboarding */, + 3199AF6E2C80734A003AEBDC /* TabModal */, 37F19A6928E2F2D000740DC6 /* DuckPlayer.swift */, 31F28C5228C8EECA00119F70 /* DuckURLSchemeHandler.swift */, 315AA06F28CA5CC800200030 /* YoutubePlayerNavigationHandler.swift */, @@ -5043,6 +5108,8 @@ 376718FE28E58504003A2A15 /* YoutubePlayer */ = { isa = PBXGroup; children = ( + 3199AF822C80736B003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift */, + 3199AF812C80736B003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift */, 3714B1E828EDBAAB0056C57A /* DuckPlayerTests.swift */, 567DA94429E95C3F008AC5EE /* YoutubeOverlayUserScriptTests.swift */, ); @@ -8169,6 +8236,7 @@ B6D574B12947224C008ED1B6 /* ContentBlockingTabExtension.swift */, B6DA06E32913ECEE00225DE2 /* ContextMenuManager.swift */, B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */, + 3199AF872C8073CA003AEBDC /* DuckPlayerOnboardingTabExtension.swift */, B6C416A6294A4AE500C4F2E7 /* DuckPlayerTabExtension.swift */, B6D574B329472253008ED1B6 /* FBProtectionTabExtension.swift */, B6C00ECC292F89D9009C73A6 /* FindInPageTabExtension.swift */, @@ -10039,6 +10107,7 @@ 7BCB90C32C1863BA008E3543 /* VPNControllerXPCClient+ConvenienceInitializers.swift in Sources */, EEC4A66E2B2C894F00F7C0AA /* VPNLocationPreferenceItemModel.swift in Sources */, 3706FA83293F65D500E42796 /* LazyLoadable.swift in Sources */, + 3199AF7C2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift in Sources */, 3706FA85293F65D500E42796 /* KeyedCodingExtension.swift in Sources */, 3706FA87293F65D500E42796 /* DownloadListStore.swift in Sources */, 37197EAB2942443D00394917 /* WebViewContainerView.swift in Sources */, @@ -10247,6 +10316,7 @@ 3707C71F294B5D2900682A9F /* WKUserContentControllerExtension.swift in Sources */, 3706FB1A293F65D500E42796 /* OutlineSeparatorViewCell.swift in Sources */, 3706FB1B293F65D500E42796 /* SafariDataImporter.swift in Sources */, + 3199AF702C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */, 3706FB1D293F65D500E42796 /* StatisticsLoader.swift in Sources */, 56406D4C2C636A8900BF8FA2 /* SpecialPagesUserScriptExtension.swift in Sources */, 3793FDD829535EBA00A2E28F /* Assertions.swift in Sources */, @@ -10279,6 +10349,7 @@ 3706FB31293F65D500E42796 /* PinnedTabsHostingView.swift in Sources */, B6AFE6BC29A5D3F8002FF962 /* PrivacyDashboardTabExtension.swift in Sources */, 3706FB32293F65D500E42796 /* FirefoxBookmarksReader.swift in Sources */, + 3199AF7E2C80734A003AEBDC /* TabModal.swift in Sources */, 1D39E57B2C2C0F3700757339 /* ReleaseNotesUserScript.swift in Sources */, 3706FB33293F65D500E42796 /* DeviceIdleStateDetector.swift in Sources */, 1DB67F2A2B6FEB17003DF243 /* WebViewSnapshotRenderer.swift in Sources */, @@ -10296,6 +10367,7 @@ 3706FB3E293F65D500E42796 /* BookmarksBarViewModel.swift in Sources */, 3706FB3F293F65D500E42796 /* NSPopUpButtonView.swift in Sources */, F1FDC9392BF51F41006B1435 /* VPNSettings+Environment.swift in Sources */, + 3199AF762C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift in Sources */, 1ED910D62B63BFB300936947 /* IdentityTheftRestorationPagesUserScript.swift in Sources */, 3706FB40293F65D500E42796 /* BookmarksContextMenu.swift in Sources */, 3706FB41293F65D500E42796 /* NavigationBarViewController.swift in Sources */, @@ -10328,6 +10400,7 @@ B6B5F5802B024105008DB58A /* DataImportSummaryView.swift in Sources */, 4B9DB0422A983B24000927DB /* WaitlistDialogView.swift in Sources */, 3706FB57293F65D500E42796 /* AppPrivacyConfigurationDataProvider.swift in Sources */, + 3199AF7A2C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift in Sources */, C1B1CBE22BE1915100B6049C /* DataImportShortcutsViewModel.swift in Sources */, 857E5AF62A790B7000FC0FB4 /* PixelExperiment.swift in Sources */, 9F33445F2BBFA77F0040CBEB /* BookmarksBarVisibilityManager.swift in Sources */, @@ -10568,11 +10641,13 @@ 31267C6B2B640C5200FEF811 /* DataBrokerProtectionAppEvents.swift in Sources */, 3706FBE4293F65D500E42796 /* FireAnimationView.swift in Sources */, 3706FBE5293F65D500E42796 /* FaviconUrlReference.swift in Sources */, + 3199AF802C80734A003AEBDC /* TabModalManageable.swift in Sources */, 3706FBE7293F65D500E42796 /* PasswordManagementItemListModel.swift in Sources */, 3706FBE8293F65D500E42796 /* SuggestionTableCellView.swift in Sources */, 3706FBE9293F65D500E42796 /* FireViewModel.swift in Sources */, B68D21D02ACBC9FD002DA3C2 /* ContentBlockerRulesManagerMock.swift in Sources */, BD7090D02C5182FB009EED82 /* UnifiedFeedbackFormView.swift in Sources */, + 3199AF742C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift in Sources */, 3706FEC6293F6F0600E42796 /* BWKeyStorage.swift in Sources */, 4B6785482AA8DE69008A5004 /* VPNUninstaller.swift in Sources */, 3706FBEC293F65D500E42796 /* EditableTextView.swift in Sources */, @@ -10767,6 +10842,7 @@ 3706FC61293F65D500E42796 /* NSAlertExtension.swift in Sources */, 3706FC62293F65D500E42796 /* ThirdPartyBrowser.swift in Sources */, 3706FC63293F65D500E42796 /* CircularProgressView.swift in Sources */, + 3199AF782C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift in Sources */, 3706FC64293F65D500E42796 /* SuggestionContainer.swift in Sources */, C16127EF2BDFB46400966BB9 /* DataImportShortcutsView.swift in Sources */, 3706FC65293F65D500E42796 /* HomePageViewController.swift in Sources */, @@ -10812,6 +10888,7 @@ 3706FC82293F65D500E42796 /* PermissionAuthorizationPopover.swift in Sources */, 4BCBE4552BA7E16600FC75A1 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, 371209312C233D69003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, + 3199AF892C8073CB003AEBDC /* DuckPlayerOnboardingTabExtension.swift in Sources */, 3706FC83293F65D500E42796 /* PopoverMessageViewController.swift in Sources */, 7B5A23702C46A116007213AC /* ExcludedDomainsModel.swift in Sources */, 9D9AE86E2AA76D1F0026E7DC /* LoginItem+NetworkProtection.swift in Sources */, @@ -11070,6 +11147,7 @@ 1DE03425298BC7F000CAB3D7 /* InternalUserDeciderStoreMock.swift in Sources */, 3706FE4A293F661700E42796 /* BookmarkManagedObjectTests.swift in Sources */, BBFF355E2C4AF26200DA3289 /* BookmarksSortModeTests.swift in Sources */, + 3199AF842C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */, EEC8EB402982CD550065AA39 /* JSAlertViewModelTests.swift in Sources */, BDA764922BC4E57200D0400C /* MockVPNLocationFormatter.swift in Sources */, 3706FE4B293F661700E42796 /* BookmarksHTMLImporterTests.swift in Sources */, @@ -11084,6 +11162,7 @@ 3706FE51293F661700E42796 /* SafariBookmarksReaderTests.swift in Sources */, 3706FE52293F661700E42796 /* FileSystemDSLTests.swift in Sources */, 3706FE53293F661700E42796 /* CoreDataEncryptionTests.swift in Sources */, + 3199AF862C80736C003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift in Sources */, 3706FE54293F661700E42796 /* PasteboardBookmarkTests.swift in Sources */, 3706FE55293F661700E42796 /* CBRCompileTimeReporterTests.swift in Sources */, B6E6BA172BA2CF60008AA7E1 /* SandboxTestToolNotifications.swift in Sources */, @@ -11638,6 +11717,7 @@ F18826912BC0105800D9AC4F /* PixelDataStore.swift in Sources */, B69B503E2726A12500758A2B /* AtbParser.swift in Sources */, 37F19A6528E1B3FB00740DC6 /* PreferencesDuckPlayerView.swift in Sources */, + 3199AF7D2C80734A003AEBDC /* TabModal.swift in Sources */, 84F1C8DE2C774D4200716446 /* NSTableViewExtension.swift in Sources */, 7B7F5D212C526CE600826256 /* AddExcludedDomainView.swift in Sources */, 4B92929E26670D2A00AD2C21 /* BookmarkSidebarTreeController.swift in Sources */, @@ -11655,6 +11735,7 @@ AAC6881928626BF800D54247 /* RecentlyClosedTab.swift in Sources */, B688B4DF27420D290087BEAF /* PDFSearchTextMenuItemHandler.swift in Sources */, AA7E919728746BCC00AB6B62 /* HistoryMenu.swift in Sources */, + 3199AF752C80734A003AEBDC /* DuckPlayerOnboardingModalView.swift in Sources */, BB470EBB2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift in Sources */, F4A6198C283CFFBB007F2080 /* ContentScopeFeatureFlagging.swift in Sources */, 85707F24276A332A00DC0649 /* OnboardingButtonStyles.swift in Sources */, @@ -11807,9 +11888,11 @@ B60293E62BA19ECD0033186B /* NetPPopoverManagerMock.swift in Sources */, B6B3E0E12657EA7A0040E0A2 /* NSScreenExtension.swift in Sources */, B65E6BA026D9F10600095F96 /* NSBezierPathExtension.swift in Sources */, + 3199AF882C8073CB003AEBDC /* DuckPlayerOnboardingTabExtension.swift in Sources */, 31ECDA112BED339600AE679F /* DataBrokerAuthenticationManagerBuilder.swift in Sources */, 4B4D60E02A0C875F00BCD287 /* Bundle+VPN.swift in Sources */, AA6820E425502F19005ED0D5 /* WebsiteDataStore.swift in Sources */, + 3199AF732C80734A003AEBDC /* DuckPlayerOnboardingModalManager.swift in Sources */, B6F9BDDC2B45B7EE00677B33 /* WebsiteInfo.swift in Sources */, 4B67854A2AA8DE75008A5004 /* VPNFeatureGatekeeper.swift in Sources */, 37A089FB2C510FE0003BB417 /* RemoteMessagingDebugMenu.swift in Sources */, @@ -11835,6 +11918,7 @@ 4B723E0F26B0006500E14D75 /* CSVParser.swift in Sources */, B63BDF7E27FDAA640072D75B /* PrivacyDashboardWebView.swift in Sources */, 37CD54CF27F2FDD100F1F7B9 /* AppearancePreferences.swift in Sources */, + 3199AF7B2C80734A003AEBDC /* DuckPlayerOnboardingLocationValidator.swift in Sources */, B6B1E87B26D381710062C350 /* DownloadListCoordinator.swift in Sources */, B68D21C82ACBC96D002DA3C2 /* MockPrivacyConfiguration.swift in Sources */, B647EFBB2922584B00BA628D /* AdClickAttributionTabExtension.swift in Sources */, @@ -11860,6 +11944,7 @@ AA5C1DD5285C780C0089850C /* RecentlyClosedCoordinator.swift in Sources */, AA88D14B252A557100980B4E /* URLRequestExtension.swift in Sources */, AA6197C6276B3168008396F0 /* FaviconHostReference.swift in Sources */, + 3199AF792C80734A003AEBDC /* DuckPlayerOnboardingViewModel.swift in Sources */, B6685E4229A61C470043D2EE /* DownloadsTabExtension.swift in Sources */, 4B8AC93B26B48ADF00879451 /* ASN1Parser.swift in Sources */, 856C98DF257014BD00A22F1F /* FileDownloadManager.swift in Sources */, @@ -11951,6 +12036,7 @@ 56A0541F2C1CA1F5007D8FAB /* OnboardingTabExtension.swift in Sources */, B63D466925BEB6C200874977 /* WKWebView+SessionState.swift in Sources */, B6F1B0222BCE5658005E863C /* BrokenSiteInfoTabExtension.swift in Sources */, + 3199AF7F2C80734A003AEBDC /* TabModalManageable.swift in Sources */, 4B4D60C02A0C848D00BCD287 /* NetworkProtectionControllerErrorStore.swift in Sources */, 4B723E1226B0006E00E14D75 /* DataImport.swift in Sources */, 7BE146072A6A83C700C313B8 /* NetworkProtectionDebugMenu.swift in Sources */, @@ -12092,6 +12178,7 @@ 7B60AFFF2C51426A008E32A3 /* VPNURLEventHandler.swift in Sources */, D64A5FF82AEA5C2B00B6D6E7 /* HomeButtonMenuFactory.swift in Sources */, 37A6A8F62AFCCA59008580A3 /* FaviconsFetcherOnboardingViewController.swift in Sources */, + 3199AF6F2C80734A003AEBDC /* DuckPlayerOnboardingDecider.swift in Sources */, AA3F895324C18AD500628DDE /* SuggestionViewModel.swift in Sources */, 4B9292A326670D2A00AD2C21 /* BookmarkManagedObject.swift in Sources */, 4B723E1326B0007A00E14D75 /* CSVLoginExporter.swift in Sources */, @@ -12106,6 +12193,7 @@ 31F28C5128C8EEC500119F70 /* YoutubeOverlayUserScript.swift in Sources */, B6ABD0CA2BC03F610000EB69 /* SecurityScopedFileURLController.swift in Sources */, B6040856274B830F00680351 /* DictionaryExtension.swift in Sources */, + 3199AF772C80734A003AEBDC /* DuckPlayerOnboardingViewController.swift in Sources */, B684592725C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift in Sources */, B6DA06E62913F39400225DE2 /* MenuItemSelectors.swift in Sources */, 3712092C2C23383C003ADF3D /* RemoteMessagingStoreErrorHandling.swift in Sources */, @@ -12402,6 +12490,7 @@ AAC9C01C24CB594C00AD1325 /* TabViewModelTests.swift in Sources */, 84DC715B2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */, 561D29CA2BDA752F007B91D0 /* MockAppearancePreferencesPersistor.swift in Sources */, + 3199AF832C80736C003AEBDC /* DuckPlayerOnboardingLocationValidatorTests.swift in Sources */, 56A0540D2C1C375D007D8FAB /* MockWindow.swift in Sources */, 567DA93F29E8045D008AC5EE /* MockEmailStorage.swift in Sources */, 37CD54B727F1B28A00F1F7B9 /* DefaultBrowserPreferencesTests.swift in Sources */, @@ -12637,6 +12726,7 @@ 376C4DB928A1A48A00CC0F5B /* FirePopoverViewModelTests.swift in Sources */, AAEC74B62642CC6A00C2EFBC /* HistoryStoringMock.swift in Sources */, AA652CB125DD825B009059CC /* LocalBookmarkStoreTests.swift in Sources */, + 3199AF852C80736C003AEBDC /* DefaultDuckPlayerOnboardingDeciderTests.swift in Sources */, B630794226731F5400DCEE41 /* WKDownloadMock.swift in Sources */, B6C0B24626E9CB190031CB7F /* RunLoopExtensionTests.swift in Sources */, 56D145F129E6F06D00E3488A /* MockBookmarkManager.swift in Sources */, diff --git a/DuckDuckGo/Assets.xcassets/Colors/CookieConsentPrimaryButton.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingPrimaryButton.colorset/Contents.json similarity index 100% rename from DuckDuckGo/Assets.xcassets/Colors/CookieConsentPrimaryButton.colorset/Contents.json rename to DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingPrimaryButton.colorset/Contents.json diff --git a/DuckDuckGo/Assets.xcassets/Colors/CookieConsentPrimaryButtonPressed.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingPrimaryButtonPressed.colorset/Contents.json similarity index 100% rename from DuckDuckGo/Assets.xcassets/Colors/CookieConsentPrimaryButtonPressed.colorset/Contents.json rename to DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingPrimaryButtonPressed.colorset/Contents.json diff --git a/DuckDuckGo/Assets.xcassets/Colors/CookieConsentSecondaryButton.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingSecondaryButton.colorset/Contents.json similarity index 100% rename from DuckDuckGo/Assets.xcassets/Colors/CookieConsentSecondaryButton.colorset/Contents.json rename to DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingSecondaryButton.colorset/Contents.json diff --git a/DuckDuckGo/Assets.xcassets/Colors/CookieConsentSecondaryButtonPressed.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingSecondaryButtonPressed.colorset/Contents.json similarity index 100% rename from DuckDuckGo/Assets.xcassets/Colors/CookieConsentSecondaryButtonPressed.colorset/Contents.json rename to DuckDuckGo/Assets.xcassets/Colors/DuckPlayerOnboardingSecondaryButtonPressed.colorset/Contents.json diff --git a/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/Contents.json b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/Contents.json new file mode 100644 index 0000000000..35fadfe35d --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "DuckPlayerConsentModal.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/DuckPlayerConsentModal.pdf b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModal.imageset/DuckPlayerConsentModal.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6c039fc856dd347f25eecd1e9dc4ff41992a023d GIT binary patch literal 4094 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 literal 0 HcmV?d00001 diff --git a/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/Contents.json new file mode 100644 index 0000000000..b2115618cd --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "DuckPlayerConsentModalDax.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/DuckPlayerConsentModalDax.pdf b/DuckDuckGo/Assets.xcassets/Images/DuckPlayer/DuckPlayerOnboardingModalDax.imageset/DuckPlayerConsentModalDax.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f35514d214ceab4f54684f7ee30b385084957df2 GIT binary patch literal 16938 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.Key.homePageShowPermanentSurvey.rawValue) } + @objc func resetDuckPlayerOnboarding(_ sender: Any?) { + DefaultDuckPlayerOnboardingDecider().reset() + } + + @objc func resetDuckPlayerPreferences(_ sender: Any?) { + DuckPlayerPreferences.shared.reset() + } + @objc func internalUserState(_ sender: Any?) { guard let internalUserDecider = NSApp.delegateTyped.internalUserDecider as? DefaultInternalUserDecider else { return } let state = internalUserDecider.isInternalUser diff --git a/DuckDuckGo/Preferences/Model/DuckPlayerPreferences.swift b/DuckDuckGo/Preferences/Model/DuckPlayerPreferences.swift index 653e7cc4c2..ca523a9875 100644 --- a/DuckDuckGo/Preferences/Model/DuckPlayerPreferences.swift +++ b/DuckDuckGo/Preferences/Model/DuckPlayerPreferences.swift @@ -117,6 +117,14 @@ final class DuckPlayerPreferences: ObservableObject { duckPlayerContingencyHandler.shouldDisplayContingencyMessage } + func reset() { + youtubeOverlayAnyButtonPressed = false + youtubeOverlayInteracted = false + duckPlayerMode = .alwaysAsk + duckPlayerOpenInNewTab = true + duckPlayerAutoplay = true + } + @MainActor func openLearnMoreContingencyURL() { guard let url = duckPlayerContingencyHandler.learnMoreURL else { return } diff --git a/DuckDuckGo/Tab/Model/Tab+Navigation.swift b/DuckDuckGo/Tab/Model/Tab+Navigation.swift index ab3fc72e87..1a4bce2d59 100644 --- a/DuckDuckGo/Tab/Model/Tab+Navigation.swift +++ b/DuckDuckGo/Tab/Model/Tab+Navigation.swift @@ -54,6 +54,9 @@ extension Tab: NavigationResponder { // Duck Player overlay navigations handling .weak(nullable: self.duckPlayer), + // Duck Player onboarding banner + .weak(nullable: self.duckPlayerOnboarding), + // open external scheme link in another app .weak(nullable: self.externalAppSchemeHandler), diff --git a/DuckDuckGo/Tab/TabExtensions/DuckPlayerOnboardingTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/DuckPlayerOnboardingTabExtension.swift new file mode 100644 index 0000000000..9c428dec5e --- /dev/null +++ b/DuckDuckGo/Tab/TabExtensions/DuckPlayerOnboardingTabExtension.swift @@ -0,0 +1,76 @@ +// +// DuckPlayerOnboardingTabExtension.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 Foundation +import Navigation +import Combine + +typealias DuckPlayerOnboardingPublisher = AnyPublisher + +final class DuckPlayerOnboardingTabExtension: TabExtension { + @Published private(set) var onboardingState: OnboardingState? + private let onboardingDecider: DuckPlayerOnboardingDecider + + init(onboardingDecider: DuckPlayerOnboardingDecider) { + self.onboardingDecider = onboardingDecider + } +} + +extension DuckPlayerOnboardingTabExtension: NavigationResponder { + + func navigationDidFinish(_ navigation: Navigation) { + guard onboardingDecider.canDisplayOnboarding else { return } + + let locationValidator = DuckPlayerOnboardingLocationValidator() + + Task { @MainActor in + if let webView = navigation.navigationAction.targetFrame?.webView, + await locationValidator.isValidLocation(webView) { + onboardingState = .init(onboardingDecider: onboardingDecider) + } + } + } +} + +struct OnboardingState { + let onboardingDecider: DuckPlayerOnboardingDecider +} + +protocol DuckPlayerOnboardingProtocol: AnyObject, NavigationResponder { + var duckPlayerOnboardingPublisher: DuckPlayerOnboardingPublisher { get } +} + +extension DuckPlayerOnboardingTabExtension: DuckPlayerOnboardingProtocol { + func getPublicProtocol() -> DuckPlayerOnboardingProtocol { self } + + var duckPlayerOnboardingPublisher: DuckPlayerOnboardingPublisher { + self.$onboardingState.eraseToAnyPublisher() + } +} + +extension TabExtensions { + var duckPlayerOnboarding: DuckPlayerOnboardingProtocol? { + resolve(DuckPlayerOnboardingTabExtension.self) + } +} + +extension Tab { + var duckPlayerOnboardingPublisher: DuckPlayerOnboardingPublisher { + self.duckPlayerOnboarding?.duckPlayerOnboardingPublisher ?? Just(nil).eraseToAnyPublisher() + } +} diff --git a/DuckDuckGo/Tab/TabExtensions/DuckPlayerTabExtension.swift b/DuckDuckGo/Tab/TabExtensions/DuckPlayerTabExtension.swift index 92d5a63573..21f8dc1cc2 100644 --- a/DuckDuckGo/Tab/TabExtensions/DuckPlayerTabExtension.swift +++ b/DuckDuckGo/Tab/TabExtensions/DuckPlayerTabExtension.swift @@ -53,17 +53,19 @@ final class DuckPlayerTabExtension { } private weak var youtubeOverlayScript: YoutubeOverlayUserScript? private weak var youtubePlayerScript: YoutubePlayerUserScript? - + private let onboardingDecider: DuckPlayerOnboardingDecider private var shouldSelectNextNewTab: Bool? init(duckPlayer: DuckPlayer, isBurner: Bool, scriptsPublisher: some Publisher, webViewPublisher: some Publisher, - preferences: DuckPlayerPreferences = .shared) { + preferences: DuckPlayerPreferences = .shared, + onboardingDecider: DuckPlayerOnboardingDecider) { self.duckPlayer = duckPlayer self.isBurner = isBurner self.preferences = preferences + self.onboardingDecider = onboardingDecider webViewPublisher.sink { [weak self] webView in self?.webView = webView @@ -87,6 +89,12 @@ final class DuckPlayerTabExtension { youtubePlayerCancellables.removeAll() guard duckPlayer.isAvailable else { return } + onboardingDecider.valueChangedPublisher.sink {[weak self] _ in + guard let self = self else { return } + + self.youtubeOverlayScript?.userUISettingsUpdated(uiValues: UIUserValues(onboardingDecider: self.onboardingDecider)) + }.store(in: &youtubePlayerCancellables) + if let hostname = url?.host, let script = youtubeOverlayScript { if script.messageOriginPolicy.isAllowed(hostname) { duckPlayer.$mode @@ -176,6 +184,7 @@ extension DuckPlayerTabExtension: NavigationResponder { @MainActor func decidePolicy(for navigationAction: NavigationAction, preferences: inout NavigationPreferences) async -> NavigationActionPolicy? { // only proceed when Private Player is enabled + guard duckPlayer.isAvailable, duckPlayer.mode != .disabled else { return decidePolicyWithDisabledDuckPlayer(for: navigationAction) } @@ -254,7 +263,7 @@ extension DuckPlayerTabExtension: NavigationResponder { func navigation(_ navigation: Navigation, didSameDocumentNavigationOf navigationType: WKSameDocumentNavigationType) { // Navigating to a Youtube URL without page reload - if duckPlayer.mode == .enabled, + if shouldOpenDuckPlayerDirectly, case .sessionStatePush = navigationType, let webView, let url = webView.url, url.isYoutubeVideo, @@ -295,7 +304,7 @@ extension DuckPlayerTabExtension: NavigationResponder { // SERP+Video <<<< YT (redirected to DP) <- Duck Player // if case .backForward(distance: let distance) = navigationAction.navigationType, distance < 0, - duckPlayer.mode == .enabled, + shouldOpenDuckPlayerDirectly, navigationAction.sourceFrame.url.isDuckPlayer, navigationAction.url.youtubeVideoID == navigationAction.sourceFrame.url.youtubeVideoID, let mainFrame = navigationAction.mainFrameTarget { @@ -319,7 +328,7 @@ extension DuckPlayerTabExtension: NavigationResponder { } // Redirect youtube urls to Duck Player when [Always enable] preference is set - if duckPlayer.mode == .enabled + if shouldOpenDuckPlayerDirectly // - or - recommendations must always be opened in the Duck Player || (navigationAction.sourceFrame.url.isDuckPlayer && navigationAction.url.isYoutubeVideoRecommendation), let mainFrame = navigationAction.mainFrameTarget { @@ -353,7 +362,7 @@ extension DuckPlayerTabExtension: NavigationResponder { return } if navigation.url.isDuckPlayer { - let setting = duckPlayer.mode == .enabled ? "always" : "default" + var setting = preferences.duckPlayerMode == .enabled ? "always" : "default" let newTabSettings = preferences.duckPlayerOpenInNewTab ? "true" : "false" let autoplay = preferences.duckPlayerAutoplay ? "true" : "false" @@ -380,5 +389,7 @@ extension DuckPlayerTabExtension: DuckPlayerExtensionProtocol, TabExtension { } extension TabExtensions { - var duckPlayer: DuckPlayerExtensionProtocol? { resolve(DuckPlayerTabExtension.self) } + var duckPlayer: DuckPlayerExtensionProtocol? { + resolve(DuckPlayerTabExtension.self) + } } diff --git a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift index e998c25902..42eb48da2c 100644 --- a/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift +++ b/DuckDuckGo/Tab/TabExtensions/TabExtensions.swift @@ -188,11 +188,17 @@ extension TabExtensionsBuilder { NavigationHotkeyHandler(isTabPinned: args.isTabPinned, isBurner: args.isTabBurner) } + let duckPlayerOnboardingDecider = DefaultDuckPlayerOnboardingDecider() add { DuckPlayerTabExtension(duckPlayer: dependencies.duckPlayer, isBurner: args.isTabBurner, scriptsPublisher: userScripts.compactMap { $0 }, - webViewPublisher: args.webViewFuture) + webViewPublisher: args.webViewFuture, + onboardingDecider: duckPlayerOnboardingDecider) + } + + add { + DuckPlayerOnboardingTabExtension(onboardingDecider: duckPlayerOnboardingDecider) } add { diff --git a/DuckDuckGo/Tab/View/BrowserTabViewController.swift b/DuckDuckGo/Tab/View/BrowserTabViewController.swift index 051665ebde..0d1f226caf 100644 --- a/DuckDuckGo/Tab/View/BrowserTabViewController.swift +++ b/DuckDuckGo/Tab/View/BrowserTabViewController.swift @@ -44,6 +44,7 @@ final class BrowserTabViewController: NSViewController { private var tabViewModelCancellables = Set() private var activeUserDialogCancellable: Cancellable? + private var duckPlayerConsentCancellable: AnyCancellable? private var pinnedTabsDelegatesCancellable: AnyCancellable? private var keyWindowSelectedTabCancellable: AnyCancellable? private var cancellables = Set() @@ -54,6 +55,10 @@ final class BrowserTabViewController: NSViewController { private var hoverLabelWorkItem: DispatchWorkItem? private(set) var transientTabContentViewController: NSViewController? + private lazy var duckPlayerOnboardingModalManager: DuckPlayerOnboardingModalManager = { + let modal = DuckPlayerOnboardingModalManager() + return modal + }() required init?(coder: NSCoder) { fatalError("BrowserTabViewController: Bad initializer") @@ -255,6 +260,7 @@ final class BrowserTabViewController: NSViewController { self.subscribeToTabContent(of: selectedTabViewModel) self.subscribeToHoveredLink(of: selectedTabViewModel) self.subscribeToUserDialogs(of: selectedTabViewModel) + self.subscribeToDuckPlayerOnboardingPrompt(of: selectedTabViewModel) self.adjustFirstResponder(force: true) } @@ -430,6 +436,18 @@ final class BrowserTabViewController: NSViewController { #endif } + private func subscribeToDuckPlayerOnboardingPrompt(of tabViewModel: TabViewModel?) { + tabViewModel?.tab.duckPlayerOnboardingPublisher.sink { [weak self, weak tab = tabViewModel?.tab] onboardingState in + + guard let self, let tab, let onboardingState = onboardingState, onboardingState.onboardingDecider.canDisplayOnboarding else { + self?.duckPlayerOnboardingModalManager.close(animated: false, completion: nil) + return + } + + self.duckPlayerOnboardingModalManager.show(on: self.view, animated: true) + }.store(in: &tabViewModelCancellables) + } + private func shouldMakeContentViewFirstResponder(for tabContent: Tab.TabContent) -> Bool { // always steal focus when first responder is not a text field guard view.window?.firstResponder is NSText else { diff --git a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift index 1890a232fe..bc26df30dd 100644 --- a/DuckDuckGo/YoutubePlayer/DuckPlayer.swift +++ b/DuckDuckGo/YoutubePlayer/DuckPlayer.swift @@ -57,6 +57,7 @@ struct InitialPlayerSettings: Codable { struct PlayerSettings: Codable { let pip: PIP let autoplay: Autoplay + let focusMode: FocusMode } struct PIP: Codable { @@ -75,6 +76,17 @@ struct InitialPlayerSettings: Codable { let state: State } + /// Represents the current focus mode of the player. + /// + /// Focus mode determines whether the bottom toolbar should be visible or hidden. + /// When focus mode is enabled, the toolbar will auto-hide after a few seconds. + /// When focus mode is disabled, the toolbar will always be visible and the background wallpaper will be slightly brighter. + /// + /// Default should be enabled. + struct FocusMode: Codable { + let state: State + } + enum State: String, Codable { case enabled case disabled @@ -90,10 +102,12 @@ struct InitialPlayerSettings: Codable { let platform: Platform let environment: Environment let locale: Locale + } struct InitialOverlaySettings: Codable { let userValues: UserValues + let ui: UIUserValues } // Values that the YouTube Overlays can use to determine the current state @@ -111,6 +125,15 @@ public struct UserValues: Codable { let overlayInteracted: Bool } +public struct UIUserValues: Codable { + /// If this value is true, we force the FE layer to play in duck player even if the settings is off + let playInDuckPlayer: Bool + + init(onboardingDecider: DuckPlayerOnboardingDecider) { + self.playInDuckPlayer = onboardingDecider.shouldOpenFirstVideoOnDuckPlayer + } +} + final class DuckPlayer { static let usesSimulatedRequests: Bool = { if #available(macOS 12.0, *) { @@ -145,12 +168,14 @@ final class DuckPlayer { init( preferences: DuckPlayerPreferences = .shared, - privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager + privacyConfigurationManager: PrivacyConfigurationManaging = ContentBlocking.shared.privacyConfigurationManager, + onboardingDecider: DuckPlayerOnboardingDecider = DefaultDuckPlayerOnboardingDecider() ) { self.preferences = preferences isFeatureEnabled = privacyConfigurationManager.privacyConfig.isEnabled(featureKey: .duckPlayer) isPiPFeatureEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(DuckPlayerSubfeature.pip) isAutoplayFeatureEnabled = privacyConfigurationManager.privacyConfig.isSubfeatureEnabled(DuckPlayerSubfeature.autoplay) + self.onboardingDecider = onboardingDecider mode = preferences.duckPlayerMode bindDuckPlayerModeIfNeeded() @@ -265,9 +290,13 @@ final class DuckPlayer { let platform = InitialPlayerSettings.Platform(name: "macos") let environment = InitialPlayerSettings.Environment.development let locale = InitialPlayerSettings.Locale.en - let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip, autoplay: autoplay) + let focusMode = InitialPlayerSettings.FocusMode(state: onboardingDecider.shouldOpenFirstVideoOnDuckPlayer ? .disabled : .enabled) + let playerSettings = InitialPlayerSettings.PlayerSettings(pip: pip, autoplay: autoplay, focusMode: focusMode) let userValues = encodeUserValues() + /// Since the FE is requesting player-encoded values, we can assume that the first player video setup is complete from the onboarding point of view. + onboardingDecider.setFirstVideoInDuckPlayerAsDone() + return InitialPlayerSettings(userValues: userValues, settings: playerSettings, platform: platform, @@ -279,7 +308,7 @@ final class DuckPlayer { private func encodedOverlaySettings(with webView: WKWebView?) async -> InitialOverlaySettings { let userValues = encodeUserValues() - return InitialOverlaySettings(userValues: userValues) + return InitialOverlaySettings(userValues: userValues, ui: UIUserValues(onboardingDecider: onboardingDecider)) } // MARK: - Private @@ -296,6 +325,7 @@ final class DuckPlayer { private var isFeatureEnabledCancellable: AnyCancellable? private var isPiPFeatureEnabled: Bool private var isAutoplayFeatureEnabled: Bool + private let onboardingDecider: DuckPlayerOnboardingDecider private func bindDuckPlayerModeIfNeeded() { if isFeatureEnabled { diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingLocationValidator.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingLocationValidator.swift new file mode 100644 index 0000000000..1b467c9f04 --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingLocationValidator.swift @@ -0,0 +1,52 @@ +// +// DuckPlayerOnboardingLocationValidator.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 Foundation +import Navigation + +struct DuckPlayerOnboardingLocationValidator { + private static let youtubeChannelCheckScript = """ + (function() { + var canonicalLink = document.querySelector('link[rel="canonical"]'); + return canonicalLink && canonicalLink.href.includes('channel'); + })(); + """ + + func isValidLocation(_ webView: WKWebView) async -> Bool { + guard let url = await webView.url else { return false } + + let isRootURL = isYoutubeRootURL(url) + let isInChannel = await isCurrentWebViewInAYoutubeChannel(webView) + return isRootURL || isInChannel + } + + private func isYoutubeRootURL(_ url: URL) -> Bool { + guard let urlComponents = URLComponents(string: url.absoluteString) else { return false } + return urlComponents.scheme == "https" && + urlComponents.host == "www.youtube.com" && + urlComponents.path == "/" + } + + private func isCurrentWebViewInAYoutubeChannel(_ webView: WKWebView) async -> Bool { + do { + return try await webView.evaluateJavaScript(DuckPlayerOnboardingLocationValidator.youtubeChannelCheckScript) as Bool? ?? false + } catch { + return false + } + } +} diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingDecider.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingDecider.swift new file mode 100644 index 0000000000..d76955b560 --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingDecider.swift @@ -0,0 +1,143 @@ +// +// DuckPlayerOnboardingDecider.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 Foundation + +/// A protocol for deciding whether to display onboarding and open the first video in the Duck Player. +protocol DuckPlayerOnboardingDecider { + /// A boolean indicating whether the onboarding should be displayed. + var canDisplayOnboarding: Bool { get } + + /// A boolean indicating whether the first video should be opened in the Duck Player. + var shouldOpenFirstVideoOnDuckPlayer: Bool { get } + + /// Sets the onboarding as done. + /// + /// This method should be called when the onboarding has been completed. + func setOnboardingAsDone() + + /// Sets the flag to open the first video in the Duck Player. + /// + /// This method should be called when user selects to use Duck Player during the onboarding + func setOpenFirstVideoOnDuckPlayer() + + /// Sets the first video in the Duck Player as done. + /// + /// This method should be called when the first video has been opened in the Duck Player. + func setFirstVideoInDuckPlayerAsDone() + + /// A publisher that emits a notification whenever any onboarding or video flags change. + /// + /// Subscribe to receive updates when `canDisplayOnboarding`, `shouldOpenFirstVideoOnDuckPlayer`, or related values change. + /// + /// Note that this publisher will emit a notification whenever any of the underlying values change, even if the change is made on a different instance of `DefaultDuckPlayerOnboardingDecider`. + var valueChangedPublisher: PassthroughSubject { get set } + + /// Resets the onboarding and video flags to their initial state. + /// + /// This method should be called when the onboarding and video flags need to be reset. + func reset() +} + +import Combine + +struct DefaultDuckPlayerOnboardingDecider: DuckPlayerOnboardingDecider { + private let defaults: UserDefaults + private var observer: NSObjectProtocol? + private let preferences: DuckPlayerPreferences + var valueChangedPublisher: PassthroughSubject = .init() + + init(defaults: UserDefaults = .standard, preferences: DuckPlayerPreferences = .shared) { + self.defaults = defaults + self.preferences = preferences + observer = NotificationCenter.default.addObserver(forName: .valuesDidChange, object: nil, queue: nil) { [valuesDidChange] _ in + valuesDidChange() + } + } + + /// We only want to display the onboarding if it was never displayed, the settings is set to alwaysAsk and haven't interacted with the overlay. + var canDisplayOnboarding: Bool { +#if DEBUG + return !defaults.onboardingWasDisplayed && preferences.duckPlayerMode == .alwaysAsk +#else + // returning false until we turn on the experiment + return false +#endif + + } + + private var isUserInExperiment: Bool { + return false + } + + var shouldOpenFirstVideoOnDuckPlayer: Bool { + return defaults.shouldOpenFirstVideoInDuckPlayer && !defaults.firstVideoWasOpenedInDuckPlayer + } + + func setOnboardingAsDone() { + NotificationCenter.default.post(name: .valuesDidChange, object: nil) + defaults.onboardingWasDisplayed = true + } + + func setOpenFirstVideoOnDuckPlayer() { + defaults.shouldOpenFirstVideoInDuckPlayer = true + NotificationCenter.default.post(name: .valuesDidChange, object: nil) + } + + func setFirstVideoInDuckPlayerAsDone() { + defaults.firstVideoWasOpenedInDuckPlayer = true + NotificationCenter.default.post(name: .valuesDidChange, object: nil) + } + + func reset() { + defaults.onboardingWasDisplayed = false + defaults.shouldOpenFirstVideoInDuckPlayer = false + defaults.firstVideoWasOpenedInDuckPlayer = false + } + + private func valuesDidChange() { + valueChangedPublisher.send() + } +} + +private extension UserDefaults { + enum Keys { + static let onboardingWasDisplayed = "duckplayer.onboarding-displayed" + static let firstVideoWasOpenedInDuckPlayer = "duckplayer.onboarding.first-video-opened" + static let shouldOpenFirstVideoInDuckPlayer = "duckplayer.onboarding.should-open-in-duckplayer" + } + + var onboardingWasDisplayed: Bool { + get { return bool(forKey: Keys.onboardingWasDisplayed) } + set { set(newValue, forKey: Keys.onboardingWasDisplayed) } + } + + var firstVideoWasOpenedInDuckPlayer: Bool { + get { return bool(forKey: Keys.firstVideoWasOpenedInDuckPlayer) } + set { set(newValue, forKey: Keys.firstVideoWasOpenedInDuckPlayer) } + } + + var shouldOpenFirstVideoInDuckPlayer: Bool { + get { return bool(forKey: Keys.shouldOpenFirstVideoInDuckPlayer) } + set { set(newValue, forKey: Keys.shouldOpenFirstVideoInDuckPlayer) } + } +} + +private extension Notification.Name { + static let valuesDidChange = Notification.Name("duckplayer.onboarding.should-open-first-video") +} diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalManager.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalManager.swift new file mode 100644 index 0000000000..63e56e7c06 --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalManager.swift @@ -0,0 +1,29 @@ +// +// DuckPlayerOnboardingModalManager.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 Foundation + +final class DuckPlayerOnboardingModalManager: TabModalManageable { + var modal: TabModal? + + var viewController: NSViewController { + DuckPlayerOnboardingViewController { [weak self] in + self?.close(animated: true, completion: nil) + } + } +} diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalView.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalView.swift new file mode 100644 index 0000000000..8f7ecbae99 --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingModalView.swift @@ -0,0 +1,293 @@ +// +// DuckPlayerOnboardingModalView.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 SwiftUI + +struct DuckPlayerOnboardingModalView: View { + private enum Constants { + static let outerContainerWidth: CGFloat = 504 + static let smallContainerHeight: CGFloat = 182 + static let bigContainerHeight: CGFloat = 286 + static let containerCornerRadius: CGFloat = 12 + static let darkModeBorderColor: Color = .white.opacity(0.2) + static let whiteModeBorderColor: Color = .black.opacity(0.1) + } + + @ObservedObject var viewModel: DuckPlayerOnboardingViewModel + @Environment(\.colorScheme) var colorScheme + + var body: some View { + currentView + .padding() + .frame(width: Constants.outerContainerWidth, height: containerHeight) + .padding(.horizontal) + .background(Color("DialogPanelBackground")) + .cornerRadius(Constants.containerCornerRadius) + .overlay( + RoundedRectangle(cornerRadius: Constants.containerCornerRadius) + .stroke(colorScheme == .dark ? Constants.darkModeBorderColor : Constants.whiteModeBorderColor, lineWidth: 1) + ) + } + + private var containerHeight: CGFloat { + switch viewModel.currentView { + case .confirmation: + return Constants.smallContainerHeight + + case .onboardingOptions: + return Constants.bigContainerHeight + } + } + + @ViewBuilder + var currentView: some View { + switch viewModel.currentView { + case .confirmation: + DuckPlayerOnboardingConfirmationView { + viewModel.handleGotItCTA() + } + + case .onboardingOptions: + DuckPlayerOnboardingChoiceView(turnOnButtonPressed: { + viewModel.currentView = .confirmation + viewModel.handleTurnOnCTA() + }, notNowPressed: viewModel.handleNotNowCTA) + } + } +} + +private struct DuckPlayerOnboardingChoiceView: View { + let turnOnButtonPressed: () -> Void + let notNowPressed: () -> Void + + var body: some View { + VStack(spacing: 20) { + DaxSpeechBubble { + VStack (alignment: .leading, spacing: 8) { + Text(UserText.duckPlayerOnboardingChoiceModalTitle) + .font(.title) + .padding(.horizontal) + + Text(UserText.duckPlayerOnboardingChoiceModalMessage) + .font(.body) + .multilineText() + .padding(.horizontal) + + HStack { + Spacer() + Image("DuckPlayerOnboardingModal") + Spacer() + } + }.frame(maxWidth: .infinity) + .padding() + + } + + HStack { + Button { + notNowPressed() + } label: { + Text(UserText.duckPlayerOnboardingChoiceModalCTADeny) + } + .buttonStyle(SecondaryCTAStyle()) + + Spacer() + Button { + turnOnButtonPressed() + } label: { + Text(UserText.duckPlayerOnboardingChoiceModalCTAConfirm) + } + .buttonStyle(PrimaryCTAStyle()) + } + } + } +} + +private struct DuckPlayerOnboardingConfirmationView: View { + let voidButtonPressed: () -> Void + var body: some View { + VStack(spacing: 20) { + DaxSpeechBubble { + VStack(alignment: .leading, spacing: 8) { + Text(UserText.duckPlayerOnboardingConfirmationModalTitle) + .font(.title) + .padding(.horizontal) + + Text(UserText.duckPlayerOnboardingConfirmationModalMessage) + .font(.body) + .padding(.horizontal) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + + } + + Button { + voidButtonPressed() + } label: { + Text(UserText.duckPlayerOnboardingConfirmationModalCTAConfirm) + } + .buttonStyle(PrimaryCTAStyle()) + } + } +} + +private struct DaxSpeechBubble: View { + let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + VStack { + HStack(alignment: .top, spacing: 12) { + Image("DuckPlayerOnboardingModalDax") + .padding(.leading, -10) + + ZStack { + SpeechBubble() + content + } + } + } + } +} + +private struct SpeechBubble: View { + let radius: CGFloat = 20 + let tailSize: CGFloat = 12 + let tailPosition: CGFloat = 38 + let tailHeight: CGFloat = 28 + + var body: some View { + ZStack { + GeometryReader { g in + let rect = CGRect(x: 0, y: 0, width: g.size.width, height: g.size.height) + + Path { path in + + path.move(to: CGPoint(x: rect.minX, y: rect.maxY - radius)) + + path.addLine(to: CGPoint(x: rect.minX, y: tailPosition + tailHeight / 2)) + path.addLine(to: CGPoint(x: rect.minX - tailSize, y: tailPosition)) + path.addLine(to: CGPoint(x: rect.minX, y: tailPosition - tailHeight / 2)) + + path.addArc( + center: CGPoint(x: rect.minX + radius, y: rect.minY + radius), + radius: radius, + startAngle: .degrees(180), + endAngle: .degrees(270), + clockwise: false + ) + path.addArc( + center: CGPoint(x: rect.maxX - radius, y: rect.minY + radius), + radius: radius, + startAngle: .degrees(270), + endAngle: .degrees(0), + clockwise: false + ) + path.addArc( + center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius), + radius: radius, + startAngle: .degrees(0), + endAngle: .degrees(90), + clockwise: false + ) + path.addArc( + center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius), + radius: radius, + startAngle: .degrees(90), + endAngle: .degrees(180), + clockwise: false + ) + + } + .fill(Color(.interfaceBackground)) + .shadow(color: Color(.onboardingDaxSpeechShadow), radius: 2, x: 0, y: 0) + } + + } + } +} + +private enum CTAConstants { + static let CTACornerRadius: CGFloat = 8 +} + +private struct PrimaryCTAStyle: ButtonStyle { + + func makeBody(configuration: Self.Configuration) -> some View { + + let color = configuration.isPressed ? Color("DuckPlayerOnboardingPrimaryButtonPressed") : Color("DuckPlayerOnboardingPrimaryButton") + + configuration.label + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .truncationMode(.tail) + .background(RoundedRectangle(cornerRadius: CTAConstants.CTACornerRadius, style: .continuous).fill(color)) + .foregroundColor(.white) + .font(.system(size: 13, weight: .light, design: .default)) + } +} + +private struct SecondaryCTAStyle: ButtonStyle { + @Environment(\.colorScheme) var colorScheme + + func makeBody(configuration: Self.Configuration) -> some View { + + let color = configuration.isPressed ? Color("DuckPlayerOnboardingSecondaryButtonPressed") : Color("DuckPlayerOnboardingSecondaryButton") + + let outterShadowOpacity = colorScheme == .dark ? 0.8 : 0.0 + + configuration.label + .font(.system(size: 13, weight: .light, design: .default)) + .foregroundColor(.primary) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: CTAConstants.CTACornerRadius, style: .continuous) + .fill(color) + .shadow(color: .black.opacity(0.1), radius: 0.1, x: 0, y: 1) + .shadow(color: .primary.opacity(outterShadowOpacity), radius: 0.1, x: 0, y: -0.6)) + + .overlay( + RoundedRectangle(cornerRadius: CTAConstants.CTACornerRadius) + .stroke(Color.black.opacity(0.1), lineWidth: 1)) + } +} + +#Preview { + VStack { + DuckPlayerOnboardingChoiceView(turnOnButtonPressed: { + + }, notNowPressed: { + + }) + .frame(width: 504, height: 286) + + Divider() + .padding() + + DuckPlayerOnboardingConfirmationView(voidButtonPressed: { + + }) + .frame(width: 504, height: 152) + } + .padding() +} diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewController.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewController.swift new file mode 100644 index 0000000000..ff9e00ba07 --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewController.swift @@ -0,0 +1,72 @@ +// +// DuckPlayerOnboardingViewController.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 SwiftUI +import AppKit + +final class DuckPlayerOnboardingViewController: NSViewController { + var didFinish: () -> Void + + internal init(didFinish: @escaping () -> Void) { + self.didFinish = didFinish + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var hostingView: NSHostingView! + private lazy var viewModel: DuckPlayerOnboardingViewModel = { + let viewModel = DuckPlayerOnboardingViewModel() + viewModel.delegate = self + return viewModel + }() + + override func loadView() { + let consentView = DuckPlayerOnboardingModalView(viewModel: viewModel) + hostingView = NSHostingView(rootView: consentView) + self.view = hostingView + } + + private func handleEnableDuckPlayerActionButton() { + print("Enabled") + } + + private func handleNotNowActionButton() { + didFinish() + } + + private func handleGotItActionButton() { + didFinish() + } +} + +extension DuckPlayerOnboardingViewController: DuckPlayerOnboardingViewModelDelegate{ + func duckPlayerOnboardingViewModelDidSelectTurnOn(_ viewModel: DuckPlayerOnboardingViewModel) { + handleEnableDuckPlayerActionButton() + } + + func duckPlayerOnboardingViewModelDidSelectNotNow(_ viewModel: DuckPlayerOnboardingViewModel) { + handleNotNowActionButton() + } + + func duckPlayerOnboardingViewModelDidSelectGotIt(_ viewModel: DuckPlayerOnboardingViewModel) { + handleGotItActionButton() + } +} diff --git a/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewModel.swift b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewModel.swift new file mode 100644 index 0000000000..d5996ae3ca --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/Onboarding/DuckPlayerOnboardingModal/DuckPlayerOnboardingViewModel.swift @@ -0,0 +1,57 @@ +// +// DuckPlayerOnboardingViewModel.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 Foundation + +protocol DuckPlayerOnboardingViewModelDelegate: AnyObject { + func duckPlayerOnboardingViewModelDidSelectTurnOn(_ viewModel: DuckPlayerOnboardingViewModel) + func duckPlayerOnboardingViewModelDidSelectNotNow(_ viewModel: DuckPlayerOnboardingViewModel) + func duckPlayerOnboardingViewModelDidSelectGotIt(_ viewModel: DuckPlayerOnboardingViewModel) +} + +final class DuckPlayerOnboardingViewModel: ObservableObject { + private let onboardingDecider: DuckPlayerOnboardingDecider + + enum DuckPlayerModalCurrentView { + case onboardingOptions + case confirmation + } + + init(onboardingDecider: DuckPlayerOnboardingDecider = DefaultDuckPlayerOnboardingDecider()) { + self.onboardingDecider = onboardingDecider + } + + @Published var currentView: DuckPlayerModalCurrentView = .onboardingOptions + weak var delegate: DuckPlayerOnboardingViewModelDelegate? + + func handleTurnOnCTA() { + delegate?.duckPlayerOnboardingViewModelDidSelectTurnOn(self) + + onboardingDecider.setOpenFirstVideoOnDuckPlayer() + onboardingDecider.setOnboardingAsDone() + } + + func handleNotNowCTA() { + delegate?.duckPlayerOnboardingViewModelDidSelectNotNow(self) + onboardingDecider.setOnboardingAsDone() + } + + func handleGotItCTA() { + delegate?.duckPlayerOnboardingViewModelDidSelectGotIt(self) + } +} diff --git a/DuckDuckGo/YoutubePlayer/TabModal/TabModal.swift b/DuckDuckGo/YoutubePlayer/TabModal/TabModal.swift new file mode 100644 index 0000000000..1c6422ba8e --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/TabModal/TabModal.swift @@ -0,0 +1,140 @@ +// +// TabModal.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 Cocoa +import Combine +private enum AnimationConsts { + static let yAnimationOffset: CGFloat = 65 + static let duration: CGFloat = 0.6 +} + +public final class TabModal { + private let modalViewController: NSViewController + private lazy var windowController: NSWindowController = { + let windowController = NSWindowController(window: NSWindow(contentViewController: modalViewController)) + + if let window = windowController.window { + window.styleMask = [.borderless] + window.acceptsMouseMovedEvents = true + window.ignoresMouseEvents = false + window.backgroundColor = .clear + window.isOpaque = false + window.hasShadow = true + window.level = .floating + } + modalViewController.view.wantsLayer = true + return windowController + }() + + private var resizeObserver: Any? + private var cancellables = Set() + + public init(modalViewController: NSViewController) { + self.modalViewController = modalViewController + } + + public required init?(coder: NSCoder) { + fatalError("OnboardingModal: Bad initializer") + } + + // MARK: - Private methods + + private func windowDidResize(_ parent: NSWindow) { + guard let overlayWindow = windowController.window else { + return + } + + let xPosition = (parent.frame.width / 2) - (overlayWindow.frame.width / 2) + parent.frame.origin.x + let yPosition = parent.frame.origin.y + parent.frame.height - overlayWindow.frame.height - AnimationConsts.yAnimationOffset + + let size = overlayWindow.frame.size + let newOrigin = NSPoint(x: xPosition, y: yPosition) + overlayWindow.setFrame(NSRect(origin: newOrigin, size: size), display: true) + } + + private func addObserverForWindowResize(_ window: NSWindow) { + NotificationCenter.default.publisher(for: NSWindow.didResizeNotification, object: window) + .receive(on: DispatchQueue.main) + .sink { [weak self] notification in + guard let parent = notification.object as? NSWindow else { return } + self?.windowDidResize(parent) + } + .store(in: &cancellables) + } +} + +// MARK: - Public methods +extension TabModal: TabModalPresentable { + public func close(animated: Bool, completion: (() -> Void)? = nil) { + guard let overlayWindow = windowController.window else { + return + } + if !overlayWindow.isVisible { return } + + let removeWindow = { + overlayWindow.parent?.removeChildWindow(overlayWindow) + overlayWindow.orderOut(nil) + completion?() + } + + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = AnimationConsts.duration + + let newOrigin = NSPoint(x: overlayWindow.frame.origin.x, y: overlayWindow.frame.origin.y + AnimationConsts.yAnimationOffset) + let size = overlayWindow.frame.size + overlayWindow.animator().alphaValue = 0 + overlayWindow.animator().setFrame(NSRect(origin: newOrigin, size: size), display: true) + } completionHandler: { + removeWindow() + } + } else { + removeWindow() + } + } + + public func show(on currentTabView: NSView, animated: Bool) { + guard let currentTabViewWindow = currentTabView.window, + let overlayWindow = windowController.window else { + return + } + + addObserverForWindowResize(currentTabViewWindow) + + currentTabViewWindow.addChildWindow(overlayWindow, ordered: .above) + + let xPosition = (currentTabViewWindow.frame.width / 2) - (overlayWindow.frame.width / 2) + currentTabViewWindow.frame.origin.x + let yPosition = currentTabViewWindow.frame.origin.y + currentTabViewWindow.frame.height - overlayWindow.frame.height + + if animated { + overlayWindow.setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + overlayWindow.alphaValue = 0 + + NSAnimationContext.runAnimationGroup { context in + context.duration = AnimationConsts.duration + let newOrigin = NSPoint(x: xPosition, y: yPosition - AnimationConsts.yAnimationOffset) + let size = overlayWindow.frame.size + overlayWindow.animator().alphaValue = 1 + overlayWindow.animator().setFrame(NSRect(origin: newOrigin, size: size), display: true) + + } + } else { + overlayWindow.setFrameOrigin(NSPoint(x: xPosition, y: yPosition - AnimationConsts.yAnimationOffset)) + } + } +} diff --git a/DuckDuckGo/YoutubePlayer/TabModal/TabModalManageable.swift b/DuckDuckGo/YoutubePlayer/TabModal/TabModalManageable.swift new file mode 100644 index 0000000000..7da40e235f --- /dev/null +++ b/DuckDuckGo/YoutubePlayer/TabModal/TabModalManageable.swift @@ -0,0 +1,82 @@ +// +// TabModalManageable.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 Foundation + +/// A protocol for handling the view controller to be displayed in the modal. +protocol TabModalPresentable: AnyObject { + /// Initializes a new instance of the modal presenter with the given view controller. + /// + /// - Parameter modalViewController: The view controller to be presented in the modal. + init(modalViewController: NSViewController) + + /// Closes the modal view controller. + /// + /// - Parameters: + /// - animated: A boolean indicating whether the closure of the modal should be animated. + /// - completion: An optional closure to be executed after the modal has been closed. + func close(animated: Bool, completion: (() -> Void)?) + + /// Shows the modal view controller on the given view. + /// + /// - Parameters: + /// - currentTabView: The view on which the modal should be presented. + /// - animated: A boolean indicating whether the presentation of the modal should be animated. + func show(on currentTabView: NSView, animated: Bool) +} + +/// A protocol for managing the modal to be presented. +protocol TabModalManageable: AnyObject { + associatedtype ModalType: TabModalPresentable + + var modal: ModalType? { get set } + var viewController: NSViewController { get } + + /// Closes the modal view controller. + /// + /// - Parameters: + /// - animated: A boolean indicating whether the closure of the modal should be animated. + /// - completion: An optional closure to be executed after the modal has been closed. + func close(animated: Bool, completion: (() -> Void)?) + + /// Shows the modal view controller on the given view. + /// + /// - Parameters: + /// - currentTabView: The view on which the modal should be presented. + /// - animated: A boolean indicating whether the presentation of the modal should be animated. + func show(on currentTabView: NSView, animated: Bool) +} + +extension TabModalManageable { + + func close(animated: Bool, completion: (() -> Void)?) { + modal?.close(animated: animated) { [weak self] in + self?.modal = nil + } + } + + func show(on currentTabView: NSView, animated: Bool) { + prepareModal() + modal?.show(on: currentTabView, animated: animated) + } + + private func prepareModal() { + guard modal == nil else { return } + modal = ModalType(modalViewController: viewController) + } +} diff --git a/DuckDuckGo/YoutubePlayer/YoutubeOverlayUserScript.swift b/DuckDuckGo/YoutubePlayer/YoutubeOverlayUserScript.swift index 79ba756c2d..4d20a6ccd4 100644 --- a/DuckDuckGo/YoutubePlayer/YoutubeOverlayUserScript.swift +++ b/DuckDuckGo/YoutubePlayer/YoutubeOverlayUserScript.swift @@ -21,6 +21,7 @@ import WebKit import Common import UserScript import PixelKit +import Combine protocol YoutubeOverlayUserScriptDelegate: AnyObject { func youtubeOverlayUserScriptDidRequestDuckPlayer(with url: URL, in webView: WKWebView) @@ -95,6 +96,7 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { } } + // User values are user controled values public func userValuesUpdated(userValues: UserValues) { guard let webView = webView else { return assertionFailure("Could not access webView") @@ -102,6 +104,14 @@ final class YoutubeOverlayUserScript: NSObject, Subfeature { broker?.push(method: "onUserValuesChanged", params: userValues, for: self, into: webView) } + // Temporary changes to user settings + public func userUISettingsUpdated(uiValues: UIUserValues) { + guard let webView = webView else { + return assertionFailure("Could not access webView") + } + broker?.push(method: "onUIValuesChanged", params: uiValues, for: self, into: webView) + } + // MARK: - Private Methods @MainActor diff --git a/UnitTests/YoutubePlayer/DefaultDuckPlayerOnboardingDeciderTests.swift b/UnitTests/YoutubePlayer/DefaultDuckPlayerOnboardingDeciderTests.swift new file mode 100644 index 0000000000..7400fb7027 --- /dev/null +++ b/UnitTests/YoutubePlayer/DefaultDuckPlayerOnboardingDeciderTests.swift @@ -0,0 +1,146 @@ +// +// DefaultDuckPlayerOnboardingDeciderTests.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 XCTest +@testable import DuckDuckGo_Privacy_Browser + +final class DefaultDuckPlayerOnboardingDeciderTests: XCTestCase { + + var decider: DefaultDuckPlayerOnboardingDecider! + var defaults: UserDefaults! + static let defaultsName = "TestDefaults" + + override func setUp() { + super.setUp() + defaults = UserDefaults(suiteName: DefaultDuckPlayerOnboardingDeciderTests.defaultsName)! + decider = DefaultDuckPlayerOnboardingDecider(defaults: defaults) + } + + override func tearDown() { + super.tearDown() + defaults.removePersistentDomain(forName: DefaultDuckPlayerOnboardingDeciderTests.defaultsName) + } + + func testCanDisplayOnboarding_InitiallyReturnsTrue() { + XCTAssertTrue(decider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_ReturnsFalseAfterSettingOnboardingAsDone() { + decider.setOnboardingAsDone() + XCTAssertFalse(decider.canDisplayOnboarding) + } + + func testShouldOpenFirstVideoOnDuckPlayer_InitiallyReturnsFalse() { + XCTAssertFalse(decider.shouldOpenFirstVideoOnDuckPlayer) + } + + func testShouldOpenFirstVideoOnDuckPlayer_ReturnsTrueAfterSettingOpenFirstVideo() { + decider.setOpenFirstVideoOnDuckPlayer() + XCTAssertTrue(decider.shouldOpenFirstVideoOnDuckPlayer) + } + + func testShouldOpenFirstVideoOnDuckPlayer_ReturnsFalseAfterSettingFirstVideoAsDone() { + decider.setOpenFirstVideoOnDuckPlayer() + XCTAssertTrue(decider.shouldOpenFirstVideoOnDuckPlayer) + + decider.setFirstVideoInDuckPlayerAsDone() + XCTAssertFalse(decider.shouldOpenFirstVideoOnDuckPlayer) + } + + func testSetOnboardingAsDone_canDisplayOnboardingReturnsFalse() { + XCTAssertTrue(decider.canDisplayOnboarding) + decider.setOnboardingAsDone() + XCTAssertFalse(decider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_WhenAlwaysAskAndNotInteracted() { + let preferences = DuckPlayerPreferences( + persistor: DuckPlayerPreferencesPersistorMock( + duckPlayerMode: .alwaysAsk, + youtubeOverlayInteracted: false, + youtubeOverlayAnyButtonPressed: false + ) + ) + + let onboardingDecider = DefaultDuckPlayerOnboardingDecider(defaults: defaults, preferences: preferences) + XCTAssertTrue(onboardingDecider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_WhenAlwaysAskAndInteracted() { + let preferences = DuckPlayerPreferences( + persistor: DuckPlayerPreferencesPersistorMock( + duckPlayerMode: .alwaysAsk, + youtubeOverlayInteracted: false, + youtubeOverlayAnyButtonPressed: true + ) + ) + + let onboardingDecider = DefaultDuckPlayerOnboardingDecider(defaults: defaults, preferences: preferences) + XCTAssertTrue(onboardingDecider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_WhenEnabled() { + let preferences = DuckPlayerPreferences( + persistor: DuckPlayerPreferencesPersistorMock( + duckPlayerMode: .enabled, + youtubeOverlayInteracted: false, + youtubeOverlayAnyButtonPressed: false + ) + ) + + let onboardingDecider = DefaultDuckPlayerOnboardingDecider(defaults: defaults, preferences: preferences) + XCTAssertFalse(onboardingDecider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_WhenDisabled() { + let preferences = DuckPlayerPreferences( + persistor: DuckPlayerPreferencesPersistorMock( + duckPlayerMode: .disabled, + youtubeOverlayInteracted: false, + youtubeOverlayAnyButtonPressed: false + ) + ) + + let onboardingDecider = DefaultDuckPlayerOnboardingDecider(defaults: defaults, preferences: preferences) + XCTAssertFalse(onboardingDecider.canDisplayOnboarding) + } + + func testCanDisplayOnboarding_WhenOnboardingWasDisplayed() { + let preferences = DuckPlayerPreferences( + persistor: DuckPlayerPreferencesPersistorMock( + duckPlayerMode: .alwaysAsk, + youtubeOverlayInteracted: false, + youtubeOverlayAnyButtonPressed: false + ) + ) + + let onboardingDecider = DefaultDuckPlayerOnboardingDecider(defaults: defaults, preferences: preferences) + onboardingDecider.setOnboardingAsDone() + XCTAssertFalse(onboardingDecider.canDisplayOnboarding) + } + + func testReset_ResetsAllFlagsToFalse() { + decider.setOnboardingAsDone() + decider.setOpenFirstVideoOnDuckPlayer() + decider.setFirstVideoInDuckPlayerAsDone() + decider.reset() + + XCTAssertTrue(decider.canDisplayOnboarding) + XCTAssertFalse(decider.shouldOpenFirstVideoOnDuckPlayer) + } +} diff --git a/UnitTests/YoutubePlayer/DuckPlayerOnboardingLocationValidatorTests.swift b/UnitTests/YoutubePlayer/DuckPlayerOnboardingLocationValidatorTests.swift new file mode 100644 index 0000000000..09b2329471 --- /dev/null +++ b/UnitTests/YoutubePlayer/DuckPlayerOnboardingLocationValidatorTests.swift @@ -0,0 +1,93 @@ +// +// DuckPlayerOnboardingLocationValidatorTests.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 XCTest +@testable import DuckDuckGo_Privacy_Browser +import Navigation +class DuckPlayerOnboardingLocationValidatorTests: XCTestCase { + + var validator: DuckPlayerOnboardingLocationValidator! + + override func setUp() { + super.setUp() + validator = DuckPlayerOnboardingLocationValidator() + } + + @MainActor + func testIsValidLocation_RootURL_ReturnsTrue() async { + let webView = MockWebView() + webView.mockURL = URL(string: "https://www.youtube.com/") + let result = await validator.isValidLocation(webView) + XCTAssertTrue(result) + } + + @MainActor + func testIsValidLocation_NonRootURL_ReturnsFalse() async { + let webView = MockWebView() + webView.mockURL = URL(string: "https://www.youtube.com/duckduckgo") + let result = await validator.isValidLocation(webView) + XCTAssertFalse(result) + } + + @MainActor + func testIsValidLocation_NonYoutubeURL_ReturnsFalse() async { + let webView = MockWebView() + webView.mockURL = URL(string: "https://www.duckduckgo.com/") + let result = await validator.isValidLocation(webView) + XCTAssertFalse(result) + } + + @MainActor + func testIsValidLocation_NoURL_ReturnsFalse() async { + let webView = MockWebView() + webView.mockURL = nil + let result = await validator.isValidLocation(webView) + XCTAssertFalse(result) + } + + @MainActor + func testIsCurrentWebViewInAYoutubeChannel_ChannelURL_ReturnsTrue() async { + let webView = MockWebView() + webView.mockURL = URL(string: "https://www.youtube.com/duckduckgo")! + webView.evaluateJavaScriptResult = true + let result = await validator.isValidLocation(webView) + XCTAssertTrue(result) + } + + @MainActor + func testIsCurrentWebViewInAYoutubeChannel_NonChannelURL_ReturnsFalse() async { + let webView = MockWebView() + webView.mockURL = URL(string: "https://www.youtube.com/settings")! + webView.evaluateJavaScriptResult = false + let result = await validator.isValidLocation(webView) + XCTAssertFalse(result) + } + +} +private final class MockWebView: WKWebView { + var mockURL: URL? + var evaluateJavaScriptResult: Any? + + override var url: URL? { + return mockURL + } + + override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) { + completionHandler?(evaluateJavaScriptResult, nil) + } +}