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

Commit 9ca4c67

Browse files
Add blue dot indicator to menu item
1 parent 2357cba commit 9ca4c67

File tree

10 files changed

+187
-15
lines changed

10 files changed

+187
-15
lines changed

DuckDuckGo-macOS.xcodeproj/project.pbxproj

+6
Original file line numberDiff line numberDiff line change
@@ -2876,6 +2876,8 @@
28762876
B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; };
28772877
B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; };
28782878
BB0346F52CEB80B400D23E05 /* DownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */; };
2879+
BB1A43902D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */; };
2880+
BB1A43912D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */; };
28792881
BB3229052D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; };
28802882
BB3229062D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; };
28812883
BB4339DB2C7F9606005D7ED7 /* PinnedTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */; };
@@ -4876,6 +4878,7 @@
48764878
B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = "<group>"; };
48774879
B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = "<group>"; };
48784880
BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTests.swift; sourceTree = "<group>"; };
4881+
BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemWithNotificationDot.swift; sourceTree = "<group>"; };
48794882
BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRemoteMessageView.swift; sourceTree = "<group>"; };
48804883
BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTabsTests.swift; sourceTree = "<group>"; };
48814884
BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManagementDetailViewModel.swift; sourceTree = "<group>"; };
@@ -8428,6 +8431,7 @@
84288431
AA86491624D8339A001BABEE /* View */ = {
84298432
isa = PBXGroup;
84308433
children = (
8434+
BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */,
84318435
BBB9314C2D1F0F1700D50AC1 /* ShowToolbarsOnFullScreenMenuCoordinator.swift */,
84328436
AA7EB6EE27E880EA00036718 /* Animations */,
84338437
AAC5E4F025D6BF10007F5990 /* AddressBarButton.swift */,
@@ -11966,6 +11970,7 @@
1196611970
B62B483A2ADE46FC000DECE5 /* Application.swift in Sources */,
1196711971
3706FBEE293F65D500E42796 /* MainView.swift in Sources */,
1196811972
3706FBEF293F65D500E42796 /* EmailUrlExtensions.swift in Sources */,
11973+
BB1A43912D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */,
1196911974
3706FBF0293F65D500E42796 /* PasswordManagementItemModel.swift in Sources */,
1197011975
3706FBF2293F65D500E42796 /* FindInPageModel.swift in Sources */,
1197111976
1D9A4E5B2B43213B00F449E2 /* TabSnapshotExtension.swift in Sources */,
@@ -13170,6 +13175,7 @@
1317013175
37878E562CA3330300CC9EB5 /* HomePageAddressBarModel.swift in Sources */,
1317113176
85480FCF25D1AA22009424E3 /* ConfigurationStore.swift in Sources */,
1317213177
AA3D531B27A2F57E00074EC1 /* Feedback.swift in Sources */,
13178+
BB1A43902D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */,
1317313179
4B0A63E8289DB58E00378EF7 /* FirefoxFaviconsReader.swift in Sources */,
1317413180
1E7E2E9029029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift in Sources */,
1317513181
4B8AC93926B48A5100879451 /* FirefoxLoginReader.swift in Sources */,

DuckDuckGo/Application/AppDelegate.swift

+2
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
164164

165165
#if SPARKLE
166166
var updateController: UpdateController!
167+
var dockCustomization: DockCustomization!
167168
#endif
168169

169170
@UserDefaultsWrapper(key: .firstLaunchDate, defaultValue: Date.monthAgo)
@@ -349,6 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
349350
#if SPARKLE
350351
if NSApp.runType != .uiTests {
351352
updateController = UpdateController(internalUserDecider: internalUserDecider)
353+
dockCustomization = DockCustomizer()
352354
stateRestorationManager.subscribeToAutomaticAppRelaunching(using: updateController.willRelaunchAppPublisher)
353355
}
354356
#endif

DuckDuckGo/Application/DockCustomizer.swift

+26-1
Original file line numberDiff line numberDiff line change
@@ -17,22 +17,39 @@
1717
//
1818

1919
import Foundation
20+
import Combine
2021
import Common
2122
import os.log
23+
import Persistence
2224

2325
protocol DockCustomization {
2426
var isAddedToDock: Bool { get }
27+
var wasFeatureShownFromMoreOptionsMenu: Bool { get set }
28+
var wasFeatureShownPublisher: AnyPublisher<Bool, Never> { get }
2529

2630
@discardableResult
2731
func addToDock() -> Bool
2832
}
2933

3034
final class DockCustomizer: DockCustomization {
35+
enum Keys {
36+
static let wasAddToDockFeatureShown = "more-options-menu.was-add-to-dock-shown"
37+
}
3138

3239
private let positionProvider: DockPositionProviding
40+
private let keyValueStore: KeyValueStoring
41+
42+
@Published private var isFeatureShownFromMoreOptionsMenu: Bool = false
43+
var wasFeatureShownPublisher: AnyPublisher<Bool, Never> {
44+
$isFeatureShownFromMoreOptionsMenu.eraseToAnyPublisher()
45+
}
3346

34-
init(positionProvider: DockPositionProviding = DockPositionProvider()) {
47+
init(positionProvider: DockPositionProviding = DockPositionProvider(),
48+
keyValueStore: KeyValueStoring = UserDefaults.standard) {
3549
self.positionProvider = positionProvider
50+
self.keyValueStore = keyValueStore
51+
52+
isFeatureShownFromMoreOptionsMenu = keyValueStore.object(forKey: Keys.wasAddToDockFeatureShown) as? Bool ?? false
3653
}
3754

3855
private var dockPlistURL: URL = URL(fileURLWithPath: NSString(string: "~/Library/Preferences/com.apple.dock.plist").expandingTildeInPath)
@@ -53,6 +70,14 @@ final class DockCustomizer: DockCustomization {
5370
return persistentApps.contains(where: { ($0["tile-data"] as? [String: AnyObject])?["bundle-identifier"] as? String == bundleIdentifier })
5471
}
5572

73+
var wasFeatureShownFromMoreOptionsMenu: Bool {
74+
get { return isFeatureShownFromMoreOptionsMenu }
75+
set {
76+
isFeatureShownFromMoreOptionsMenu = newValue
77+
keyValueStore.set(newValue, forKey: Keys.wasAddToDockFeatureShown)
78+
}
79+
}
80+
5681
// Adds a dictionary representing the application, either by using an existing
5782
// one from 'recent-apps' or creating a new one if the application isn't recently used.
5883
// It then inserts this dictionary into the 'persistent-apps' list at a position
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"colors" : [
3+
{
4+
"color" : {
5+
"color-space" : "srgb",
6+
"components" : {
7+
"alpha" : "1.000",
8+
"blue" : "0xDE",
9+
"green" : "0x78",
10+
"red" : "0x64"
11+
}
12+
},
13+
"idiom" : "universal"
14+
},
15+
{
16+
"appearances" : [
17+
{
18+
"appearance" : "luminosity",
19+
"value" : "dark"
20+
}
21+
],
22+
"color" : {
23+
"color-space" : "srgb",
24+
"components" : {
25+
"alpha" : "1.000",
26+
"blue" : "0xA1",
27+
"green" : "0x44",
28+
"red" : "0x2D"
29+
}
30+
},
31+
"idiom" : "universal"
32+
}
33+
],
34+
"info" : {
35+
"author" : "xcode",
36+
"version" : 1
37+
}
38+
}

DuckDuckGo/Menus/MainMenu.swift

+1
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,7 @@ final class MainMenu: NSMenu {
684684
NSMenuItem(title: "Reset Home Page Settings Onboarding", action: #selector(MainViewController.resetHomePageSettingsOnboarding(_:)))
685685
NSMenuItem(title: "Reset Contextual Onboarding", action: #selector(MainViewController.resetContextualOnboarding(_:)))
686686
NSMenuItem(title: "Reset Sync Promo prompts", action: #selector(MainViewController.resetSyncPromoPrompts))
687+
NSMenuItem(title: "Reset Add To Dock more options menu notification", action: #selector(MainViewController.resetAddToDockFeatureNotification))
687688

688689
}.withAccessibilityIdentifier("MainMenu.resetData")
689690
NSMenuItem(title: "UI Triggers") {

DuckDuckGo/Menus/MainMenuActions.swift

+5
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,11 @@ extension MainViewController {
912912
SyncPromoManager().resetPromos()
913913
}
914914

915+
@objc func resetAddToDockFeatureNotification(_ sender: Any?) {
916+
guard var dockCustomizer = Application.appDelegate.dockCustomization else { return }
917+
dockCustomizer.wasFeatureShownFromMoreOptionsMenu = false
918+
}
919+
915920
@objc func resetTipKit(_ sender: Any?) {
916921
TipKitDebugOptionsUIActionHandler().resetTipKitTapped()
917922
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// MenuItemWithNotificationDot.swift
3+
//
4+
// Copyright © 2025 DuckDuckGo. All rights reserved.
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
//
18+
19+
import SwiftUI
20+
21+
/// View that represents a menu item that has a blue notification dot at the right.
22+
struct MenuItemWithNotificationDot: View {
23+
let leftImage: NSImage
24+
let title: String
25+
var onTapMenuItem: () -> Void
26+
27+
@State private var isHovered: Bool = false
28+
29+
var body: some View {
30+
HStack(spacing: 0) {
31+
Image(nsImage: leftImage)
32+
.resizable()
33+
.foregroundColor(isHovered ? .white : .blackWhite100)
34+
.frame(width: 16, height: 16)
35+
.padding([.leading, .trailing], 6)
36+
37+
Text(title)
38+
.foregroundColor(isHovered ? .white : .blackWhite100.opacity(0.9))
39+
.frame(maxWidth: .infinity, alignment: .leading)
40+
41+
Circle()
42+
.fill(isHovered ? .white : .updateIndicator)
43+
.frame(width: 7, height: 7)
44+
.padding(.trailing, 6)
45+
}
46+
.padding(4)
47+
.background(isHovered ? .menuItemHover : Color.clear)
48+
.cornerRadius(5)
49+
.onHover { hovering in
50+
isHovered = hovering
51+
}
52+
.padding(4)
53+
.onTapGesture {
54+
onTapMenuItem()
55+
}
56+
}
57+
}

DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift

+40-9
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import Subscription
2626
import os.log
2727
import Freemium
2828
import DataBrokerProtection
29+
import SwiftUI
2930

3031
protocol OptionsButtonMenuDelegate: AnyObject {
3132

@@ -65,7 +66,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
6566
private let freemiumDBPFeature: FreemiumDBPFeature
6667
private let freemiumDBPPresenter: FreemiumDBPPresenter
6768
private let appearancePreferences: AppearancePreferences
68-
private let dockCustomizer: DockCustomization
69+
private var dockCustomizer: DockCustomization?
6970
private let defaultBrowserPreferences: DefaultBrowserPreferences
7071

7172
private let notificationCenter: NotificationCenter
@@ -94,7 +95,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
9495
freemiumDBPFeature: FreemiumDBPFeature,
9596
freemiumDBPPresenter: FreemiumDBPPresenter = DefaultFreemiumDBPPresenter(),
9697
appearancePreferences: AppearancePreferences = .shared,
97-
dockCustomizer: DockCustomization = DockCustomizer(),
98+
dockCustomizer: DockCustomization? = nil,
9899
defaultBrowserPreferences: DefaultBrowserPreferences = .shared,
99100
notificationCenter: NotificationCenter = .default,
100101
freemiumDBPExperimentPixelHandler: EventMapping<FreemiumDBPExperimentPixel> = FreemiumDBPExperimentPixelHandler(),
@@ -153,12 +154,28 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
153154

154155
#endif // FEEDBACK
155156

156-
#if !APPSTORE
157-
if !dockCustomizer.isAddedToDock {
158-
let addToDockMenuItem = NSMenuItem(title: UserText.addDuckDuckGoToDock, action: #selector(addToDock(_:)))
159-
.targetting(self)
160-
.withImage(.addToDockMenuItem)
161-
addItem(addToDockMenuItem)
157+
#if SPARKLE
158+
if let dockCustomizer = self.dockCustomizer {
159+
if dockCustomizer.isAddedToDock == false {
160+
if dockCustomizer.wasFeatureShownFromMoreOptionsMenu {
161+
let addToDockMenuItem = NSMenuItem(title: UserText.addDuckDuckGoToDock, action: #selector(addToDock(_:)))
162+
.targetting(self)
163+
.withImage(.addToDockMenuItem)
164+
addItem(addToDockMenuItem)
165+
} else {
166+
let addToDockMenuItem = NSMenuItem(action: #selector(addToDock(_:)))
167+
.targetting(self)
168+
addToDockMenuItem.view = createMenuItemWithFeatureIndicator(
169+
title: UserText.addDuckDuckGoToDock,
170+
image: .addToDockMenuItem) {
171+
if let target = addToDockMenuItem.target {
172+
_ = target.perform(addToDockMenuItem.action, with: addToDockMenuItem)
173+
// TODO: Need to close the menu when this happens
174+
}
175+
}
176+
addItem(addToDockMenuItem)
177+
}
178+
}
162179
}
163180
#endif
164181
if !defaultBrowserPreferences.isDefault {
@@ -199,6 +216,16 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
199216
addItem(preferencesItem)
200217
}
201218

219+
private func createMenuItemWithFeatureIndicator(title: String, image: NSImage, onTap: @escaping () -> Void) -> NSView {
220+
let menuItem = MenuItemWithNotificationDot(leftImage: image, title: title, onTapMenuItem: onTap)
221+
222+
let hostingView = NSHostingView(rootView: menuItem)
223+
hostingView.frame = NSRect(x: 0, y: 0, width: size.width, height: 22)
224+
hostingView.autoresizingMask = [.width, .height]
225+
226+
return hostingView
227+
}
228+
202229
@objc func openDataBrokerProtection(_ sender: NSMenuItem) {
203230
actionDelegate?.optionsButtonMenuRequestedDataBrokerProtection(self)
204231
}
@@ -210,7 +237,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
210237
@MainActor
211238
@objc func addToDock(_ sender: NSMenuItem) {
212239
PixelKit.fire(GeneralPixel.userAddedToDockFromMoreOptionsMenu)
213-
dockCustomizer.addToDock()
240+
dockCustomizer?.addToDock()
214241
}
215242

216243
@MainActor
@@ -530,6 +557,10 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate {
530557
}
531558
#endif
532559
}
560+
561+
func menuDidClose(_ menu: NSMenu) {
562+
dockCustomizer?.wasFeatureShownFromMoreOptionsMenu = true
563+
}
533564
}
534565

535566
final class EmailOptionsButtonSubMenu: NSMenu {

DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift

+6-4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ final class MoreOptionsMenuButton: MouseOverButton {
2626

2727
#if SPARKLE
2828
private var updateController: UpdateControllerProtocol?
29+
private var dockCustomization: DockCustomization?
2930
#endif
3031

3132
private var notificationLayer: CALayer?
@@ -52,6 +53,7 @@ final class MoreOptionsMenuButton: MouseOverButton {
5253
#if SPARKLE
5354
if NSApp.runType != .uiTests {
5455
updateController = Application.appDelegate.updateController
56+
dockCustomization = Application.appDelegate.dockCustomization
5557
}
5658
subscribeToUpdateInfo()
5759
#endif
@@ -64,11 +66,11 @@ final class MoreOptionsMenuButton: MouseOverButton {
6466

6567
private func subscribeToUpdateInfo() {
6668
#if SPARKLE
67-
guard let updateController else { return }
68-
cancellable = Publishers.CombineLatest(updateController.hasPendingUpdatePublisher, updateController.notificationDotPublisher)
69+
guard let updateController, let dockCustomization else { return }
70+
cancellable = Publishers.CombineLatest3(updateController.hasPendingUpdatePublisher, updateController.notificationDotPublisher, dockCustomization.wasFeatureShownPublisher)
6971
.receive(on: DispatchQueue.main)
70-
.sink { [weak self] hasPendingUpdate, needsNotificationDot in
71-
self?.isNotificationVisible = hasPendingUpdate && needsNotificationDot
72+
.sink { [weak self] hasPendingUpdate, needsNotificationDot, wasAddToDockFeatureShown in
73+
self?.isNotificationVisible = hasPendingUpdate && needsNotificationDot || !wasAddToDockFeatureShown
7274
}
7375
#endif
7476
}

DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,17 @@ final class NavigationBarViewController: NSViewController {
304304
@IBAction func optionsButtonAction(_ sender: NSButton) {
305305
let internalUserDecider = NSApp.delegateTyped.internalUserDecider
306306
let freemiumDBPFeature = Application.appDelegate.freemiumDBPFeature
307+
var dockCustomization: DockCustomization? = nil
308+
#if SPARKLE
309+
dockCustomization = Application.appDelegate.dockCustomization
310+
#endif
307311
let menu = MoreOptionsMenu(tabCollectionViewModel: tabCollectionViewModel,
308312
passwordManagerCoordinator: PasswordManagerCoordinator.shared,
309313
vpnFeatureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager),
310314
internalUserDecider: internalUserDecider,
311315
subscriptionManager: subscriptionManager,
312-
freemiumDBPFeature: freemiumDBPFeature)
316+
freemiumDBPFeature: freemiumDBPFeature,
317+
dockCustomizer: dockCustomization)
313318

314319
menu.actionDelegate = self
315320
let location = NSPoint(x: -menu.size.width + sender.bounds.width, y: sender.bounds.height + 4)

0 commit comments

Comments
 (0)