From 9ca4c67fcf7768411e2609397277c17ff1d9925f Mon Sep 17 00:00:00 2001 From: Juan Manuel Pereira Date: Tue, 28 Jan 2025 20:57:36 -0300 Subject: [PATCH] Add blue dot indicator to menu item --- DuckDuckGo-macOS.xcodeproj/project.pbxproj | 6 ++ DuckDuckGo/Application/AppDelegate.swift | 2 + DuckDuckGo/Application/DockCustomizer.swift | 27 ++++++++- .../MenuItemHoverColor.colorset/Contents.json | 38 +++++++++++++ DuckDuckGo/Menus/MainMenu.swift | 1 + DuckDuckGo/Menus/MainMenuActions.swift | 5 ++ .../View/MenuItemWithNotificationDot.swift | 57 +++++++++++++++++++ .../NavigationBar/View/MoreOptionsMenu.swift | 49 +++++++++++++--- .../View/MoreOptionsMenuButton.swift | 10 ++-- .../View/NavigationBarViewController.swift | 7 ++- 10 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 DuckDuckGo/Assets.xcassets/Colors/MenuItemHoverColor.colorset/Contents.json create mode 100644 DuckDuckGo/NavigationBar/View/MenuItemWithNotificationDot.swift diff --git a/DuckDuckGo-macOS.xcodeproj/project.pbxproj b/DuckDuckGo-macOS.xcodeproj/project.pbxproj index d029680ce9..8f91fa8f62 100644 --- a/DuckDuckGo-macOS.xcodeproj/project.pbxproj +++ b/DuckDuckGo-macOS.xcodeproj/project.pbxproj @@ -2876,6 +2876,8 @@ B6FA893F269C424500588ECD /* PrivacyDashboardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */; }; B6FA8941269C425400588ECD /* PrivacyDashboardPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */; }; BB0346F52CEB80B400D23E05 /* DownloadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */; }; + BB1A43902D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */; }; + BB1A43912D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */; }; BB3229052D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; }; BB3229062D08644400DA92E9 /* TabBarRemoteMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */; }; BB4339DB2C7F9606005D7ED7 /* PinnedTabsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */; }; @@ -4876,6 +4878,7 @@ B6FA893E269C424500588ECD /* PrivacyDashboardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardViewController.swift; sourceTree = ""; }; B6FA8940269C425400588ECD /* PrivacyDashboardPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyDashboardPopover.swift; sourceTree = ""; }; BB0346F42CEB80B400D23E05 /* DownloadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsTests.swift; sourceTree = ""; }; + BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemWithNotificationDot.swift; sourceTree = ""; }; BB3229042D08643700DA92E9 /* TabBarRemoteMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarRemoteMessageView.swift; sourceTree = ""; }; BB4339DA2C7F9606005D7ED7 /* PinnedTabsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinnedTabsTests.swift; sourceTree = ""; }; BB470EBA2C5A66D6002EE91D /* BookmarkManagementDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkManagementDetailViewModel.swift; sourceTree = ""; }; @@ -8428,6 +8431,7 @@ AA86491624D8339A001BABEE /* View */ = { isa = PBXGroup; children = ( + BB1A438F2D4968F2000807C7 /* MenuItemWithNotificationDot.swift */, BBB9314C2D1F0F1700D50AC1 /* ShowToolbarsOnFullScreenMenuCoordinator.swift */, AA7EB6EE27E880EA00036718 /* Animations */, AAC5E4F025D6BF10007F5990 /* AddressBarButton.swift */, @@ -11966,6 +11970,7 @@ B62B483A2ADE46FC000DECE5 /* Application.swift in Sources */, 3706FBEE293F65D500E42796 /* MainView.swift in Sources */, 3706FBEF293F65D500E42796 /* EmailUrlExtensions.swift in Sources */, + BB1A43912D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */, 3706FBF0293F65D500E42796 /* PasswordManagementItemModel.swift in Sources */, 3706FBF2293F65D500E42796 /* FindInPageModel.swift in Sources */, 1D9A4E5B2B43213B00F449E2 /* TabSnapshotExtension.swift in Sources */, @@ -13170,6 +13175,7 @@ 37878E562CA3330300CC9EB5 /* HomePageAddressBarModel.swift in Sources */, 85480FCF25D1AA22009424E3 /* ConfigurationStore.swift in Sources */, AA3D531B27A2F57E00074EC1 /* Feedback.swift in Sources */, + BB1A43902D4968F2000807C7 /* MenuItemWithNotificationDot.swift in Sources */, 4B0A63E8289DB58E00378EF7 /* FirefoxFaviconsReader.swift in Sources */, 1E7E2E9029029A2A00C01B54 /* ContentBlockingRulesUpdateObserver.swift in Sources */, 4B8AC93926B48A5100879451 /* FirefoxLoginReader.swift in Sources */, diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index 35b4a3fb27..78d584ba5e 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -164,6 +164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #if SPARKLE var updateController: UpdateController! + var dockCustomization: DockCustomization! #endif @UserDefaultsWrapper(key: .firstLaunchDate, defaultValue: Date.monthAgo) @@ -349,6 +350,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { #if SPARKLE if NSApp.runType != .uiTests { updateController = UpdateController(internalUserDecider: internalUserDecider) + dockCustomization = DockCustomizer() stateRestorationManager.subscribeToAutomaticAppRelaunching(using: updateController.willRelaunchAppPublisher) } #endif diff --git a/DuckDuckGo/Application/DockCustomizer.swift b/DuckDuckGo/Application/DockCustomizer.swift index f2baf5d894..0a13444842 100644 --- a/DuckDuckGo/Application/DockCustomizer.swift +++ b/DuckDuckGo/Application/DockCustomizer.swift @@ -17,22 +17,39 @@ // import Foundation +import Combine import Common import os.log +import Persistence protocol DockCustomization { var isAddedToDock: Bool { get } + var wasFeatureShownFromMoreOptionsMenu: Bool { get set } + var wasFeatureShownPublisher: AnyPublisher { get } @discardableResult func addToDock() -> Bool } final class DockCustomizer: DockCustomization { + enum Keys { + static let wasAddToDockFeatureShown = "more-options-menu.was-add-to-dock-shown" + } private let positionProvider: DockPositionProviding + private let keyValueStore: KeyValueStoring + + @Published private var isFeatureShownFromMoreOptionsMenu: Bool = false + var wasFeatureShownPublisher: AnyPublisher { + $isFeatureShownFromMoreOptionsMenu.eraseToAnyPublisher() + } - init(positionProvider: DockPositionProviding = DockPositionProvider()) { + init(positionProvider: DockPositionProviding = DockPositionProvider(), + keyValueStore: KeyValueStoring = UserDefaults.standard) { self.positionProvider = positionProvider + self.keyValueStore = keyValueStore + + isFeatureShownFromMoreOptionsMenu = keyValueStore.object(forKey: Keys.wasAddToDockFeatureShown) as? Bool ?? false } private var dockPlistURL: URL = URL(fileURLWithPath: NSString(string: "~/Library/Preferences/com.apple.dock.plist").expandingTildeInPath) @@ -53,6 +70,14 @@ final class DockCustomizer: DockCustomization { return persistentApps.contains(where: { ($0["tile-data"] as? [String: AnyObject])?["bundle-identifier"] as? String == bundleIdentifier }) } + var wasFeatureShownFromMoreOptionsMenu: Bool { + get { return isFeatureShownFromMoreOptionsMenu } + set { + isFeatureShownFromMoreOptionsMenu = newValue + keyValueStore.set(newValue, forKey: Keys.wasAddToDockFeatureShown) + } + } + // Adds a dictionary representing the application, either by using an existing // one from 'recent-apps' or creating a new one if the application isn't recently used. // It then inserts this dictionary into the 'persistent-apps' list at a position diff --git a/DuckDuckGo/Assets.xcassets/Colors/MenuItemHoverColor.colorset/Contents.json b/DuckDuckGo/Assets.xcassets/Colors/MenuItemHoverColor.colorset/Contents.json new file mode 100644 index 0000000000..ad368f77aa --- /dev/null +++ b/DuckDuckGo/Assets.xcassets/Colors/MenuItemHoverColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDE", + "green" : "0x78", + "red" : "0x64" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xA1", + "green" : "0x44", + "red" : "0x2D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DuckDuckGo/Menus/MainMenu.swift b/DuckDuckGo/Menus/MainMenu.swift index a39f5d2b14..7c05ec5a18 100644 --- a/DuckDuckGo/Menus/MainMenu.swift +++ b/DuckDuckGo/Menus/MainMenu.swift @@ -684,6 +684,7 @@ final class MainMenu: NSMenu { NSMenuItem(title: "Reset Home Page Settings Onboarding", action: #selector(MainViewController.resetHomePageSettingsOnboarding(_:))) NSMenuItem(title: "Reset Contextual Onboarding", action: #selector(MainViewController.resetContextualOnboarding(_:))) NSMenuItem(title: "Reset Sync Promo prompts", action: #selector(MainViewController.resetSyncPromoPrompts)) + NSMenuItem(title: "Reset Add To Dock more options menu notification", action: #selector(MainViewController.resetAddToDockFeatureNotification)) }.withAccessibilityIdentifier("MainMenu.resetData") NSMenuItem(title: "UI Triggers") { diff --git a/DuckDuckGo/Menus/MainMenuActions.swift b/DuckDuckGo/Menus/MainMenuActions.swift index 2260c37b48..4b96221603 100644 --- a/DuckDuckGo/Menus/MainMenuActions.swift +++ b/DuckDuckGo/Menus/MainMenuActions.swift @@ -912,6 +912,11 @@ extension MainViewController { SyncPromoManager().resetPromos() } + @objc func resetAddToDockFeatureNotification(_ sender: Any?) { + guard var dockCustomizer = Application.appDelegate.dockCustomization else { return } + dockCustomizer.wasFeatureShownFromMoreOptionsMenu = false + } + @objc func resetTipKit(_ sender: Any?) { TipKitDebugOptionsUIActionHandler().resetTipKitTapped() } diff --git a/DuckDuckGo/NavigationBar/View/MenuItemWithNotificationDot.swift b/DuckDuckGo/NavigationBar/View/MenuItemWithNotificationDot.swift new file mode 100644 index 0000000000..4581882e63 --- /dev/null +++ b/DuckDuckGo/NavigationBar/View/MenuItemWithNotificationDot.swift @@ -0,0 +1,57 @@ +// +// MenuItemWithNotificationDot.swift +// +// Copyright © 2025 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 + +/// View that represents a menu item that has a blue notification dot at the right. +struct MenuItemWithNotificationDot: View { + let leftImage: NSImage + let title: String + var onTapMenuItem: () -> Void + + @State private var isHovered: Bool = false + + var body: some View { + HStack(spacing: 0) { + Image(nsImage: leftImage) + .resizable() + .foregroundColor(isHovered ? .white : .blackWhite100) + .frame(width: 16, height: 16) + .padding([.leading, .trailing], 6) + + Text(title) + .foregroundColor(isHovered ? .white : .blackWhite100.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + + Circle() + .fill(isHovered ? .white : .updateIndicator) + .frame(width: 7, height: 7) + .padding(.trailing, 6) + } + .padding(4) + .background(isHovered ? .menuItemHover : Color.clear) + .cornerRadius(5) + .onHover { hovering in + isHovered = hovering + } + .padding(4) + .onTapGesture { + onTapMenuItem() + } + } +} diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift index f632d8b1b2..fa9218d485 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenu.swift @@ -26,6 +26,7 @@ import Subscription import os.log import Freemium import DataBrokerProtection +import SwiftUI protocol OptionsButtonMenuDelegate: AnyObject { @@ -65,7 +66,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { private let freemiumDBPFeature: FreemiumDBPFeature private let freemiumDBPPresenter: FreemiumDBPPresenter private let appearancePreferences: AppearancePreferences - private let dockCustomizer: DockCustomization + private var dockCustomizer: DockCustomization? private let defaultBrowserPreferences: DefaultBrowserPreferences private let notificationCenter: NotificationCenter @@ -94,7 +95,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { freemiumDBPFeature: FreemiumDBPFeature, freemiumDBPPresenter: FreemiumDBPPresenter = DefaultFreemiumDBPPresenter(), appearancePreferences: AppearancePreferences = .shared, - dockCustomizer: DockCustomization = DockCustomizer(), + dockCustomizer: DockCustomization? = nil, defaultBrowserPreferences: DefaultBrowserPreferences = .shared, notificationCenter: NotificationCenter = .default, freemiumDBPExperimentPixelHandler: EventMapping = FreemiumDBPExperimentPixelHandler(), @@ -153,12 +154,28 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { #endif // FEEDBACK -#if !APPSTORE - if !dockCustomizer.isAddedToDock { - let addToDockMenuItem = NSMenuItem(title: UserText.addDuckDuckGoToDock, action: #selector(addToDock(_:))) - .targetting(self) - .withImage(.addToDockMenuItem) - addItem(addToDockMenuItem) +#if SPARKLE + if let dockCustomizer = self.dockCustomizer { + if dockCustomizer.isAddedToDock == false { + if dockCustomizer.wasFeatureShownFromMoreOptionsMenu { + let addToDockMenuItem = NSMenuItem(title: UserText.addDuckDuckGoToDock, action: #selector(addToDock(_:))) + .targetting(self) + .withImage(.addToDockMenuItem) + addItem(addToDockMenuItem) + } else { + let addToDockMenuItem = NSMenuItem(action: #selector(addToDock(_:))) + .targetting(self) + addToDockMenuItem.view = createMenuItemWithFeatureIndicator( + title: UserText.addDuckDuckGoToDock, + image: .addToDockMenuItem) { + if let target = addToDockMenuItem.target { + _ = target.perform(addToDockMenuItem.action, with: addToDockMenuItem) + // TODO: Need to close the menu when this happens + } + } + addItem(addToDockMenuItem) + } + } } #endif if !defaultBrowserPreferences.isDefault { @@ -199,6 +216,16 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { addItem(preferencesItem) } + private func createMenuItemWithFeatureIndicator(title: String, image: NSImage, onTap: @escaping () -> Void) -> NSView { + let menuItem = MenuItemWithNotificationDot(leftImage: image, title: title, onTapMenuItem: onTap) + + let hostingView = NSHostingView(rootView: menuItem) + hostingView.frame = NSRect(x: 0, y: 0, width: size.width, height: 22) + hostingView.autoresizingMask = [.width, .height] + + return hostingView + } + @objc func openDataBrokerProtection(_ sender: NSMenuItem) { actionDelegate?.optionsButtonMenuRequestedDataBrokerProtection(self) } @@ -210,7 +237,7 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { @MainActor @objc func addToDock(_ sender: NSMenuItem) { PixelKit.fire(GeneralPixel.userAddedToDockFromMoreOptionsMenu) - dockCustomizer.addToDock() + dockCustomizer?.addToDock() } @MainActor @@ -530,6 +557,10 @@ final class MoreOptionsMenu: NSMenu, NSMenuDelegate { } #endif } + + func menuDidClose(_ menu: NSMenu) { + dockCustomizer?.wasFeatureShownFromMoreOptionsMenu = true + } } final class EmailOptionsButtonSubMenu: NSMenu { diff --git a/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift b/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift index a81893b20e..d64df4e779 100644 --- a/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift +++ b/DuckDuckGo/NavigationBar/View/MoreOptionsMenuButton.swift @@ -26,6 +26,7 @@ final class MoreOptionsMenuButton: MouseOverButton { #if SPARKLE private var updateController: UpdateControllerProtocol? + private var dockCustomization: DockCustomization? #endif private var notificationLayer: CALayer? @@ -52,6 +53,7 @@ final class MoreOptionsMenuButton: MouseOverButton { #if SPARKLE if NSApp.runType != .uiTests { updateController = Application.appDelegate.updateController + dockCustomization = Application.appDelegate.dockCustomization } subscribeToUpdateInfo() #endif @@ -64,11 +66,11 @@ final class MoreOptionsMenuButton: MouseOverButton { private func subscribeToUpdateInfo() { #if SPARKLE - guard let updateController else { return } - cancellable = Publishers.CombineLatest(updateController.hasPendingUpdatePublisher, updateController.notificationDotPublisher) + guard let updateController, let dockCustomization else { return } + cancellable = Publishers.CombineLatest3(updateController.hasPendingUpdatePublisher, updateController.notificationDotPublisher, dockCustomization.wasFeatureShownPublisher) .receive(on: DispatchQueue.main) - .sink { [weak self] hasPendingUpdate, needsNotificationDot in - self?.isNotificationVisible = hasPendingUpdate && needsNotificationDot + .sink { [weak self] hasPendingUpdate, needsNotificationDot, wasAddToDockFeatureShown in + self?.isNotificationVisible = hasPendingUpdate && needsNotificationDot || !wasAddToDockFeatureShown } #endif } diff --git a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift index 11280b7ede..9d7f44d263 100644 --- a/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/NavigationBarViewController.swift @@ -304,12 +304,17 @@ final class NavigationBarViewController: NSViewController { @IBAction func optionsButtonAction(_ sender: NSButton) { let internalUserDecider = NSApp.delegateTyped.internalUserDecider let freemiumDBPFeature = Application.appDelegate.freemiumDBPFeature + var dockCustomization: DockCustomization? = nil +#if SPARKLE + dockCustomization = Application.appDelegate.dockCustomization +#endif let menu = MoreOptionsMenu(tabCollectionViewModel: tabCollectionViewModel, passwordManagerCoordinator: PasswordManagerCoordinator.shared, vpnFeatureGatekeeper: DefaultVPNFeatureGatekeeper(subscriptionManager: subscriptionManager), internalUserDecider: internalUserDecider, subscriptionManager: subscriptionManager, - freemiumDBPFeature: freemiumDBPFeature) + freemiumDBPFeature: freemiumDBPFeature, + dockCustomizer: dockCustomization) menu.actionDelegate = self let location = NSPoint(x: -menu.size.width + sender.bounds.width, y: sender.bounds.height + 4)