Skip to content

Commit

Permalink
Modal for Duck Player experiment (#3163)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204167627774280/1208057428394427/f

**Description**:
Implement the onboarding screen for the Duck Player experiment on macOS
  • Loading branch information
Bunn authored Aug 30, 2024
1 parent 8e1c851 commit e21b328
Show file tree
Hide file tree
Showing 31 changed files with 1,420 additions and 11 deletions.
90 changes: 90 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions DuckDuckGo/Assets.xcassets/Images/DuckPlayer/Contents.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "DuckPlayerConsentModal.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "DuckPlayerConsentModalDax.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.
9 changes: 9 additions & 0 deletions DuckDuckGo/Common/Localizables/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,15 @@ struct UserText {
static let duckPlayerContingencyMessageBody = NSLocalizedString("duck-player.video-contingency-message", value: "Duck Player's functionality has been affected by recent changes to YouTube. We’re working to fix these issues and appreciate your understanding.", comment: "Message explaining to the user that Duck Player is not available")
static let duckPlayerContingencyMessageCTA = NSLocalizedString("duck-player.video-contingency-cta", value: "Learn More", comment: "Button for the message explaining to the user that Duck Player is not available so the user can learn more")

static let duckPlayerOnboardingChoiceModalTitle = NSLocalizedString("duck-player.onboarding-choice-modal-title", value: "Drowning in ads on YouTube?", comment: "Title for a Duck Player onboarding modal screen")
static let duckPlayerOnboardingChoiceModalMessage = NSLocalizedString("duck-player.onboarding-choice-modal-message", value: "Duck Player lets you watch without targeted ads and comes free to use in DuckDuckGo.", comment: "Message for a Duck Player onboarding modal screen")
static let duckPlayerOnboardingChoiceModalCTAConfirm = NSLocalizedString("duck-player.onboarding-choice-modal-CTA-confirm", value: "Turn on Duck Player", comment: "Confirm Button to enable Duck Player. -Duck Player- should not be translated")
static let duckPlayerOnboardingChoiceModalCTADeny = NSLocalizedString("duck-player.onboarding-choice-modal-CTA-deny", value: "Not Now", comment: "Deny Button to enable Duck Player")

static let duckPlayerOnboardingConfirmationModalTitle = NSLocalizedString("duck-player.onboarding-confirmation-modal-title", value: "All set!", comment: "Title for a Duck Player onboarding modal confirmation screen")
static let duckPlayerOnboardingConfirmationModalMessage = NSLocalizedString("duck-player.onboarding-confirmation-modal-message", value: "Pick a video to see Duck Player work its magic.", comment: "Message for a Duck Player onboarding modal confirmation screen")
static let duckPlayerOnboardingConfirmationModalCTAConfirm = NSLocalizedString("duck-player.onboarding-confirmation-modal-CTA-confirm", value: "Got it", comment: "Button to confirm on Duck Player onboarding modal confirmation screen")


static let gpcCheckboxTitle = NSLocalizedString("gpc.checkbox.title", value: "Enable Global Privacy Control", comment: "GPC settings checkbox title")
static let gpcExplanation = NSLocalizedString("gpc.explanation", value: "Tells participating websites not to sell or share your data.", comment: "GPC explanation in settings")
Expand Down
3 changes: 3 additions & 0 deletions DuckDuckGo/Menus/MainMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,9 @@ final class MainMenu: NSMenu {
NSMenuItem(title: "Reset Pixels Storage", action: #selector(MainViewController.resetDailyPixels))
NSMenuItem(title: "Reset Remote Messages", action: #selector(AppDelegate.resetRemoteMessages))
NSMenuItem(title: "Reset CPM Experiment Cohort (needs restart)", action: #selector(AppDelegate.resetCpmCohort))
NSMenuItem(title: "Reset Duck Player Onboarding", action: #selector(MainViewController.resetDuckPlayerOnboarding))
NSMenuItem(title: "Reset Duck Player Preferences", action: #selector(MainViewController.resetDuckPlayerPreferences))

}.withAccessibilityIdentifier("MainMenu.resetData")
NSMenuItem(title: "UI Triggers") {
NSMenuItem(title: "Show Save Credentials Popover", action: #selector(MainViewController.showSaveCredentialsPopover))
Expand Down
8 changes: 8 additions & 0 deletions DuckDuckGo/Menus/MainMenuActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,14 @@ extension MainViewController {
UserDefaults.standard.set(true, forKey: UserDefaultsWrapper<Bool>.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
Expand Down
8 changes: 8 additions & 0 deletions DuckDuckGo/Preferences/Model/DuckPlayerPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions DuckDuckGo/Tab/Model/Tab+Navigation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
Original file line number Diff line number Diff line change
@@ -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<OnboardingState?, Never>

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()
}
}
25 changes: 18 additions & 7 deletions DuckDuckGo/Tab/TabExtensions/DuckPlayerTabExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<some YoutubeScriptsProvider, Never>,
webViewPublisher: some Publisher<WKWebView, Never>,
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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"

Expand All @@ -380,5 +389,7 @@ extension DuckPlayerTabExtension: DuckPlayerExtensionProtocol, TabExtension {
}

extension TabExtensions {
var duckPlayer: DuckPlayerExtensionProtocol? { resolve(DuckPlayerTabExtension.self) }
var duckPlayer: DuckPlayerExtensionProtocol? {
resolve(DuckPlayerTabExtension.self)
}
}
8 changes: 7 additions & 1 deletion DuckDuckGo/Tab/TabExtensions/TabExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions DuckDuckGo/Tab/View/BrowserTabViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class BrowserTabViewController: NSViewController {

private var tabViewModelCancellables = Set<AnyCancellable>()
private var activeUserDialogCancellable: Cancellable?
private var duckPlayerConsentCancellable: AnyCancellable?
private var pinnedTabsDelegatesCancellable: AnyCancellable?
private var keyWindowSelectedTabCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
Expand All @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit e21b328

Please sign in to comment.