Skip to content

Commit

Permalink
Improvements to subscription settings (#2916)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1203936086921904/1207147238749956/f

**Description**:
Make the entry point for managing subscription functionality more
obvious so that users have a sense of control over their subscriptions,
allowing them to make changes easily without the need for customer
support.
  • Loading branch information
miasma13 authored Jun 30, 2024
1 parent ff02e79 commit 85881d9
Show file tree
Hide file tree
Showing 23 changed files with 582 additions and 476 deletions.
2 changes: 1 addition & 1 deletion DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -13042,7 +13042,7 @@
repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit";
requirement = {
kind = exactVersion;
version = 163.0.0;
version = 163.0.1;
};
};
9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/duckduckgo/BrowserServicesKit",
"state" : {
"revision" : "a51fed4db0c332cd4f02eafca2d9c7a178c0829a",
"version" : "163.0.0"
"revision" : "39e10c8eeddeb03750350597bd55fd8c43b5fd83",
"version" : "163.0.1"
}
},
{
Expand Down
13 changes: 7 additions & 6 deletions DuckDuckGo/Application/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
syncService?.initializeIfNeeded()
syncService?.scheduler.notifyAppLifecycleEvent()

subscriptionManager.updateSubscriptionStatus { isActive in
if isActive {
PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActive, frequency: .daily)
}
}

NetworkProtectionAppEvents(featureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager)).applicationDidBecomeActive()

#if DBP
DataBrokerProtectionAppEvents(featureGatekeeper:
DefaultDataBrokerProtectionFeatureGatekeeper(accountManager:
Expand All @@ -386,12 +393,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

AppPrivacyFeatures.shared.contentBlocking.privacyConfigurationManager.toggleProtectionsCounter.sendEventsIfNeeded()

subscriptionManager.updateSubscriptionStatus { isActive in
if isActive {
PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActive, frequency: .daily)
}
}

Task { @MainActor in
await vpnRedditSessionWorkaround.installRedditSessionWorkaround()
}
Expand Down
4 changes: 4 additions & 0 deletions DuckDuckGo/Common/Localizables/UserText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,10 @@ struct UserText {

static let identityTheftRestorationOptionsMenuItem = "Identity Theft Restoration"

// Key: "subscription.settings.menu.item"
// Comment: "Title for Subscription Settings item in the options menu"
static let subscriptionSettingsOptionsMenuItem = "Subscription Settings"

// Key: "preferences.subscription"
// Comment: "Show subscription preferences"
static let subscription = "Privacy Pro"
Expand Down
242 changes: 134 additions & 108 deletions DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ protocol OptionsButtonMenuDelegate: AnyObject {
func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu)
#endif
func optionsButtonMenuRequestedSubscriptionPurchasePage(_ menu: NSMenu)
func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu)
func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu)
}

