Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.

Commit 85881d9

Browse files
authored
Improvements to subscription settings (#2916)
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.
1 parent ff02e79 commit 85881d9

File tree

23 files changed

+582
-476
lines changed

23 files changed

+582
-476
lines changed

DuckDuckGo.xcodeproj/project.pbxproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -13042,7 +13042,7 @@
1304213042
repositoryURL = "https://github.com/duckduckgo/BrowserServicesKit";
1304313043
requirement = {
1304413044
kind = exactVersion;
13045-
version = 163.0.0;
13045+
version = 163.0.1;
1304613046
};
1304713047
};
1304813048
9FF521422BAA8FF300B9819B /* XCRemoteSwiftPackageReference "lottie-spm" */ = {

DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

+2-2
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
"kind" : "remoteSourceControl",
3333
"location" : "https://github.com/duckduckgo/BrowserServicesKit",
3434
"state" : {
35-
"revision" : "a51fed4db0c332cd4f02eafca2d9c7a178c0829a",
36-
"version" : "163.0.0"
35+
"revision" : "39e10c8eeddeb03750350597bd55fd8c43b5fd83",
36+
"version" : "163.0.1"
3737
}
3838
},
3939
{

DuckDuckGo/Application/AppDelegate.swift

+7-6
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
377377
syncService?.initializeIfNeeded()
378378
syncService?.scheduler.notifyAppLifecycleEvent()
379379

380+
subscriptionManager.updateSubscriptionStatus { isActive in
381+
if isActive {
382+
PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActive, frequency: .daily)
383+
}
384+
}
385+
380386
NetworkProtectionAppEvents(featureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager)).applicationDidBecomeActive()
387+
381388
#if DBP
382389
DataBrokerProtectionAppEvents(featureGatekeeper:
383390
DefaultDataBrokerProtectionFeatureGatekeeper(accountManager:
@@ -386,12 +393,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
386393

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

389-
subscriptionManager.updateSubscriptionStatus { isActive in
390-
if isActive {
391-
PixelKit.fire(PrivacyProPixel.privacyProSubscriptionActive, frequency: .daily)
392-
}
393-
}
394-
395396
Task { @MainActor in
396397
await vpnRedditSessionWorkaround.installRedditSessionWorkaround()
397398
}

DuckDuckGo/Common/Localizables/UserText.swift

+4
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,10 @@ struct UserText {
12141214

12151215
static let identityTheftRestorationOptionsMenuItem = "Identity Theft Restoration"
12161216

1217+
// Key: "subscription.settings.menu.item"
1218+
// Comment: "Title for Subscription Settings item in the options menu"
1219+
static let subscriptionSettingsOptionsMenuItem = "Subscription Settings"
1220+
12171221
// Key: "preferences.subscription"
12181222
// Comment: "Show subscription preferences"
12191223
static let subscription = "Privacy Pro"

DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift

+134-108
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ protocol OptionsButtonMenuDelegate: AnyObject {
4444
func optionsButtonMenuRequestedDataBrokerProtection(_ menu: NSMenu)
4545
#endif
4646
func optionsButtonMenuRequestedSubscriptionPurchasePage(_ menu: NSMenu)
47+
func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu)
4748
func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu)
4849
}
4950

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

6264
private let vpnFeatureGatekeeper: VPNFeatureGatekeeper
6365
private let subscriptionFeatureAvailability: SubscriptionFeatureAvailability
@@ -73,15 +75,15 @@ final class MoreOptionsMenu: NSMenu {
7375
subscriptionFeatureAvailability: SubscriptionFeatureAvailability = DefaultSubscriptionFeatureAvailability(),
7476
sharingMenu: NSMenu? = nil,
7577
internalUserDecider: InternalUserDecider,
76-
accountManager: AccountManager) {
78+
subscriptionManager: SubscriptionManager) {
7779

7880
self.tabCollectionViewModel = tabCollectionViewModel
7981
self.emailManager = emailManager
8082
self.passwordManagerCoordinator = passwordManagerCoordinator
8183
self.vpnFeatureGatekeeper = vpnFeatureGatekeeper
8284
self.subscriptionFeatureAvailability = subscriptionFeatureAvailability
8385
self.internalUserDecider = internalUserDecider
84-
self.accountManager = accountManager
86+
self.subscriptionManager = subscriptionManager
8587

8688
super.init(title: "")
8789

@@ -234,6 +236,10 @@ final class MoreOptionsMenu: NSMenu {
234236
actionDelegate?.optionsButtonMenuRequestedSubscriptionPurchasePage(self)
235237
}
236238

239+
@objc func openSubscriptionSettings(_ sender: NSMenuItem) {
240+
actionDelegate?.optionsButtonMenuRequestedSubscriptionPreferences(self)
241+
}
242+
237243
@objc func openIdentityTheftRestoration(_ sender: NSMenuItem) {
238244
actionDelegate?.optionsButtonMenuRequestedIdentityTheftRestoration(self)
239245
}
@@ -294,119 +300,31 @@ final class MoreOptionsMenu: NSMenu {
294300
}
295301

296302
private func addSubscriptionItems() {
297-
var items: [NSMenuItem] = []
298-
299-
if subscriptionFeatureAvailability.isFeatureAvailable && !accountManager.isUserAuthenticated {
300-
items.append(contentsOf: makeInactiveSubscriptionItems())
301-
} else {
302-
items.append(contentsOf: makeActiveSubscriptionItems()) // this adds NETP and DBP only if conditionally enabled
303-
}
304-
305-
if !items.isEmpty {
306-
items.forEach { addItem($0) }
307-
addItem(NSMenuItem.separator())
308-
}
309-
}
303+
guard subscriptionFeatureAvailability.isFeatureAvailable else { return }
310304

311-
// swiftlint:disable:next cyclomatic_complexity function_body_length
312-
private func makeActiveSubscriptionItems() -> [NSMenuItem] {
313-
var items: [NSMenuItem] = []
314-
315-
let networkProtectionItem: NSMenuItem
316-
317-
networkProtectionItem = makeNetworkProtectionItem()
318-
319-
items.append(networkProtectionItem)
320-
321-
if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
322-
Task {
323-
let isMenuItemEnabled: Bool
324-
325-
switch await accountManager.hasEntitlement(forProductName: .networkProtection) {
326-
case let .success(result):
327-
isMenuItemEnabled = result
328-
case .failure:
329-
isMenuItemEnabled = false
330-
}
331-
332-
networkProtectionItem.isEnabled = isMenuItemEnabled
333-
}
305+
func shouldHideDueToNoProduct() -> Bool {
306+
let platform = subscriptionManager.currentEnvironment.purchasePlatform
307+
return platform == .appStore && subscriptionManager.canPurchase == false
334308
}
335309

336-
#if DBP
337-
let dbpGatekeeper = DefaultDataBrokerProtectionFeatureGatekeeper(accountManager: accountManager)
338-
if dbpGatekeeper.isFeatureVisible() || dbpGatekeeper.isPrivacyProEnabled() {
339-
let dataBrokerProtectionItem = NSMenuItem(title: UserText.dataBrokerProtectionOptionsMenuItem,
340-
action: #selector(openDataBrokerProtection),
341-
keyEquivalent: "")
342-
.targetting(self)
343-
.withImage(.dbpIcon)
344-
items.append(dataBrokerProtectionItem)
345-
346-
if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
347-
Task {
348-
let isMenuItemEnabled: Bool
310+
let privacyProItem = NSMenuItem(title: UserText.subscriptionOptionsMenuItem).withImage(.subscriptionIcon)
349311

350-
switch await accountManager.hasEntitlement(forProductName: .dataBrokerProtection) {
351-
case let .success(result):
352-
isMenuItemEnabled = result
353-
case .failure:
354-
isMenuItemEnabled = false
355-
}
312+
if !accountManager.isUserAuthenticated {
313+
privacyProItem.target = self
314+
privacyProItem.action = #selector(openSubscriptionPurchasePage(_:))
356315

357-
dataBrokerProtectionItem.isEnabled = isMenuItemEnabled
358-
}
316+
// Do not add for App Store when purchase not available in the region
317+
if !shouldHideDueToNoProduct() {
318+
addItem(privacyProItem)
319+
addItem(NSMenuItem.separator())
359320
}
360-
361-
DataBrokerProtectionExternalWaitlistPixels.fire(pixel: GeneralPixel.dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, frequency: .dailyAndCount)
362-
363321
} else {
364-
dbpGatekeeper.disableAndDeleteForWaitlistUsers()
365-
}
366-
#endif // DBP
367-
368-
if accountManager.isUserAuthenticated {
369-
let identityTheftRestorationItem = NSMenuItem(title: UserText.identityTheftRestorationOptionsMenuItem,
370-
action: #selector(openIdentityTheftRestoration),
371-
keyEquivalent: "")
372-
.targetting(self)
373-
.withImage(.itrIcon)
374-
items.append(identityTheftRestorationItem)
375-
376-
if subscriptionFeatureAvailability.isFeatureAvailable && accountManager.isUserAuthenticated {
377-
Task {
378-
let isMenuItemEnabled: Bool
379-
380-
switch await accountManager.hasEntitlement(forProductName: .identityTheftRestoration) {
381-
case let .success(result):
382-
isMenuItemEnabled = result
383-
case .failure:
384-
isMenuItemEnabled = false
385-
}
386-
387-
identityTheftRestorationItem.isEnabled = isMenuItemEnabled
388-
}
389-
}
322+
privacyProItem.submenu = SubscriptionSubMenu(targeting: self,
323+
subscriptionFeatureAvailability: DefaultSubscriptionFeatureAvailability(),
324+
accountManager: accountManager)
325+
addItem(privacyProItem)
326+
addItem(NSMenuItem.separator())
390327
}
391-
392-
return items
393-
}
394-
395-
private func makeInactiveSubscriptionItems() -> [NSMenuItem] {
396-
let subscriptionManager = Application.appDelegate.subscriptionManager
397-
let platform = subscriptionManager.currentEnvironment.purchasePlatform
398-
let shouldHidePrivacyProDueToNoProducts = platform == .appStore && subscriptionManager.canPurchase == false
399-
if shouldHidePrivacyProDueToNoProducts {
400-
return []
401-
}
402-
403-
let privacyProItem = NSMenuItem(title: UserText.subscriptionOptionsMenuItem,
404-
action: #selector(openSubscriptionPurchasePage(_:)),
405-
keyEquivalent: "")
406-
.targetting(self)
407-
.withImage(.subscriptionIcon)
408-
409-
return [privacyProItem]
410328
}
411329

412330
private func addPageItems() {
@@ -770,4 +688,112 @@ final class LoginsSubMenu: NSMenu {
770688

771689
}
772690

691+
@MainActor
692+
final class SubscriptionSubMenu: NSMenu, NSMenuDelegate {
693+
694+
var subscriptionFeatureAvailability: SubscriptionFeatureAvailability
695+
var accountManager: AccountManager
696+
697+
var networkProtectionItem: NSMenuItem!
698+
var dataBrokerProtectionItem: NSMenuItem!
699+
var identityTheftRestorationItem: NSMenuItem!
700+
var subscriptionSettingsItem: NSMenuItem!
701+
702+
init(targeting target: AnyObject,
703+
subscriptionFeatureAvailability: SubscriptionFeatureAvailability,
704+
accountManager: AccountManager) {
705+
706+
self.subscriptionFeatureAvailability = subscriptionFeatureAvailability
707+
self.accountManager = accountManager
708+
709+
super.init(title: "")
710+
711+
self.networkProtectionItem = makeNetworkProtectionItem(target: target)
712+
self.dataBrokerProtectionItem = makeDataBrokerProtectionItem(target: target)
713+
self.identityTheftRestorationItem = makeIdentityTheftRestorationItem(target: target)
714+
self.subscriptionSettingsItem = makeSubscriptionSettingsItem(target: target)
715+
716+
delegate = self
717+
718+
addMenuItems()
719+
}
720+
721+
required init(coder: NSCoder) {
722+
fatalError("init(coder:) has not been implemented")
723+
}
724+
725+
private func addMenuItems() {
726+
addItem(networkProtectionItem)
727+
addItem(dataBrokerProtectionItem)
728+
addItem(identityTheftRestorationItem)
729+
addItem(NSMenuItem.separator())
730+
addItem(subscriptionSettingsItem)
731+
}
732+
733+
private func makeNetworkProtectionItem(target: AnyObject) -> NSMenuItem {
734+
return NSMenuItem(title: UserText.networkProtection,
735+
action: #selector(MoreOptionsMenu.showNetworkProtectionStatus(_:)),
736+
keyEquivalent: "")
737+
.targetting(target)
738+
.withImage(.image(for: .vpnIcon))
739+
}
740+
741+
private func makeDataBrokerProtectionItem(target: AnyObject) -> NSMenuItem {
742+
return NSMenuItem(title: UserText.dataBrokerProtectionOptionsMenuItem,
743+
action: #selector(MoreOptionsMenu.openDataBrokerProtection),
744+
keyEquivalent: "")
745+
.targetting(target)
746+
.withImage(.dbpIcon)
747+
}
748+
749+
private func makeIdentityTheftRestorationItem(target: AnyObject) -> NSMenuItem {
750+
return NSMenuItem(title: UserText.identityTheftRestorationOptionsMenuItem,
751+
action: #selector(MoreOptionsMenu.openIdentityTheftRestoration),
752+
keyEquivalent: "")
753+
.targetting(target)
754+
.withImage(.itrIcon)
755+
}
756+
757+
private func makeSubscriptionSettingsItem(target: AnyObject) -> NSMenuItem {
758+
return NSMenuItem(title: UserText.subscriptionSettingsOptionsMenuItem,
759+
action: #selector(MoreOptionsMenu.openSubscriptionSettings),
760+
keyEquivalent: "")
761+
.targetting(target)
762+
}
763+
764+
private func refreshAvailabilityBasedOnEntitlements() {
765+
guard subscriptionFeatureAvailability.isFeatureAvailable, accountManager.isUserAuthenticated else { return }
766+
767+
@Sendable func hasEntitlement(for productName: Entitlement.ProductName) async -> Bool {
768+
switch await self.accountManager.hasEntitlement(forProductName: productName) {
769+
case let .success(result):
770+
return result
771+
case .failure:
772+
return false
773+
}
774+
}
775+
776+
Task.detached(priority: .background) { [weak self] in
777+
guard let self else { return }
778+
779+
let isNetworkProtectionItemEnabled = await hasEntitlement(for: .networkProtection)
780+
let isDataBrokerProtectionItemEnabled = await hasEntitlement(for: .dataBrokerProtection)
781+
let isIdentityTheftRestorationItemEnabled = await hasEntitlement(for: .identityTheftRestoration)
782+
783+
Task { @MainActor in
784+
self.networkProtectionItem.isEnabled = isNetworkProtectionItemEnabled
785+
self.dataBrokerProtectionItem.isEnabled = isDataBrokerProtectionItemEnabled
786+
self.identityTheftRestorationItem.isEnabled = isIdentityTheftRestorationItemEnabled
787+
788+
DataBrokerProtectionExternalWaitlistPixels.fire(pixel: GeneralPixel.dataBrokerProtectionWaitlistEntryPointMenuItemDisplayed, frequency: .dailyAndCount)
789+
}
790+
}
791+
}
792+
793+
public func menuWillOpen(_ menu: NSMenu) {
794+
refreshAvailabilityBasedOnEntitlements()
795+
}
796+
797+
}
798+
773799
extension MoreOptionsMenu: EmailManagerRequestDelegate {}

DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift

+5-1
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ final class NavigationBarViewController: NSViewController {
275275
passwordManagerCoordinator: PasswordManagerCoordinator.shared,
276276
vpnFeatureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager),
277277
internalUserDecider: internalUserDecider,
278-
accountManager: subscriptionManager.accountManager)
278+
subscriptionManager: subscriptionManager)
279279
menu.actionDelegate = self
280280
let location = NSPoint(x: -menu.size.width + sender.bounds.width, y: sender.bounds.height + 4)
281281
menu.popUp(positioning: nil, at: location, in: sender)
@@ -1083,6 +1083,10 @@ extension NavigationBarViewController: OptionsButtonMenuDelegate {
10831083
PixelKit.fire(PrivacyProPixel.privacyProOfferScreenImpression)
10841084
}
10851085

1086+
func optionsButtonMenuRequestedSubscriptionPreferences(_ menu: NSMenu) {
1087+
WindowControllersManager.shared.showPreferencesTab(withSelectedPane: .subscription)
1088+
}
1089+
10861090
func optionsButtonMenuRequestedIdentityTheftRestoration(_ menu: NSMenu) {
10871091
let url = subscriptionManager.url(for: .identityTheftRestoration)
10881092
WindowControllersManager.shared.showTab(with: .identityTheftRestoration(url))

0 commit comments

Comments
 (0)