Expand All @@ -57,7 +58,8 @@ final class MoreOptionsMenu: NSMenu {
private let passwordManagerCoordinator: PasswordManagerCoordinating
private let internalUserDecider: InternalUserDecider
private lazy var sharingMenu: NSMenu = SharingMenu(title: UserText.shareMenuItem)
private let accountManager: AccountManager
private var accountManager: AccountManager { subscriptionManager.accountManager }
private let subscriptionManager: SubscriptionManager

private let vpnFeatureGatekeeper: VPNFeatureGatekeeper
private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability
Expand All @@ -73,15 +75,15 @@ final class MoreOptionsMenu: NSMenu {
subscriptionFeatureAvailability: SubscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(),
sharingMenu: NSMenu? = nil,
internalUserDecider: InternalUserDecider,
accountManager: AccountManager) {
subscriptionManager: SubscriptionManager) {

self.tabCollectionViewModel = tabCollectionViewModel
self.emailManager = emailManager
self.passwordManagerCoordinator = passwordManagerCoordinator
self.vpnFeatureGatekeeper = vpnFeatureGatekeeper
self.subscriptionFeatureAvailability = subscriptionFeatureAvailability
self.internalUserDecider = internalUserDecider
self.accountManager = accountManager
self.subscriptionManager = subscriptionManager

super.init(title: "")

Expand Down Expand Up @@ -234,6 +236,10 @@ final class MoreOptionsMenu: NSMenu {
actionDelegate?.optionsButtonMenuRequestedSubscriptionPurchasePage(self)
}

@objc func openSubscriptionSettings(_ sender: NSMenuItem) {
actionDelegate?.optionsButtonMenuRequestedSubscriptionPreferences(self)
}

@objc func openIdentityTheftRestoration(_ sender: NSMenuItem) {
actionDelegate?.optionsButtonMenuRequestedIdentityTheftRestoration(self)
}
Expand Down Expand Up @@ -294,119 +300,31 @@ final class MoreOptionsMenu: NSMenu {
}

private func addSubscriptionItems() {
var items: [NSMenuItem] = []

if subscriptionFeatureAvailability.isFeatureAvailable && !accountManager.isUserAuthenticated {
items.append(contentsOf: makeInactiveSubscriptionItems())
} else {
items.append(contentsOf: makeActiveSubscriptionItems()) // this adds NETP and DBP only if conditionally enabled
}

if !items.isEmpty {
items.forEach { addItem($0) }
addItem(NSMenuItem.separator())
}
}
guard subscriptionFeatureAvailability.isFeatureAvailable else { return }

// swiftlint:disable:next cyclomatic_complexity function_body_length
private func makeActiveSubscriptionItems() -> [NSMenuItem] {
var items: [NSMenuItem] = []

let networkProtectionItem: NSMenuItem

networkProtectionItem = makeNetworkProtectionItem()

items.append(networkProtectionItem)

if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
Task {
let isMenuItemEnabled: Bool

switch await accountManager.hasEntitlement(forProductName: .networkProtection) {
case let .success(result):
isMenuItemEnabled = result
case .failure:
isMenuItemEnabled = false
}

networkProtectionItem.isEnabled = isMenuItemEnabled
}
func shouldHideDueToNoProduct() -> Bool {
let platform = subscriptionManager.currentEnvironment.purchasePlatform
return platform == .appStore && subscriptionManager.canPurchase == false
}

#if DBP
let dbpGatekeeper = DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: accountManager)
if dbpGatekeeper.isFeatureVisible() || dbpGatekeeper.isPrivacyProEnabled() {
let dataBrokerProtectionItem = NSMenuItem(title: UserText.dataBrokerProtectionOptionsMenuItem,
action: #selector(openDataBrokerProtection),
keyEquivalent: "")
.targetting(self)
.withImage(.dbpIcon)
items.append(dataBrokerProtectionItem)

if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
Task {
let isMenuItemEnabled: Bool
let privacyProItem = NSMenuItem(title: UserText.subscriptionOptionsMenuItem).withImage(.subscriptionIcon)

switch await accountManager.hasEntitlement(forProductName: .dataBrokerProtection) {
case let .success(result):
isMenuItemEnabled = result
case .failure:
isMenuItemEnabled = false
}
if !accountManager.isUserAuthenticated {
privacyProItem.target = self
privacyProItem.action = #selector(openSubscriptionPurchasePage(_:))

dataBrokerProtectionItem.isEnabled = isMenuItemEnabled
}
// Do not add for App Store when purchase not available in the region
if !shouldHideDueToNoProduct() {
addItem(privacyProItem)
addItem(NSMenuItem.separator())
}

DataBrokerProtectionExternalWaitlistPixels.fire(pixel: GeneralPixel.dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, frequency: .dailyAndCount)

} else {
dbpGatekeeper.disableAndDeleteForWaitlistUsers()
}
#endif // DBP

if accountManager.isUserAuthenticated {
let identityTheftRestorationItem = NSMenuItem(title: UserText.identityTheftRestorationOptionsMenuItem,
action: #selector(openIdentityTheftRestoration),
keyEquivalent: "")
.targetting(self)
.withImage(.itrIcon)
items.append(identityTheftRestorationItem)

if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
Task {
let isMenuItemEnabled: Bool

switch await accountManager.hasEntitlement(forProductName: .identityTheftRestoration) {
case let .success(result):
isMenuItemEnabled = result
case .failure:
isMenuItemEnabled = false
}

identityTheftRestorationItem.isEnabled = isMenuItemEnabled
}
}
privacyProItem.submenu = SubscriptionSubMenu(targeting: self,
subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability(),
accountManager: accountManager)
addItem(privacyProItem)
addItem(NSMenuItem.separator())
}

return items
}

private func makeInactiveSubscriptionItems() -> [NSMenuItem] {
let subscriptionManager = Application.appDelegate.subscriptionManager
let platform = subscriptionManager.currentEnvironment.purchasePlatform
let shouldHidePrivacyProDueToNoProducts = platform == .appStore && subscriptionManager.canPurchase == false
if shouldHidePrivacyProDueToNoProducts {
return []
}

let privacyProItem = NSMenuItem(title: UserText.subscriptionOptionsMenuItem,
action: #selector(openSubscriptionPurchasePage(_:)),
keyEquivalent: "")
.targetting(self)
.withImage(.subscriptionIcon)

return [privacyProItem]
}

private func addPageItems() {
Expand Down Expand Up @@ -770,4 +688,112 @@ final class LoginsSubMenu: NSMenu {

}

@MainActor
final class SubscriptionSubMenu: NSMenu, NSMenuDelegate {

var subscriptionFeatureAvailability: SubscriptionFeatureAvailability
var accountManager: AccountManager

var networkProtectionItem: NSMenuItem!
var dataBrokerProtectionItem: NSMenuItem!
var identityTheftRestorationItem: NSMenuItem!
var subscriptionSettingsItem: NSMenuItem!

init(targeting target: AnyObject,
subscriptionFeatureAvailability: SubscriptionFeatureAvailability,
accountManager: AccountManager) {

self.subscriptionFeatureAvailability = subscriptionFeatureAvailability
self.accountManager = accountManager

super.init(title: "")

self.networkProtectionItem = makeNetworkProtectionItem(target: target)
self.dataBrokerProtectionItem = makeDataBrokerProtectionItem(target: target)
self.identityTheftRestorationItem = makeIdentityTheftRestorationItem(target: target)
self.subscriptionSettingsItem = makeSubscriptionSettingsItem(target: target)

delegate = self

addMenuItems()
}

required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

private func addMenuItems() {
addItem(networkProtectionItem)
addItem(dataBrokerProtectionItem)
addItem(identityTheftRestorationItem)
addItem(NSMenuItem.separator())
addItem(subscriptionSettingsItem)
}

private func makeNetworkProtectionItem(target: AnyObject) -> NSMenuItem {
return NSMenuItem(title: UserText.networkProtection,
action: #selector(MoreOptionsMenu.showNetworkProtectionStatus(_:)),
keyEquivalent: "")
.targetting(target)
.withImage(.image(for: .vpnIcon))
}

private func makeDataBrokerProtectionItem(target: AnyObject) -> NSMenuItem {
return NSMenuItem(title: UserText.dataBrokerProtectionOptionsMenuItem,
action: #selector(MoreOptionsMenu.openDataBrokerProtection),
keyEquivalent: "")
.targetting(target)
.withImage(.dbpIcon)
}

private func makeIdentityTheftRestorationItem(target: AnyObject) -> NSMenuItem {
return NSMenuItem(title: UserText.identityTheftRestorationOptionsMenuItem,
action: #selector(MoreOptionsMenu.openIdentityTheftRestoration),
keyEquivalent: "")
.targetting(target)
.withImage(.itrIcon)
}

private func makeSubscriptionSettingsItem(target: AnyObject) -> NSMenuItem {
return NSMenuItem(title: UserText.subscriptionSettingsOptionsMenuItem,
action: #selector(MoreOptionsMenu.openSubscriptionSettings),
keyEquivalent: "")
.targetting(target)
}

private func refreshAvailabilityBasedOnEntitlements() {
guard subscriptionFeatureAvailability.isFeatureAvailable, accountManager.isUserAuthenticated else { return }

@Sendable func hasEntitlement(for productName: Entitlement.ProductName) async -> Bool {
switch await self.accountManager.hasEntitlement(forProductName: productName) {
case let .success(result):
return result
case .failure:
return false
}
}

Task.detached(priority: .background) { [weak self] in
guard let self else { return }

let isNetworkProtectionItemEnabled = await hasEntitlement(for: .networkProtection)
let isDataBrokerProtectionItemEnabled = await hasEntitlement(for: .dataBrokerProtection)
let isIdentityTheftRestorationItemEnabled = await hasEntitlement(for: .identityTheftRestoration)

Task { @MainActor in
self.networkProtectionItem.isEnabled = isNetworkProtectionItemEnabled
self.dataBrokerProtectionItem.isEnabled = isDataBrokerProtectionItemEnabled
self.identityTheftRestorationItem.isEnabled = isIdentityTheftRestorationItemEnabled

DataBrokerProtectionExternalWaitlistPixels.fire(pixel: GeneralPixel.dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, frequency: .dailyAndCount)
}
}
}

public func menuWillOpen(_ menu: NSMenu) {
refreshAvailabilityBasedOnEntitlements()
}

}

extension MoreOptionsMenu: EmailManagerRequestDelegate {}
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ final class NavigationBarViewController: NSViewController {
passwordManagerCoordinator: PasswordManagerCoordinator.shared,
vpnFeatureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager),
internalUserDecider: internalUserDecider,
accountManager: subscriptionManager.accountManager)
subscriptionManager: subscriptionManager)
menu.actionDelegate = self
let location = NSPoint(x: -menu.size.width + sender.bounds.width, y: sender.bounds.height + 4)
menu.popUp(positioning: nil, at: location, in: sender)
Expand Down Expand Up @@ -1083,6 +1083,10 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate {
PixelKit.fire(PrivacyProPixel.privacyProOfferScreenImpression)
}

func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu) {
WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .subscription)
}

func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu) {
let url = subscriptionManager.url(for: .identityTheftRestoration)
WindowControllersManager.shared.showTab(with: .identityTheftRestoration(url))
Expand Down
Loading

0 comments on commit 85881d9

Please sign in to comment.