From 46cdf5775ed3f56dc861501818ca77c1b014c3e4 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 30 Aug 2024 15:36:07 -0400 Subject: [PATCH 1/2] [iOS] Add new browser menu UI This adds a new modern browser menu UI behind the feature flag `brave-use-modern-browser-menu` --- .../App/Client.xcodeproj/Brave.xctestplan | 93 ++-- ios/brave-ios/Package.swift | 12 +- .../BrowserViewController/BVC+Menu.swift | 461 ++++++++++++++-- .../BVC+ToolbarDelegate.swift | 12 + .../Brave/Frontend/Share/MenuActivity.swift | 3 +- .../Sources/BraveStrings/BraveStrings.swift | 4 +- .../SwiftUI/AnyTransitionExtensions.swift | 37 ++ ios/brave-ios/Sources/BraveVPN/BraveVPN.swift | 14 +- .../BraveVPN/Resources/BraveVPNStrings.swift | 14 + .../Sources/BrowserMenu/Action.swift | 101 ++++ .../Sources/BrowserMenu/BrowserMenu.swift | 516 ++++++++++++++++++ .../BrowserMenu/BrowserMenuController.swift | 57 ++ .../BrowserMenu/BrowserMenuModel.swift | 186 +++++++ .../BrowserMenu/BrowserMenuPreferences.swift | 20 + .../BrowserMenu/BrowserMenuStrings.swift | 124 +++++ .../BrowserMenu/CustomizeMenuView.swift | 106 ++++ .../Sources/BrowserMenu/Identifiers.swift | 242 ++++++++ .../Resources/ar.lproj/Localizable.strings | 1 + .../Resources/bs.lproj/Localizable.strings | 1 + .../Resources/ca.lproj/Localizable.strings | 1 + .../Resources/cs.lproj/Localizable.strings | 1 + .../Resources/da.lproj/Localizable.strings | 1 + .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/el.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/es.lproj/Localizable.strings | 1 + .../Resources/fi.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/gsw.lproj/Localizable.strings | 1 + .../Resources/hr.lproj/Localizable.strings | 1 + .../Resources/hu.lproj/Localizable.strings | 1 + .../Resources/id-ID.lproj/Localizable.strings | 1 + .../Resources/it.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko-KR.lproj/Localizable.strings | 1 + .../Resources/ms.lproj/Localizable.strings | 1 + .../Resources/nb.lproj/Localizable.strings | 1 + .../Resources/nl.lproj/Localizable.strings | 1 + .../Resources/pl.lproj/Localizable.strings | 1 + .../Resources/pt-BR.lproj/Localizable.strings | 1 + .../Resources/pt-PT.lproj/Localizable.strings | 1 + .../Resources/pt.lproj/Localizable.strings | 1 + .../Resources/ro.lproj/Localizable.strings | 1 + .../Resources/ru.lproj/Localizable.strings | 1 + .../Resources/sk.lproj/Localizable.strings | 1 + .../Resources/sv.lproj/Localizable.strings | 1 + .../Resources/tr.lproj/Localizable.strings | 1 + .../Resources/uk.lproj/Localizable.strings | 1 + .../Resources/zh-TW.lproj/Localizable.strings | 1 + .../Resources/zh.lproj/Localizable.strings | 1 + .../Sources/BrowserMenu/VPNStatus.swift | 72 +++ .../BrowserMenuTests/BrowserMenuTests.swift | 325 +++++++++++ ios/browser/api/features/BUILD.gn | 1 + ios/browser/api/features/features.h | 1 + ios/browser/api/features/features.mm | 6 + ios/browser/flags/about_flags.mm | 8 + ios/browser/flags/sources.gni | 1 + ios/browser/ui/browser_menu/BUILD.gn | 12 + ios/browser/ui/browser_menu/features.h | 18 + ios/browser/ui/browser_menu/features.mm | 14 + ios/nala/BUILD.gn | 8 + 61 files changed, 2407 insertions(+), 94 deletions(-) create mode 100644 ios/brave-ios/Sources/BraveUI/SwiftUI/AnyTransitionExtensions.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/Action.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/BrowserMenuController.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/BrowserMenuPreferences.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/BrowserMenuStrings.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/CustomizeMenuView.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/Identifiers.swift create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ar.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/bs.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ca.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/cs.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/da.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/de.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/el.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/en.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/es.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/fi.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/fr.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/gsw.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/hr.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/hu.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/id-ID.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/it.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ja.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ko-KR.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ms.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/nb.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/nl.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/pl.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/pt-BR.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/pt-PT.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/pt.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ro.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/ru.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/sk.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/sv.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/tr.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/uk.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/zh-TW.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/Resources/zh.lproj/Localizable.strings create mode 100644 ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift create mode 100644 ios/brave-ios/Tests/BrowserMenuTests/BrowserMenuTests.swift create mode 100644 ios/browser/ui/browser_menu/BUILD.gn create mode 100644 ios/browser/ui/browser_menu/features.h create mode 100644 ios/browser/ui/browser_menu/features.mm diff --git a/ios/brave-ios/App/Client.xcodeproj/Brave.xctestplan b/ios/brave-ios/App/Client.xcodeproj/Brave.xctestplan index 1091c6653638..ee92679d60b9 100644 --- a/ios/brave-ios/App/Client.xcodeproj/Brave.xctestplan +++ b/ios/brave-ios/App/Client.xcodeproj/Brave.xctestplan @@ -30,118 +30,118 @@ }, "testTargets" : [ { - "skippedTests" : [ - "CertificatePinningTest\/testSelfSignedRootAllowed2()" - ], "target" : { "containerPath" : "container:..", - "identifier" : "BraveSharedTests", - "name" : "BraveSharedTests" + "identifier" : "AIChatTests", + "name" : "AIChatTests" } }, { - "skippedTests" : [ - "DecentralizedDNSHelperTests" - ], "target" : { "containerPath" : "container:..", - "identifier" : "BraveWalletTests", - "name" : "BraveWalletTests" + "identifier" : "BraveVPNTests", + "name" : "BraveVPNTests" } }, { "skippedTests" : [ - "NavigationRouterTests", - "PlaylistTests\/testVideoPlayerTrackBarTimeManualFormatter()", - "SyncTests", - "TabSessionTests", - "TestFavicons", - "URLFormatTests" + "CertificatePinningTest\/testSelfSignedRootAllowed2()" ], "target" : { "containerPath" : "container:..", - "identifier" : "ClientTests", - "name" : "ClientTests" + "identifier" : "CertificateUtilitiesTests", + "name" : "CertificateUtilitiesTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "DataTests", - "name" : "DataTests" + "identifier" : "PrivateCDNTests", + "name" : "PrivateCDNTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "SharedTests", - "name" : "SharedTests" + "identifier" : "DataTests", + "name" : "DataTests" } }, { + "skippedTests" : [ + "CertificatePinningTest\/testSelfSignedRootAllowed2()" + ], "target" : { "containerPath" : "container:..", - "identifier" : "StorageTests", - "name" : "StorageTests" + "identifier" : "BraveSharedTests", + "name" : "BraveSharedTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "BraveNewsTests", - "name" : "BraveNewsTests" + "identifier" : "BrowserMenuTests", + "name" : "BrowserMenuTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "BraveTalkTests", - "name" : "BraveTalkTests" + "identifier" : "UserAgentTests", + "name" : "UserAgentTests" } }, { - "skippedTests" : [ - "CertificatePinningTest\/testSelfSignedRootAllowed2()" - ], "target" : { "containerPath" : "container:..", - "identifier" : "CertificateUtilitiesTests", - "name" : "CertificateUtilitiesTests" + "identifier" : "BraveNewsTests", + "name" : "BraveNewsTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "GrowthTests", - "name" : "GrowthTests" + "identifier" : "StorageTests", + "name" : "StorageTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "PrivateCDNTests", - "name" : "PrivateCDNTests" + "identifier" : "SharedTests", + "name" : "SharedTests" } }, { + "skippedTests" : [ + "NavigationRouterTests", + "PlaylistTests\/testVideoPlayerTrackBarTimeManualFormatter()", + "SyncTests", + "TabSessionTests", + "TestFavicons", + "URLFormatTests" + ], "target" : { "containerPath" : "container:..", - "identifier" : "BraveVPNTests", - "name" : "BraveVPNTests" + "identifier" : "ClientTests", + "name" : "ClientTests" } }, { + "skippedTests" : [ + "DecentralizedDNSHelperTests" + ], "target" : { "containerPath" : "container:..", - "identifier" : "UserAgentTests", - "name" : "UserAgentTests" + "identifier" : "BraveWalletTests", + "name" : "BraveWalletTests" } }, { "target" : { "containerPath" : "container:..", - "identifier" : "AIChatTests", - "name" : "AIChatTests" + "identifier" : "GrowthTests", + "name" : "GrowthTests" } }, { @@ -150,6 +150,13 @@ "identifier" : "PlaylistUITests", "name" : "PlaylistUITests" } + }, + { + "target" : { + "containerPath" : "container:..", + "identifier" : "BraveTalkTests", + "name" : "BraveTalkTests" + } } ], "version" : 1 diff --git a/ios/brave-ios/Package.swift b/ios/brave-ios/Package.swift index 9082719539ff..0716999bcf58 100644 --- a/ios/brave-ios/Package.swift +++ b/ios/brave-ios/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 5.10 // The swift-tools-version declares the minimum version of Swift required to build this package. import Foundation @@ -45,6 +45,7 @@ var package = Package( .library(name: "UserAgent", targets: ["UserAgent"]), .library(name: "CredentialProviderUI", targets: ["CredentialProviderUI"]), .library(name: "PlaylistUI", targets: ["PlaylistUI"]), + .library(name: "BrowserMenu", targets: ["BrowserMenu"]), .executable(name: "LeoAssetCatalogGenerator", targets: ["LeoAssetCatalogGenerator"]), .plugin(name: "IntentBuilderPlugin", targets: ["IntentBuilderPlugin"]), .plugin(name: "LoggerPlugin", targets: ["LoggerPlugin"]), @@ -422,6 +423,14 @@ var package = Package( dependencies: ["PlaylistUI", "Playlist", "Preferences", "Data", "TestHelpers"], resources: [.copy("Resources/Big_Buck_Bunny_360_10s_1MB.mp4")] ), + .target( + name: "BrowserMenu", + dependencies: [ + "DesignSystem", "BraveUI", "Preferences", "Strings", "BraveStrings", "BraveVPN", + "GuardianConnect", "BraveWallet", + ] + ), + .testTarget(name: "BrowserMenuTests", dependencies: ["BrowserMenu"]), .plugin(name: "IntentBuilderPlugin", capability: .buildTool()), .plugin(name: "LoggerPlugin", capability: .buildTool()), .plugin( @@ -469,6 +478,7 @@ var braveTarget: PackageDescription.Target = .target( .product(name: "Lottie", package: "lottie-spm"), .product(name: "Collections", package: "swift-collections"), "PlaylistUI", + "BrowserMenu", ], exclude: [ "Frontend/UserContent/UserScripts/AllFrames", diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift index bb64eca3550e..74dd1f70fc46 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+Menu.swift @@ -8,6 +8,7 @@ import BraveShared import BraveUI import BraveVPN import BraveWallet +import BrowserMenu import Data import Foundation import PlaylistUI @@ -233,52 +234,56 @@ extension BrowserViewController { } } MenuItemFactory.button(for: .settings) { [unowned self, unowned menuController] in - let isPrivateMode = privateBrowsingManager.isPrivateBrowsing - let keyringService = BraveWallet.KeyringServiceFactory.get(privateMode: isPrivateMode) - let walletService = BraveWallet.ServiceFactory.get(privateMode: isPrivateMode) - let rpcService = BraveWallet.JsonRpcServiceFactory.get(privateMode: isPrivateMode) - let walletP3A = braveCore.braveWalletAPI.walletP3A() - - var keyringStore: KeyringStore? = walletStore?.keyringStore - if keyringStore == nil { - if let keyringService = keyringService, - let walletService = walletService, - let rpcService = rpcService, - let walletP3A - { - keyringStore = KeyringStore( - keyringService: keyringService, - walletService: walletService, - rpcService: rpcService, - walletP3A: walletP3A - ) - } - } - - var cryptoStore: CryptoStore? = walletStore?.cryptoStore - if cryptoStore == nil { - cryptoStore = CryptoStore.from( - ipfsApi: braveCore.ipfsAPI, - walletP3A: walletP3A, - privateMode: isPrivateMode - ) - } + menuController.pushInnerMenu(self.settingsController) + } + } + } - let vc = SettingsViewController( - profile: self.profile, - tabManager: self.tabManager, - feedDataSource: self.feedDataSource, - rewards: self.rewards, - windowProtection: self.windowProtection, - braveCore: self.braveCore, - attributionManager: attributionManager, - keyringStore: keyringStore, - cryptoStore: cryptoStore + private var settingsController: SettingsViewController { + let isPrivateMode = privateBrowsingManager.isPrivateBrowsing + let keyringService = BraveWallet.KeyringServiceFactory.get(privateMode: isPrivateMode) + let walletService = BraveWallet.ServiceFactory.get(privateMode: isPrivateMode) + let rpcService = BraveWallet.JsonRpcServiceFactory.get(privateMode: isPrivateMode) + let walletP3A = braveCore.braveWalletAPI.walletP3A() + + var keyringStore: KeyringStore? = walletStore?.keyringStore + if keyringStore == nil { + if let keyringService = keyringService, + let walletService = walletService, + let rpcService = rpcService, + let walletP3A + { + keyringStore = KeyringStore( + keyringService: keyringService, + walletService: walletService, + rpcService: rpcService, + walletP3A: walletP3A ) - vc.settingsDelegate = self - menuController.pushInnerMenu(vc) } } + + var cryptoStore: CryptoStore? = walletStore?.cryptoStore + if cryptoStore == nil { + cryptoStore = CryptoStore.from( + ipfsApi: braveCore.ipfsAPI, + walletP3A: walletP3A, + privateMode: isPrivateMode + ) + } + + let vc = SettingsViewController( + profile: self.profile, + tabManager: self.tabManager, + feedDataSource: self.feedDataSource, + rewards: self.rewards, + windowProtection: self.windowProtection, + braveCore: self.braveCore, + attributionManager: attributionManager, + keyringStore: keyringStore, + cryptoStore: cryptoStore + ) + vc.settingsDelegate = self + return vc } /// Presents Wallet without an origin (ex. from menu) @@ -483,3 +488,375 @@ extension BrowserViewController { } } } + +extension BrowserViewController { + func presentBrowserMenu( + from sourceView: UIView, + activities: [UIActivity], + pageURL: URL?, + webView: WKWebView? + ) { + var actions: [Action] = [] + actions.append(vpnMenuAction) + actions.append(contentsOf: destinationMenuActions(for: pageURL)) + actions.append(contentsOf: pageActions(for: pageURL, webView: webView)) + let pageActivities: Set = Set( + activities + .compactMap { activity in + guard let id = (activity as? MenuActivity)?.id, + let actionID = Action.Identifier.allPageActivites.first(where: { $0.id == id }) + else { + return nil + } + return (activity, actionID) + } + .map { (activity: UIActivity, actionID: Action.Identifier) in + .init(id: actionID) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + activity.perform() + } + return .none + } + } + ) + // Sets up empty actions for any page actions that weren't setup as UIActivity's + let remainingPageActivities: [Action] = Action.ID.allPageActivites + .subtracting(pageActivities.map(\.id)) + .map { .init(id: $0, attributes: .disabled) } + actions.append(contentsOf: pageActivities) + actions.append(contentsOf: remainingPageActivities) + let browserMenu = BrowserMenuController( + actions: actions, + handlePresentation: { [unowned self] action in + switch action { + case .settings: + let vc = self.settingsController + self.dismiss(animated: true) { + self.presentSettingsNavigation(with: vc) + } + case .vpnRegionPicker: + let vc = UIHostingController( + rootView: BraveVPNRegionListView( + onServerRegionSet: { _ in + self.presentVPNServerRegionPopup() + } + ) + ) + vc.title = Strings.VPN.vpnRegionListServerScreenTitle + self.dismiss(animated: true) { + self.presentSettingsNavigation(with: vc) + } + } + } + ) + if UIDevice.current.userInterfaceIdiom == .pad { + browserMenu.modalPresentationStyle = .popover + } + browserMenu.popoverPresentationController?.sourceView = sourceView + browserMenu.popoverPresentationController?.sourceRect = sourceView.bounds + browserMenu.popoverPresentationController?.popoverLayoutMargins = .init(equalInset: 4) + browserMenu.popoverPresentationController?.permittedArrowDirections = [.up, .down] + present(browserMenu, animated: true) + return + } + + private func pageActions(for pageURL: URL?, webView: WKWebView?) -> [Action] { + let playlistActivity = addToPlayListActivityItem ?? openInPlaylistActivityItem + let isPlaylistItemAdded = openInPlaylistActivityItem != nil + var actions: [Action] = [ + .init(id: .share) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + self.tabToolbarDidPressShare() + } + return .none + }, + .init(id: .addBookmark) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + self.openAddBookmark() + } + return .none + }, + .init( + id: .addToPlaylist, + title: isPlaylistItemAdded ? Strings.PlayList.toastAddedToPlaylistTitle : nil, + image: isPlaylistItemAdded ? "leo.product.playlist-added" : nil, + attributes: playlistActivity?.enabled == true ? [] : .disabled + ) { @MainActor [unowned self] action in + let playlistActivity = addToPlayListActivityItem ?? openInPlaylistActivityItem + let isPlaylistItemAdded = openInPlaylistActivityItem != nil + guard let item = playlistActivity?.item else { return .none } + if !isPlaylistItemAdded { + // Add to playlist + // TODO: Need to be able to return something that will update the underlying action + let addedItem = await withCheckedContinuation { continuation in + self.addToPlaylist(item: item) { didAddItem in + continuation.resume(returning: didAddItem) + } + } + if addedItem { + var actionCopy = action + actionCopy.title = Strings.PlayList.toastAddedToPlaylistTitle + actionCopy.image = "leo.product.playlist-added" + return .updateAction(actionCopy) + } + } else { + self.dismiss(animated: true) { + self.openPlaylist( + tab: self.tabManager.selectedTab, + item: item + ) + } + } + return .none + }, + .init( + id: .toggleNightMode, + state: Preferences.General.nightModeEnabled.value + ) { @MainActor action in + var actionCopy = action + Preferences.General.nightModeEnabled.value.toggle() + actionCopy.state = Preferences.General.nightModeEnabled.value + return .updateAction(actionCopy) + }, + .init(id: .shredData) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + guard let tab = self.tabManager.selectedTab, let url = tab.url else { return } + let alert = UIAlertController.shredDataAlert(url: url) { _ in + self.shredData(for: url, in: tab) + } + self.present(alert, animated: true) + } + return .none + }, + .init(id: .print) { @MainActor [unowned self, weak webView] _ in + guard let webView else { return .none } + self.dismiss(animated: true) { + let printController = UIPrintInteractionController.shared + printController.printFormatter = webView.viewPrintFormatter() + printController.present(animated: true) + } + return .none + }, + ] + if pageURL == nil { + for index in actions.indices { + actions[index].attributes.insert(.disabled) + } + } + return actions + } + + private var vpnMenuAction: Action { + func alertForExpiredState() -> UIAlertController? { + if !BraveSkusManager.keepShowingSessionExpiredState { + return nil + } + return BraveSkusManager.sessionExpiredStateAlert(loginCallback: { _ in + self.openURLInNewTab( + .brave.account, + isPrivate: self.privateBrowsingManager.isPrivateBrowsing, + isPrivileged: false + ) + }) + } + + let vpnState = BraveVPN.vpnState + switch vpnState { + case .notPurchased, .expired: + return .init(id: .vpn) { @MainActor [unowned self] _ in + if !BraveVPNProductInfo.isComplete { + // Reattempt to connect to the App Store to get VPN prices. + vpnProductInfo.load() + return .none + } + + if let alert = alertForExpiredState() { + self.dismiss(animated: true) { + self.present(alert, animated: true) + } + return .none + } + + // Expired Subcriptions can cause glitch because of connect on demand + // Disconnect VPN before showing Purchase + BraveVPN.disconnect(skipChecks: true) + guard BraveVPN.vpnState.isPaywallEnabled else { return .none } + + let vpnPaywallView = BraveVPNPaywallView( + openVPNAuthenticationInNewTab: { [weak self] in + guard let self else { return } + + self.popToBVC() + + self.openURLInNewTab( + .brave.braveVPNRefreshCredentials, + isPrivate: self.privateBrowsingManager.isPrivateBrowsing, + isPrivileged: false + ) + }, + installVPNProfile: { [weak self] in + guard let self else { return } + self.popToBVC() + self.openInsideSettingsNavigation(with: BraveVPNInstallViewController()) + } + ) + + let vc = BraveVPNPaywallHostingController(paywallView: vpnPaywallView) + let container = UINavigationController(rootViewController: vc) + self.dismiss(animated: true) { + self.present(container, animated: true) + } + return .none + } + case .purchased: + let isConnected = BraveVPN.isConnected || BraveVPN.isConnecting + return .init( + id: .vpn, + title: isConnected ? Strings.VPN.vpnOnMenuButtonTitle : Strings.VPN.vpnOffMenuButtonTitle, + state: isConnected + ) { @MainActor [unowned self] _ in + if let alert = alertForExpiredState() { + self.dismiss(animated: true) { + self.present(alert, animated: true) + } + return .none + } + + if BraveVPN.isConnected || BraveVPN.isConnecting { + await withCheckedContinuation { continuation in + BraveVPN.disconnect { error in + continuation.resume() + } + } + } else { + await withCheckedContinuation { continuation in + BraveVPN.reconnect { success in + continuation.resume() + } + } + // FIXME: VPN activity donation + // Donate Enable VPN Activity for suggestions + // let enableVPNActivity = ActivityShortcutManager.shared.createShortcutActivity( + // type: .enableBraveVPN + // ) + // Does this need to be attached to the menu specifically? + // browserMenuController.userActivity = enableVPNActivity + // enableVPNActivity.becomeCurrent() + } + try? await Task.sleep(for: .milliseconds(100)) + return .updateAction(vpnMenuAction) + } + } + } + + private func destinationMenuActions(for pageURL: URL?) -> [Action] { + let isPrivateBrowsing = privateBrowsingManager.isPrivateBrowsing + return [ + .init(id: .bookmarks) { @MainActor [unowned self] _ in + let vc = BookmarksViewController( + folder: bookmarkManager.lastVisitedFolder(), + bookmarkManager: bookmarkManager, + isPrivateBrowsing: privateBrowsingManager.isPrivateBrowsing + ) + vc.toolbarUrlActionsDelegate = self + let container = UINavigationController(rootViewController: vc) + self.dismiss(animated: true) { + self.present(container, animated: true) + } + return .none + }, + .init(id: .history) { @MainActor [unowned self] _ in + let vc = UIHostingController( + rootView: HistoryView( + model: HistoryModel( + api: self.braveCore.historyAPI, + tabManager: self.tabManager, + toolbarUrlActionsDelegate: self, + dismiss: { [weak self] in self?.dismiss(animated: true) }, + askForAuthentication: self.askForLocalAuthentication + ) + ) + ) + let container = UINavigationController(rootViewController: vc) + // TODO: Move the button into HistoryView when old menu is removed + vc.navigationItem.rightBarButtonItem = UIBarButtonItem( + systemItem: .done, + primaryAction: .init(handler: { [unowned self] _ in + self.dismiss(animated: true) + }) + ) + self.dismiss(animated: true) { + self.present(container, animated: true) + } + return .none + }, + .init(id: .downloads) { @MainActor [unowned self] _ in + UIApplication.shared.openBraveDownloadsFolder { success in + if !success { + self.dismiss(animated: true) { + self.displayOpenDownloadsError() + } + } + } + return .none + }, + .init(id: .braveWallet) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + self.presentWallet() + } + return .none + }, + .init( + id: .braveLeo, + attributes: isPrivateBrowsing ? .disabled : [] + ) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + self.openBraveLeo() + } + return .none + }, + .init(id: .playlist) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + self.presentPlaylistController() + } + return .none + }, + .init(id: .braveNews) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + if pageURL == nil, + let newTabPageController = self.tabManager.selectedTab?.newTabPageViewController + { + // Already on NTP + newTabPageController.scrollToBraveNews() + } else { + // Make a new tab and scroll to it + self.openBlankNewTab( + attemptLocationFieldFocus: false, + isPrivate: false, + isExternal: true + ) + self.popToBVC() + if let newTabPageController = self.tabManager.selectedTab?.newTabPageViewController { + newTabPageController.scrollToBraveNews() + } + } + } + return .none + }, + .init(id: .braveTalk) { @MainActor [unowned self] _ in + self.dismiss(animated: true) { + guard let url = URL(string: "https://talk.brave.com/") else { return } + self.popToBVC() + if pageURL == nil { + // Already on NTP + self.finishEditingAndSubmit(url) + } else { + self.openURLInNewTab(url, isPrivileged: false) + } + } + return .none + }, + ] + } + +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift index 8e539fa9723c..65c37d9161dd 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+ToolbarDelegate.swift @@ -11,6 +11,7 @@ import BraveStrings import BraveUI import BraveWallet import BraveWidgetsModels +import BrowserMenu import CertificateUtilities import Data import Lottie @@ -955,6 +956,17 @@ extension BrowserViewController: ToolbarDelegate { arrowDirection: .up ) } + + if FeatureList.kModernBrowserMenuEnabled.enabled { + presentBrowserMenu( + from: tabToolbar.menuButton, + activities: activities, + pageURL: selectedTabURL, + webView: tabManager.selectedTab?.webView + ) + return + } + let initialHeight: CGFloat = selectedTabURL != nil ? 470 : 500 let menuController = MenuViewController( initialHeight: initialHeight, diff --git a/ios/brave-ios/Sources/Brave/Frontend/Share/MenuActivity.swift b/ios/brave-ios/Sources/Brave/Frontend/Share/MenuActivity.swift index 39d42ff1bbbc..7382d69281d7 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Share/MenuActivity.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Share/MenuActivity.swift @@ -9,6 +9,7 @@ import SwiftUI import UIKit protocol MenuActivity: UIActivity { + var id: String { get } /// The image to use when shown on the menu. var menuImage: Image { get } } @@ -21,7 +22,7 @@ class BasicMenuActivity: UIActivity, MenuActivity { var braveSystemImage: String } - private var id: String + private(set) var id: String private var title: String private var braveSystemImage: String private let callback: () -> Bool diff --git a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift index 29a2fd5afa72..2a27cd39c880 100644 --- a/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift +++ b/ios/brave-ios/Sources/BraveStrings/BraveStrings.swift @@ -4799,7 +4799,7 @@ extension Strings { "playList.toastAddToPlaylistTitle", tableName: "BraveShared", bundle: .module, - value: "Add to Brave Playlist", + value: "Add to Playlist", comment: "The title for the toast that shows up on a page containing a playlist item" ) @@ -4808,7 +4808,7 @@ extension Strings { "playList.toastAddedToPlaylistTitle", tableName: "BraveShared", bundle: .module, - value: "Added to Brave Playlist", + value: "View in Playlist", comment: "The title for the toast that shows up on a page containing a playlist item that was added to playlist" ) diff --git a/ios/brave-ios/Sources/BraveUI/SwiftUI/AnyTransitionExtensions.swift b/ios/brave-ios/Sources/BraveUI/SwiftUI/AnyTransitionExtensions.swift new file mode 100644 index 000000000000..660b87a50faa --- /dev/null +++ b/ios/brave-ios/Sources/BraveUI/SwiftUI/AnyTransitionExtensions.swift @@ -0,0 +1,37 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import SwiftUI + +extension AnyTransition { + /// Configuration properties for a transition. + public enum BlurReplaceTransitionConfiguration: Hashable, Sendable { + /// A configuration that requests a transition that scales the + /// view down while removing it and up while inserting it. + case downUp + + /// A configuration that requests a transition that scales the + /// view up while both removing and inserting it. + case upUp + + @available(iOS 17.0, *) + fileprivate var configuration: BlurReplaceTransition.Configuration { + switch self { + case .downUp: return .downUp + case .upUp: return .upUp + } + } + } + /// A blur replace transition or a fallback for iOS 16 users + public static func blurReplace( + configuration: BlurReplaceTransitionConfiguration = .downUp, + fallback: AnyTransition = .opacity + ) -> AnyTransition { + if #available(iOS 17.0, *) { + return AnyTransition(.blurReplace(configuration.configuration)) + } + return fallback + } +} diff --git a/ios/brave-ios/Sources/BraveVPN/BraveVPN.swift b/ios/brave-ios/Sources/BraveVPN/BraveVPN.swift index 96a1cb8fff9e..da3b3cdc6fad 100644 --- a/ios/brave-ios/Sources/BraveVPN/BraveVPN.swift +++ b/ios/brave-ios/Sources/BraveVPN/BraveVPN.swift @@ -168,6 +168,10 @@ public class BraveVPN { helper.isConnected() } + public static var isConnecting: Bool { + helper.isConnecting() + } + /// Returns the last used hostname for the vpn configuration. /// Returns nil if the hostname string is empty(due to some error when configuring it for example). public static var hostname: String? { @@ -222,7 +226,7 @@ public class BraveVPN { /// Reconnects to the vpn. /// The vpn must be configured prior to that otherwise it does nothing. - public static func reconnect() { + public static func reconnect(completion: ((Bool) -> Void)? = nil) { if reconnectPending { logAndStoreError("Can't reconnect the vpn while another reconnect is pending.") return @@ -230,14 +234,14 @@ public class BraveVPN { reconnectPending = true - connectToVPN() + connectToVPN(completion: completion) } /// Disconnects the vpn. /// The vpn must be configured prior to that otherwise it does nothing. - public static func disconnect(skipChecks: Bool = false) { + public static func disconnect(skipChecks: Bool = false, completion: ((Error?) -> Void)? = nil) { if skipChecks { - helper.disconnectVPN() + helper.disconnectVPN(completion: completion) return } @@ -246,7 +250,7 @@ public class BraveVPN { return } - helper.disconnectVPN() + helper.disconnectVPN(completion: completion) } public static func connectToVPN(completion: ((Bool) -> Void)? = nil) { diff --git a/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift b/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift index b60f01f6ca2d..81d6693db9ba 100644 --- a/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift +++ b/ios/brave-ios/Sources/BraveVPN/Resources/BraveVPNStrings.swift @@ -1266,5 +1266,19 @@ extension Strings { comment: "The title of the toggle in settings that disconnects the internet connection, when not connected to the VPN. Emergency Shutoff, Kill Switch, Safety Switch, etc." ) + + public static let vpnOnMenuButtonTitle = NSLocalizedString( + "vpn.vpnOnMenuButtonTitle", + bundle: .module, + value: "VPN On", + comment: "The title shown on the menu when the VPN connection is connected" + ) + + public static let vpnOffMenuButtonTitle = NSLocalizedString( + "vpn.vpnOffMenuButtonTitle", + bundle: .module, + value: "VPN Off", + comment: "The title shown on the menu when the VPN connection is disconnected" + ) } } diff --git a/ios/brave-ios/Sources/BrowserMenu/Action.swift b/ios/brave-ios/Sources/BrowserMenu/Action.swift new file mode 100644 index 000000000000..d077fb43219d --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Action.swift @@ -0,0 +1,101 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveStrings +import Foundation +import Strings +import SwiftUI + +public struct Action: Hashable, Identifiable, Sendable { + + public enum HandlerResult { + case none + case updateAction(Action) + } + + public struct Attributes: OptionSet, Hashable, Sendable { + public var rawValue: Int = 0 + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let disabled: Self = .init(rawValue: 1 << 0) + } + + public struct Traits: Hashable, Sendable { + public var badgeColor: UIColor? = nil + + public init(badgeColor: UIColor? = nil) { + self.badgeColor = badgeColor + } + } + + public enum Visibility: Hashable, Sendable { + case visible + case hidden + } + + public struct Identifier: Hashable, Sendable { + public var id: String + public var defaultTitle: String + public var defaultImage: String + public var defaultRank: Int + public var defaultVisibility: Visibility + + public init( + id: String, + title: String, + braveSystemImage: String, + defaultRank: Int, + defaultVisibility: Visibility + ) { + self.id = id + self.defaultTitle = title + self.defaultImage = braveSystemImage + self.defaultRank = defaultRank + self.defaultVisibility = defaultVisibility + } + } + + public var id: Identifier + public var title: String + public var image: String + public var attributes: Attributes + public var traits: Traits + public var state: Bool? + public var handler: @Sendable @MainActor (Action) async -> HandlerResult + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(title) + hasher.combine(image) + hasher.combine(attributes) + hasher.combine(traits) + hasher.combine(state) + } + + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id && lhs.title == rhs.title && lhs.image == rhs.image + && lhs.attributes == rhs.attributes && lhs.traits == rhs.traits && lhs.state == rhs.state + } + + public init( + id: Identifier, + title: String? = nil, + image: String? = nil, + attributes: Attributes = [], + traits: Traits = .init(), + state: Bool? = nil, + handler: (@Sendable (Action) async -> HandlerResult)? = nil + ) { + self.id = id + self.title = title ?? id.defaultTitle + self.image = image ?? id.defaultImage + self.attributes = attributes + self.traits = traits + self.state = state + self.handler = handler ?? { _ in .none } + } +} diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift new file mode 100644 index 000000000000..7f56595f0027 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift @@ -0,0 +1,516 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveUI +import DesignSystem +import GuardianConnect +import Strings +import SwiftUI + +public struct BrowserMenu: View { + @ObservedObject var model: BrowserMenuModel + var handlePresentation: (BrowserMenuPresentation) -> Void + + @State private var isEditMenuPresented = false + + /// Whether or not we may be viewing on a device that doesn't have as much horizontal space + /// available (such as when Display Zoom is enabled) + @State private var isHorizontalSpaceRestricted: Bool = false + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + private func handleAction(_ action: Binding) { + Task { + let result = await action.wrappedValue.handler(action.wrappedValue) + switch result { + case .updateAction(let replacement): + withAnimation(.snappy) { + action.wrappedValue = replacement + } + break + case .none: + break + } + } + } + + private var numberOfQuickActions: Int { + if model.visibleActions.count < 4 { + return model.visibleActions.count + } + if dynamicTypeSize.isAccessibilitySize || isHorizontalSpaceRestricted { + return 4 + } + return 5 + } + + private var quickActions: Binding<[Action]> { + Binding( + get: { Array(model.visibleActions.prefix(numberOfQuickActions)) }, + set: { + model.visibleActions.replaceSubrange( + 0.. { + Binding( + get: { + if model.visibleActions.count < numberOfQuickActions { + return [] + } + return Array(model.visibleActions[numberOfQuickActions...]) + }, + set: { + if model.visibleActions.count > numberOfQuickActions { + model.visibleActions.replaceSubrange(numberOfQuickActions..., with: $0) + } + } + ) + } + + public var body: some View { + ScrollView { + VStack(spacing: 16) { + VStack(alignment: .leading) { + HStack { + Text(Strings.BrowserMenu.myActions) + .font(.caption.weight(.semibold)) + .textCase(.uppercase) + Spacer() + Button { + isEditMenuPresented = true + } label: { + Text(Strings.BrowserMenu.editButtonTitle) + .font(.caption.weight(.semibold)) + .padding(.vertical, 4) + .padding(.horizontal, 12) + .background(Color(braveSystemName: .iosBrowserContainerHighlightIos), in: .capsule) + } + } + .foregroundStyle(Color(braveSystemName: .textTertiary)) + QuickActionsView(actions: quickActions) { $action in + handleAction($action) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + ActionsList( + actions: listedActions, + additionalActions: $model.hiddenActions, + handler: { $action in + handleAction($action) + } + ) + if case .connected(let region) = model.vpnStatus { + Button { + handlePresentation(.vpnRegionPicker) + } label: { + Label { + HStack { + Text(Strings.BrowserMenu.vpnButtonTitle) + Spacer() + Text(region.flag) + Text(region.displayName) + } + } icon: { + Image(braveSystemName: "leo.product.vpn") + } + } + .modifier(MenuRowButtonStyleModifier()) + .background( + Color(braveSystemName: .iosBrowserContainerHighlightIos), + in: .rect(cornerRadius: 14, style: .continuous) + ) + .transition(.blurReplace()) + } + Button { + handlePresentation(.settings) + } label: { + Label(Strings.BrowserMenu.allSettingsButtonTitle, braveSystemImage: "leo.settings") + } + .modifier(MenuRowButtonStyleModifier()) + .background( + Color(braveSystemName: .iosBrowserContainerHighlightIos), + in: .rect(cornerRadius: 14, style: .continuous) + ) + } + .padding() + } + .background(Material.thick) + .dynamicTypeSize(DynamicTypeSize.xSmall..) -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ForEach($actions) { $action in + let imageOverride: Image? = action.state.map { + Image(braveSystemName: $0 ? "leo.toggle.on" : "leo.toggle.off") + } + Button { + handler($action) + } label: { + Label { + Text(action.title) + } icon: { + imageOverride ?? Image(braveSystemName: action.image) + } + } + .modifier(ButtonStyleViewModifier(traits: action.traits)) + .disabled(action.attributes.contains(.disabled)) + .transition(.blurReplace()) + .id(action) + } + } + } + + private struct ButtonStyleViewModifier: ViewModifier { + var traits: Action.Traits + + func body(content: Content) -> some View { + if #available(iOS 18, *) { + content.buttonStyle(_OS18ButtonStyle(traits: traits)) + } else { + content.buttonStyle(_StandardButtonStyle(traits: traits)) + } + } + + @available( + iOS, + introduced: 18.0, + message: """ + A PrimitiveButtonStyle to that handles highlights & action execution for quick action buttons. + + On iOS 18 there is a bug where a Button inside of a ScrollView which is being presented + in a sheet will not cancel their tap gesture when a drag occurs which would move the sheet + instead of the ScrollView, and as a result will execute the action you started your drag on + even if you dismiss the sheet. + + This is tested up to iOS 18.2 and still broken. + """ + ) + private struct _OS18ButtonStyle: PrimitiveButtonStyle { + var traits: Action.Traits + + @GestureState private var isPressed: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .simultaneousGesture( + DragGesture(minimumDistance: 0).updating( + $isPressed, + body: { _, state, _ in state = true } + ) + ) + .onTapGesture { + configuration.trigger() + } + .labelStyle(_LabelStyle(isPressed: isPressed, traits: traits)) + .animation(isPressed ? nil : .default, value: isPressed) + } + } + + private struct _StandardButtonStyle: ButtonStyle { + var traits: Action.Traits + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .labelStyle(_LabelStyle(isPressed: configuration.isPressed, traits: traits)) + } + } + + } + + private struct _LabelStyle: LabelStyle { + @Environment(\.isEnabled) private var isEnabled + @ScaledMetric private var iconFrameSize = 22 + @ScaledMetric private var iconFontSize = 18 + @ScaledMetric private var badgeRadius = 8 + var isPressed: Bool + var traits: Action.Traits + + func makeBody(configuration: Configuration) -> some View { + VStack(spacing: 8) { + configuration.icon + .frame(width: iconFrameSize, height: iconFrameSize) + .padding(.vertical, 12) + .font(.system(size: iconFontSize)) + .foregroundStyle(isEnabled ? Color(braveSystemName: .iconDefault) : .secondary) + .frame(maxWidth: .infinity) + .background { + Color(braveSystemName: .iosBrowserContainerHighlightIos) + .overlay( + Color(braveSystemName: .iosBrowserContainerHighlightIos).opacity(isPressed ? 1 : 0) + ) + .clipShape(.rect(cornerRadius: 14, style: .continuous)) + .hoverEffect() + } + .overlay(alignment: .topTrailing) { + if let badgeColor = traits.badgeColor { + Circle() + .fill(Color(uiColor: badgeColor)) + .frame(width: badgeRadius, height: badgeRadius) + .allowsHitTesting(false) + } + } + configuration.title + .font(.caption2) + .lineLimit(2) + .foregroundStyle(isEnabled ? Color(braveSystemName: .textPrimary) : .secondary) + .multilineTextAlignment(.center) + } + .opacity(isEnabled ? 1 : 0.7) + .contentShape(.rect) + } + } +} + +private struct ActionButton: View { + @Binding var action: Action + var handler: (Binding) -> Void + var badgeRadius: CGFloat + + var body: some View { + Button { + handler($action) + } label: { + HStack { + Label { + Text(action.title) + } icon: { + Image(braveSystemName: action.image) + } + Spacer() + if let badgeColor = action.traits.badgeColor { + Circle() + .fill(Color(uiColor: badgeColor)) + .frame(width: badgeRadius, height: badgeRadius) + .allowsHitTesting(false) + } + if let state = action.state { + Toggle("", isOn: .constant(state)) + .tint(Color(braveSystemName: .primary50)) + .labelsHidden() + .allowsHitTesting(false) + } + } + } + .modifier(MenuRowButtonStyleModifier()) + .disabled(action.attributes.contains(.disabled)) + .transition(.blurReplace()) + .id(action) + } +} + +private struct ActionsList: View { + @Binding var actions: [Action] + @Binding var additionalActions: [Action] + var handler: (Binding) -> Void + + init( + actions: Binding<[Action]>, + additionalActions: Binding<[Action]>, + handler: @escaping (Binding) -> Void + ) { + self._actions = actions + self._additionalActions = additionalActions + self.handler = handler + } + + init( + action: Action, + handler: @escaping (Action) -> Void + ) { + self._actions = .constant([action]) + self._additionalActions = .constant([]) + self.handler = { action in + handler(action.wrappedValue) + } + } + + @State private var isAdditionalActionsVisible: Bool = false + @Environment(\.pixelLength) private var pixelLength + @ScaledMetric private var badgeRadius = 8 + + @ViewBuilder private func labelForAction(_ action: Action) -> some View { + HStack { + Label { + Text(action.title) + } icon: { + Image(braveSystemName: action.image) + } + Spacer() + if let badgeColor = action.traits.badgeColor { + Circle() + .fill(Color(uiColor: badgeColor)) + .frame(width: badgeRadius, height: badgeRadius) + .allowsHitTesting(false) + } + if let state = action.state { + Toggle("", isOn: .constant(state)) + .tint(Color(braveSystemName: .primary50)) + .labelsHidden() + .allowsHitTesting(false) + } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach($actions) { $action in + ActionButton(action: $action, handler: handler, badgeRadius: badgeRadius) + if !additionalActions.isEmpty || action.id != actions.last?.id { + Color(braveSystemName: .materialDivider) + .frame(height: pixelLength) + .padding(.leading, 16) + } + } + if !additionalActions.isEmpty { + if !isAdditionalActionsVisible { + Button { + withAnimation(.snappy) { + isAdditionalActionsVisible = true + } + } label: { + Label(Strings.BrowserMenu.showAllButtonTitle, braveSystemImage: "leo.more.horizontal") + } + .modifier(MenuRowButtonStyleModifier()) + } + if isAdditionalActionsVisible { + ForEach($additionalActions) { $action in + ActionButton(action: $action, handler: handler, badgeRadius: badgeRadius) + if action.id != additionalActions.last?.id { + Color(braveSystemName: .materialDivider) + .frame(height: pixelLength) + .padding(.leading, 16) + } + } + } + } + } + .background(Color(braveSystemName: .iosBrowserContainerHighlightIos)) + .clipShape(.rect(cornerRadius: 14, style: .continuous)) + } +} + +private struct MenuRowButtonStyleModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 18, *) { + content.buttonStyle(_OS18ButtonStyle()) + } else { + content.buttonStyle(_StandardButtonStyle()) + } + } + + @available( + iOS, + introduced: 18.0, + message: """ + A PrimitiveButtonStyle to that handles highlights & action execution for menu row buttons. + + On iOS 18 there is a bug where a Button inside of a ScrollView which is being presented + in a sheet will not cancel their tap gesture when a drag occurs which would move the sheet + instead of the ScrollView, and as a result will execute the action you started your drag on + even if you dismiss the sheet. + + This is tested up to iOS 18.2 and still broken. + """ + ) + private struct _OS18ButtonStyle: PrimitiveButtonStyle { + @GestureState private var isPressed: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(minHeight: 46) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(.rect) + .simultaneousGesture( + DragGesture(minimumDistance: 0).updating( + $isPressed, + body: { _, state, _ in state = true } + ) + ) + .onTapGesture { + configuration.trigger() + } + .hoverEffect() + .background( + Color(braveSystemName: .iosBrowserContainerHighlightIos).opacity(isPressed ? 1 : 0) + .animation(isPressed ? nil : .default, value: isPressed) + ) + .labelStyle(_LabelStyle()) + } + } + + private struct _StandardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(minHeight: 46) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(.rect) + .hoverEffect() + .background( + Color(braveSystemName: .iosBrowserContainerHighlightIos).opacity( + configuration.isPressed ? 1 : 0 + ) + ) + .labelStyle(_LabelStyle()) + } + } + + private struct _LabelStyle: LabelStyle { + @ScaledMetric private var iconFrameSize = 22 + @ScaledMetric private var iconFontSize = 18 + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 10) { + configuration.icon + .frame(width: iconFrameSize, height: iconFrameSize) + .font(.system(size: iconFontSize)) + .foregroundStyle(isEnabled ? Color(braveSystemName: .iconDefault) : .secondary) + configuration.title + .font(.body) + .foregroundStyle(isEnabled ? Color(braveSystemName: .textPrimary) : .secondary) + } + .padding(.vertical, 12) + .opacity(isEnabled ? 1 : 0.7) + } + } +} + +#if DEBUG +#Preview { + BrowserMenu(model: .mock, handlePresentation: { _ in }) + .background( + LinearGradient( + colors: [Color.red, Color.blue, Color.purple, Color.green], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) +} +#endif diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuController.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuController.swift new file mode 100644 index 000000000000..fb5551ce4a88 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuController.swift @@ -0,0 +1,57 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveVPN +import Foundation +import SwiftUI + +public enum BrowserMenuPresentation { + case settings + case vpnRegionPicker +} + +public class BrowserMenuController: UIHostingController { + public init( + actions: [Action], + handlePresentation: @escaping (BrowserMenuPresentation) -> Void + ) { + super.init( + rootView: BrowserMenu( + model: .init( + actions: actions, + vpnStatus: .liveVPNStatus, + vpnStatusPublisher: .liveVPNStatus + ), + handlePresentation: handlePresentation + ) + ) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .clear + } + + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + + let size = view.intrinsicContentSize + if let controller = sheetPresentationController + ?? popoverPresentationController?.adaptiveSheetPresentationController + { + controller.detents = [ + .custom(resolver: { context in + return min(context.maximumDetentValue * 0.7, size.height) + }), .large(), + ] + controller.prefersGrabberVisible = true + } + } +} diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift new file mode 100644 index 000000000000..281b1670fdee --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift @@ -0,0 +1,186 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import Combine +import Foundation +import GuardianConnect +import NetworkExtension +import Preferences +import SwiftUI + +/// Represents an instance of the browser menu +@MainActor class BrowserMenuModel: ObservableObject { + /// The list of visible actions shown on the menu + @Published var visibleActions: [Action] = [] + /// The list of hidden actions shown only when the user expands the "More Actions" + @Published var hiddenActions: [Action] = [] + /// The connected VPN region + @Published private(set) var vpnStatus: VPNStatus = .disconnected + + private var actions: [Action] + private var actionVisibility: Preferences.Option<[String: Bool]> + private var actionRanks: Preferences.Option<[String: Double]> + private var cancellables: Set = [] + + init( + actions: [Action], + vpnStatus: VPNStatus = .disconnected, + vpnStatusPublisher: AnyPublisher? = nil, + actionVisibility: Preferences.Option<[String: Bool]> = Preferences.BrowserMenu + .actionVisibility, + actionRanks: Preferences.Option<[String: Double]> = Preferences.BrowserMenu.actionRanks + ) { + self.actions = actions + self.vpnStatus = vpnStatus + self.actionVisibility = actionVisibility + self.actionRanks = actionRanks + + #if DEBUG + let duplicates = Dictionary(grouping: actions.map(\.id), by: \.defaultRank) + .filter { $1.count > 1 } + assert(duplicates.isEmpty, "Multiple action IDs have the same default rank: \(duplicates)") + #endif + + vpnStatusPublisher? + .receive(on: RunLoop.main) + .sink(receiveValue: { status in + MainActor.assumeIsolated { + withAnimation { + self.vpnStatus = status + } + } + }) + .store(in: &cancellables) + + reloadActions() + } + + // MARK: - + + private func reloadActions() { + let visibleActionOverrides = actionVisibility.value + let visibleActions = actions.filter { + if let override = visibleActionOverrides[$0.id.id] { + return override + } + return $0.id.defaultVisibility == .visible + } + let sortedActions: [Action] = visibleActions.sorted(by: { first, second in + let firstRank = rank(for: first) + let secondRank = rank(for: second) + return firstRank < secondRank + }) + let hiddenActionsIDs = Array(Set(actions.map(\.id)).subtracting(sortedActions.map(\.id))) + let hiddenActions = actions.filter({ hiddenActionsIDs.contains($0.id) }).sorted( + using: KeyPathComparator(\Action.id.defaultRank) + ) + + self.visibleActions = sortedActions + self.hiddenActions = hiddenActions + } + + private func rank(for action: Action) -> Double { + actionRanks.value[action.id.id] ?? Double(action.id.defaultRank) + } + + func updateActionVisibility(_ action: Action, visibility: Action.Visibility) { + actionVisibility.value[action.id.id] = visibility == .visible ? true : false + if visibility == .visible { + // If the user is making a hidden item visible and the visible list is empty, we won't + // give it an overridden rank to ensure it ends at the bottom + if !visibleActions.isEmpty { + // Adding a new item from the hidden list, append it to the end if the list is non-empty + actionRanks.value[action.id.id] = rank(for: visibleActions.last!) + 1 + } + } else { + // Hiding it, reset any custom rank + actionRanks.value[action.id.id] = nil + } + reloadActions() + } + + func reorderVisibleAction(_ action: Action, to index: Int) { + if visibleActions.isEmpty || !visibleActions.contains(where: { action.id == $0.id }) { + return + } + var newRank: Double = 0 + if index == 0 { + // First item + newRank = rank(for: visibleActions.first!) / 2 + } else if index == visibleActions.count - 1 { + // Last item + newRank = rank(for: visibleActions.last!) + 1 + } else { + // Insert between items + let previousRank = rank(for: visibleActions[index - 1]) + let nextRank = rank(for: visibleActions[index]) + newRank = (previousRank + nextRank) / 2 + } + actionRanks.value[action.id.id] = newRank + reloadActions() + } +} + +extension BrowserMenuModel { + static var mock: BrowserMenuModel { + let mockStatus: VPNStatus = .connected( + activeRegion: .init(countryCode: "CA", displayName: "Canada") + ) + let vpnStatusPublisher = CurrentValueSubject(mockStatus) + let model = BrowserMenuModel( + actions: [ + .init( + id: .vpn, + title: "VPN On", + traits: .init(badgeColor: UIColor(braveSystemName: .primary50)), + state: true + ) { action in + var actionCopy = action + actionCopy.state?.toggle() + actionCopy.title = "VPN \(actionCopy.state! ? "On" : "Off")" + vpnStatusPublisher.send(actionCopy.state! ? mockStatus : .disconnected) + return .updateAction(actionCopy) + }, + .init(id: .addBookmark, attributes: .disabled), + .init(id: .history), + .init(id: .braveLeo), + .init(id: .playlist), + .init(id: .addFavourites), + .init( + id: .toggleNightMode, + traits: .init(badgeColor: UIColor(braveSystemName: .primary50)), + state: false + ) { action in + var actionCopy = action + actionCopy.state?.toggle() + return .updateAction(actionCopy) + }, + .init(id: .bookmarks, attributes: .disabled), + .init(id: .braveNews), + .init(id: .createPDF), + .init(id: .pageZoom), + ], + vpnStatusPublisher: vpnStatusPublisher.eraseToAnyPublisher(), + actionVisibility: { + let pref = Preferences.Option<[String: Bool]>( + key: "browser-menu-mock-action-visibility", + default: [:] + ) + pref.reset() + pref.value[Action.Identifier.createPDF.id] = true + return pref + }(), + actionRanks: { + let pref = Preferences.Option<[String: Double]>( + key: "browser-menu-mock-action-ranks", + default: [:] + ) + pref.reset() + return pref + }() + ) + return model + } +} diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuPreferences.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuPreferences.swift new file mode 100644 index 000000000000..673ea2d7f259 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuPreferences.swift @@ -0,0 +1,20 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +@preconcurrency import Preferences + +extension Preferences { + struct BrowserMenu { + // maps `Action.Identifier.id` to whether or not the item is explicitly visible in the menu + static let actionVisibility: Option<[String: Bool]> = .init( + key: "menu.action-visibility-overrides", + default: [:] + ) + + // maps `Action.Identifier.id` to the user defined rankings + static let actionRanks: Option<[String: Double]> = .init(key: "menu.action-ranks", default: [:]) + } +} diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuStrings.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuStrings.swift new file mode 100644 index 000000000000..52525b40c6b5 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuStrings.swift @@ -0,0 +1,124 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import Foundation +@_exported import Strings + +extension Strings { + enum BrowserMenu { + public static let allSettingsButtonTitle = NSLocalizedString( + "BrowserMenu.allSettingsButtonTitle", + bundle: .module, + value: "All Settings", + comment: "The title of a button that presents the settings screen" + ) + public static let myActions = NSLocalizedString( + "BrowserMenu.myActions", + bundle: .module, + value: "My Actions", + comment: + "The title above a list of the users selected browser actions such as bookmarks, history, etc." + ) + public static let editButtonTitle = NSLocalizedString( + "BrowserMenu.editButtonTitle", + bundle: .module, + value: "Edit", + comment: + "The title of a button that shows a screen that lets the user customize which actions are visible on the menu" + ) + public static let vpnButtonTitle = NSLocalizedString( + "BrowserMenu.vpnButtonTitle", + bundle: .module, + value: "VPN", + comment: "A title on a button that lets the user switch the active VPN's selected region." + ) + public static let customizeTitle = NSLocalizedString( + "BrowserMenu.customizeTitle", + bundle: .module, + value: "Customize", + comment: "A title shown at the top of the edit/cutsomize actions screen." + ) + public static let doneButtonTitle = NSLocalizedString( + "BrowserMenu.doneButtonTitle", + bundle: .module, + value: "Done", + comment: "A button title that when tapped dismisses the customize screen." + ) + public static let visibleActionsTitle = NSLocalizedString( + "BrowserMenu.visibleActionsTitle", + bundle: .module, + value: "All Actions", + comment: "A title shown above the list of actions that are visible on the menu." + ) + public static let hiddenActionsTitle = NSLocalizedString( + "BrowserMenu.hiddenActionsTitle", + bundle: .module, + value: "Available Actions", + comment: + "A title shown above the list of actions that are hidden on the menu but available for the user to make visible." + ) + public static let showAllButtonTitle = NSLocalizedString( + "BrowserMenu.showAllButtonTitle", + bundle: .module, + value: "Show All…", + comment: "A button title that when taps shows all actions that are hidden by default" + ) + } + + /// Action titles that are different when shown in the new menu design + enum ActionTitles { + public static let playlist = NSLocalizedString( + "ActionTitles.playlist", + bundle: .module, + value: "Playlist", + comment: "A button title shown on the menu that presents the Brave Playlist feature" + ) + public static let rewards = NSLocalizedString( + "ActionTitles.rewards", + bundle: .module, + value: "Rewards", + comment: "A button title shown on the menu that presents the Brave Rewards feature." + ) + public static let leoAIChat = NSLocalizedString( + "ActionTitles.leoAIChat", + bundle: .module, + value: "AI Chat", + comment: "A button title shown on the menu that presents the Brave Leo AI feature." + ) + public static let braveTalk = NSLocalizedString( + "ActionTitles.braveTalk", + bundle: .module, + value: "Brave Talk", + comment: "A button title shown on the menu that opens a link to the Brave Talk feature." + ) + public static let braveNews = NSLocalizedString( + "ActionTitles.braveNews", + bundle: .module, + value: "Brave News", + comment: "A button title shown on the menu that presents the Brave News feature." + ) + public static let share = NSLocalizedString( + "ActionTitles.share", + bundle: .module, + value: "Share…", + comment: + "A button title shown on the menu that when tapped lets the user share the current URL or document they're viewing" + ) + public static let print = NSLocalizedString( + "ActionTitles.print", + bundle: .module, + value: "Print", + comment: + "A button title shown on the menu that when tapped lets the user print the page they're viewing" + ) + public static let vpn = NSLocalizedString( + "ActionTitles.vpn", + bundle: .module, + value: "VPN", + comment: + "A button title shown on the menu that when tapped presents a Brave VPN paywall" + ) + } +} diff --git a/ios/brave-ios/Sources/BrowserMenu/CustomizeMenuView.swift b/ios/brave-ios/Sources/BrowserMenu/CustomizeMenuView.swift new file mode 100644 index 000000000000..6eb59b175ad6 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/CustomizeMenuView.swift @@ -0,0 +1,106 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveUI +import DesignSystem +import Strings +import SwiftUI + +struct CustomizeMenuView: View { + @ObservedObject var model: BrowserMenuModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + ForEach(model.visibleActions) { action in + let id = action.id + HStack { + Button { + withAnimation { + model.updateActionVisibility(action, visibility: .hidden) + } + } label: { + Image(systemName: "minus.circle.fill") + .imageScale(.large) + } + .buttonStyle(.plain) + .foregroundStyle(.red) + Label { + Text(id.defaultTitle) + } icon: { + Image(braveSystemName: id.defaultImage) + .foregroundStyle(Color(braveSystemName: .iconDefault)) + } + } + } + .onMove { indexSet, offset in + guard let index = indexSet.first, let action = model.visibleActions[safe: index] else { + return + } + // SwiftUI's move offset logic is a bit strange where the destination can be the + // count instead of the final valid index, so its safer to use SwiftUI's Collection + // API to handle it then get the valid destination index of that mutated array. + var ids = model.visibleActions.map(\.id) + ids.move(fromOffsets: indexSet, toOffset: offset) + guard let destination = ids.firstIndex(of: action.id) else { + return + } + model.reorderVisibleAction(action, to: destination) + } + .listRowBackground(Color(.secondaryBraveGroupedBackground)) + } header: { + Text(Strings.BrowserMenu.visibleActionsTitle) + } + + Section { + ForEach(model.hiddenActions) { action in + let id = action.id + HStack { + Button { + withAnimation { + model.updateActionVisibility(action, visibility: .visible) + } + } label: { + Image(systemName: "plus.circle.fill") + .imageScale(.large) + } + .foregroundStyle(.green) + Label { + Text(id.defaultTitle) + } icon: { + Image(braveSystemName: id.defaultImage) + .foregroundStyle(Color(braveSystemName: .iconDefault)) + } + } + } + .listRowBackground(Color(.secondaryBraveGroupedBackground)) + } header: { + Text(Strings.BrowserMenu.hiddenActionsTitle) + } + } + .scrollContentBackground(.hidden) + .background(Color(.braveGroupedBackground)) + .environment(\.editMode, .constant(EditMode.active)) + .toolbar { + ToolbarItemGroup(placement: .confirmationAction) { + Button { + dismiss() + } label: { + Text(Strings.BrowserMenu.doneButtonTitle) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(Strings.BrowserMenu.customizeTitle) + } + } +} + +#Preview { + CustomizeMenuView(model: .mock) +} diff --git a/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift b/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift new file mode 100644 index 000000000000..77017a3e4912 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift @@ -0,0 +1,242 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveWallet +import Foundation + +extension Action.Identifier { + + public static let vpn: Self = .init( + id: "ToggleVPN", + title: Strings.ActionTitles.vpn, + braveSystemImage: "leo.product.vpn", + defaultRank: 100, + defaultVisibility: .visible + ) + + public static let bookmarks: Self = .init( + id: "Bookmarks", + title: Strings.bookmarksMenuItem, + braveSystemImage: "leo.product.bookmarks", + defaultRank: 1600, + defaultVisibility: .hidden + ) + + public static let downloads: Self = .init( + id: "Downloads", + title: Strings.downloadsMenuItem, + braveSystemImage: "leo.folder.download", + defaultRank: 1700, + defaultVisibility: .hidden + ) + + public static let history: Self = .init( + id: "History", + title: Strings.historyMenuItem, + braveSystemImage: "leo.history", + defaultRank: 300, + defaultVisibility: .visible + ) + + public static let braveLeo: Self = .init( + id: "BraveLeo", + title: Strings.ActionTitles.leoAIChat, + braveSystemImage: "leo.product.brave-leo", + defaultRank: 400, + defaultVisibility: .visible + ) + + public static let braveNews: Self = .init( + id: "BraveNews", + title: Strings.ActionTitles.braveNews, + braveSystemImage: "leo.product.brave-news", + defaultRank: 2500, + defaultVisibility: .hidden + ) + + public static let braveTalk: Self = .init( + id: "BraveTalk", + title: Strings.ActionTitles.braveTalk, + braveSystemImage: "leo.product.brave-talk", + defaultRank: 2400, + defaultVisibility: .hidden + ) + + public static let braveWallet: Self = .init( + id: "BraveWallet", + title: Strings.Wallet.wallet, + braveSystemImage: "leo.product.brave-wallet", + defaultRank: 900, + defaultVisibility: .visible + ) + + public static let playlist: Self = .init( + id: "Playlist", + title: Strings.ActionTitles.playlist, + braveSystemImage: "leo.product.playlist", + defaultRank: 500, + defaultVisibility: .visible + ) + + public static let braveRewards: Self = .init( + id: "BraveRewards", + title: Strings.ActionTitles.rewards, + braveSystemImage: "leo.product.bat-outline", + defaultRank: 800, + defaultVisibility: .visible + ) + + // MARK: - Page Actions + + public static let share: Self = .init( + id: "Share", + title: Strings.ActionTitles.share, + braveSystemImage: "leo.share.macos", + defaultRank: 600, + defaultVisibility: .visible + ) + public static let addToPlaylist: Self = .init( + id: "AddToPlaylist", + title: Strings.PlayList.toastAddToPlaylistTitle, + braveSystemImage: "leo.product.playlist-add", + defaultRank: 1200, + defaultVisibility: .hidden + ) + public static let toggleNightMode: Self = .init( + id: "ToggleNightMode", + title: Strings.NightMode.settingsTitle, + braveSystemImage: "leo.theme.dark", + defaultRank: 2000, + defaultVisibility: .hidden + ) + public static let shredData: Self = .init( + id: "ShredData", + title: Strings.Shields.shredSiteData, + braveSystemImage: "leo.shred.data", + defaultRank: 1000, + defaultVisibility: .visible + ) + public static let print: Self = .init( + id: "PrintPage", + title: Strings.ActionTitles.print, + braveSystemImage: "leo.print", + defaultRank: 1800, + defaultVisibility: .hidden + ) + + // MARK: - Page Activities + // This list is essentially the same as the ones defined in ShareExtensionHelper + + // A set of all of the page activities defined below. + public static let allPageActivites: Set = [ + .copyCleanLink, + .sendURL, + .toggleReaderMode, + .findInPage, + .pageZoom, + .addFavourites, + .requestDesktopSite, + .addSourceNews, + .createPDF, + .addSearchEngine, + .displaySecurityCertificate, + .reportBrokenSite, + // Ensure any additional Page activity ID's added below are added to this list since they are + // not created during menu presentation manually and instead and explicit UIActivity types + // passed into the share sheet so they will not all exist at all times when creating the menu + ] + + public static let copyCleanLink: Self = .init( + id: "CopyCleanLink", + title: Strings.copyCleanLink, + braveSystemImage: "leo.copy.clean", + defaultRank: 1300, + defaultVisibility: .hidden + ) + public static let sendURL: Self = .init( + id: "SendURL", + title: Strings.OpenTabs.sendWebsiteShareActionTitle, + braveSystemImage: "leo.smartphone.laptop", + defaultRank: 2100, + defaultVisibility: .hidden + ) + public static let toggleReaderMode: Self = .init( + id: "ToggleReaderMode", + title: Strings.toggleReaderMode, + braveSystemImage: "leo.product.speedreader", + defaultRank: 1400, + defaultVisibility: .hidden + ) + public static let findInPage: Self = .init( + id: "FindInPage", + title: Strings.findInPage, + braveSystemImage: "leo.search", + defaultRank: 700, + defaultVisibility: .visible + ) + public static let pageZoom: Self = .init( + id: "PageZoom", + title: Strings.PageZoom.settingsTitle, + braveSystemImage: "leo.font.size", + defaultRank: 1500, + defaultVisibility: .hidden + ) + public static let addFavourites: Self = .init( + id: "AddFavourites", + title: Strings.addToFavorites, + braveSystemImage: "leo.star.outline", + defaultRank: 1100, + defaultVisibility: .hidden + ) + public static let addBookmark: Self = .init( + id: "AddBookmark", + title: Strings.addToMenuItem, + braveSystemImage: "leo.browser.bookmark-add", + defaultRank: 200, + defaultVisibility: .visible + ) + public static let requestDesktopSite: Self = .init( + id: "ToggleUserAgent", + title: Strings.appMenuViewDesktopSiteTitleString, + braveSystemImage: "leo.monitor", + defaultRank: 2200, + defaultVisibility: .hidden + ) + public static let addSourceNews: Self = .init( + id: "AddSourceNews", + title: Strings.BraveNews.addSourceShareTitle, + braveSystemImage: "leo.rss", + defaultRank: 2600, + defaultVisibility: .hidden + ) + public static let createPDF: Self = .init( + id: "CreatePDF", + title: Strings.createPDF, + braveSystemImage: "leo.file.new", + defaultRank: 1900, + defaultVisibility: .hidden + ) + public static let addSearchEngine: Self = .init( + id: "AddSearchEngine", + title: Strings.CustomSearchEngine.customEngineNavigationTitle, + braveSystemImage: "leo.internet.search", + defaultRank: 2700, + defaultVisibility: .hidden + ) + public static let displaySecurityCertificate: Self = .init( + id: "DisplaySecurityCertificate", + title: Strings.displayCertificate, + braveSystemImage: "leo.certificate.valid", + defaultRank: 2800, + defaultVisibility: .hidden + ) + public static let reportBrokenSite: Self = .init( + id: "ReportBrokenSite", + title: Strings.Shields.reportABrokenSite, + braveSystemImage: "leo.file.warning", + defaultRank: 2300, + defaultVisibility: .hidden + ) +} diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ar.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ar.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ar.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/bs.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/bs.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/bs.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ca.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ca.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ca.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/cs.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/cs.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/cs.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/da.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/da.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/da.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/de.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/de.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/de.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/el.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/el.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/el.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/en.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/en.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/en.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/es.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/es.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/es.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/fi.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/fi.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/fi.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/fr.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/fr.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/fr.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/gsw.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/gsw.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/gsw.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/hr.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/hr.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/hr.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/hu.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/hu.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/hu.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/id-ID.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/id-ID.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/id-ID.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/it.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/it.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/it.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ja.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ja.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ja.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ko-KR.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ko-KR.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ko-KR.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ms.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ms.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ms.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/nb.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/nb.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/nb.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/nl.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/nl.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/nl.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/pl.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/pl.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/pl.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/pt-BR.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/pt-BR.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/pt-BR.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/pt-PT.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/pt-PT.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/pt-PT.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/pt.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/pt.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/pt.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ro.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ro.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ro.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/ru.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/ru.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/ru.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/sk.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/sk.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/sk.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/sv.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/sv.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/sv.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/tr.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/tr.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/tr.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/uk.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/uk.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/uk.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/zh-TW.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/zh-TW.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/zh-TW.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/Resources/zh.lproj/Localizable.strings b/ios/brave-ios/Sources/BrowserMenu/Resources/zh.lproj/Localizable.strings new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/Resources/zh.lproj/Localizable.strings @@ -0,0 +1 @@ + diff --git a/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift b/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift new file mode 100644 index 000000000000..ad88fa030c92 --- /dev/null +++ b/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift @@ -0,0 +1,72 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import BraveVPN +import Combine +import GuardianConnect + +/// The current status of the Brave VPN connection +enum VPNStatus: Equatable { + /// VPN is not connected + case disconnected + /// VPN is connected to a given region + case connected(activeRegion: VPNRegion) +} + +/// The VPN region details to show +struct VPNRegion: Equatable { + /// The unicode/emoji flag for the given country code provided + var flag: String + /// The display name for the connected server + var displayName: String + + init(countryCode: String, displayName: String) { + self.flag = Self.flagEmojiForCountryCode(code: countryCode) + self.displayName = displayName + } + + private static func flagEmojiForCountryCode(code: String) -> String { + // Root Unicode flags index + let rootIndex: UInt32 = 127397 + var unicodeScalarView = "" + + for scalar in code.unicodeScalars { + // Shift the letter index to the flags index + if let appendedScalar = UnicodeScalar(rootIndex + scalar.value) { + // Append symbol to the Unicode string + unicodeScalarView.unicodeScalars.append(appendedScalar) + } + } + return unicodeScalarView + } +} + +// MARK: - Live Values + +extension VPNRegion { + init(region: GRDRegion) { + self.init(countryCode: region.countryISOCode, displayName: region.displayName) + } +} + +extension VPNStatus { + static var liveVPNStatus: VPNStatus { + if BraveVPN.isConnected, let region = BraveVPN.activatedRegion.map(VPNRegion.init) { + return .connected(activeRegion: region) + } + return .disconnected + } +} + +extension AnyPublisher { + static var liveVPNStatus: AnyPublisher { + NotificationCenter.default + .publisher(for: .NEVPNStatusDidChange) + .map { _ in + return .liveVPNStatus + } + .eraseToAnyPublisher() + } +} diff --git a/ios/brave-ios/Tests/BrowserMenuTests/BrowserMenuTests.swift b/ios/brave-ios/Tests/BrowserMenuTests/BrowserMenuTests.swift new file mode 100644 index 000000000000..1e2d82eb5eac --- /dev/null +++ b/ios/brave-ios/Tests/BrowserMenuTests/BrowserMenuTests.swift @@ -0,0 +1,325 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +import Combine +import Foundation +import Preferences +import XCTest + +@testable import BrowserMenu + +extension Action { + static func test( + id: String = UUID().uuidString, + rank: Int, + visibility: Action.Visibility + ) -> Action { + .init( + id: .init( + id: id, + title: id, + braveSystemImage: "", + defaultRank: rank, + defaultVisibility: visibility + ) + ) + } +} + +extension Action: CustomDebugStringConvertible { + public var debugDescription: String { + "\(id.defaultRank) \(id.defaultVisibility == .visible ? "visible" : "hidden") (\(id.id))" + } +} + +class BrowserMenuTests: XCTestCase { + + private let actionRanks: Preferences.Option<[String: Double]> = .init(key: "ranks", default: [:]) + private let actionVisibility: Preferences.Option<[String: Bool]> = .init( + key: "visibilities", + default: [:] + ) + + override func tearDown() { + super.tearDown() + + actionRanks.reset() + actionVisibility.reset() + } + + /// Tests setting up the model with actions that are all visible and all pre-sorted + @MainActor func testPresortedAllVisibleActions() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + XCTAssertEqual(model.visibleActions, actions) // Already in order + XCTAssertTrue(model.hiddenActions.isEmpty) // Nothing hidden + } + + /// Tests adding a hidden item to be visible and vice-versa + @MainActor func testVisibilityChange() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + XCTAssertEqual(model.visibleActions, [actions[0], actions[1]]) + XCTAssertEqual(model.hiddenActions, [actions[2]]) + + model.updateActionVisibility(actions[0], visibility: .hidden) + + XCTAssertEqual(model.visibleActions, [actions[1]]) + XCTAssertEqual(model.hiddenActions, [actions[0], actions[2]]) + + model.updateActionVisibility(actions[2], visibility: .visible) + XCTAssertEqual(model.visibleActions, [actions[1], actions[2]]) + XCTAssertEqual(model.hiddenActions, [actions[0]]) + } + + /// Tests that altering default visibility of an action will adjust its overriden rank such + /// that a hidden action made visible will be appended to the end of the list (final rank + 1), + /// and that a visible action made hidden will fallback to its original default ranking + @MainActor func testSortOverrideAfterVisbilityChange() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .hidden), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + model.updateActionVisibility(actions[1], visibility: .visible) + XCTAssertEqual(model.visibleActions, [actions[0], actions[2], actions[1]]) + model.updateActionVisibility(actions[1], visibility: .hidden) + XCTAssertNil(actionRanks.value[actions[1].id.id]) + XCTAssertEqual(model.hiddenActions, [actions[1], actions[3]]) + } + + /// This tests the edge case where the user has hidden every action, and is now adding one + /// to the visible list + @MainActor func testHiddenToVisibleWithNoVisibleActions() { + let actions: [Action] = [ + .test(rank: 1, visibility: .hidden), + .test(rank: 2, visibility: .hidden), + .test(rank: 3, visibility: .hidden), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + model.updateActionVisibility(actions[0], visibility: .visible) + XCTAssertEqual(model.visibleActions, [actions[0]]) + // No need to give it a custom rank since there are no other items in the visible list + XCTAssertNil(actionRanks.value[actions[0].id.id]) + } + + @MainActor func testVisibleToHiddenWithNoHiddenActions() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .visible), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + model.updateActionVisibility(actions[0], visibility: .hidden) + XCTAssertEqual(model.hiddenActions, [actions[0]]) + // No need to give it a custom rank since there are no other items in the visible list + XCTAssertNil(actionRanks.value[actions[0].id.id]) + } + + @MainActor func testSortByDefaultRankings() { + let actions: [Action] = [ + .test(rank: 3, visibility: .visible), + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 5, visibility: .hidden), + .test(rank: 9, visibility: .hidden), + .test(rank: 6, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + XCTAssertEqual(model.visibleActions, [actions[1], actions[2], actions[0]]) + XCTAssertEqual(model.hiddenActions, [actions[3], actions[5], actions[4]]) + } + + @MainActor func testReorderLastItemToFirstIndex() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + model.reorderVisibleAction(actions[2], to: 0) + XCTAssertEqual(model.visibleActions, [actions[2], actions[0], actions[1]]) + } + + @MainActor func testReorderFirstItemToLastIndex() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + model.reorderVisibleAction(actions[0], to: 2) + XCTAssertEqual(model.visibleActions, [actions[1], actions[2], actions[0]]) + } + + @MainActor func testReorderLastItemToMiddleIndex() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + model.reorderVisibleAction(actions[2], to: 1) + XCTAssertEqual(model.visibleActions, [actions[0], actions[2], actions[1]]) + } + + @MainActor func testNewActionAddedAfterReorder() { + var actions: [Action] = [ + .test(rank: 10, visibility: .visible), + .test(rank: 20, visibility: .visible), + .test(rank: 30, visibility: .visible), + .test(rank: 40, visibility: .hidden), + ] + var model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + model.reorderVisibleAction(actions[2], to: 1) // actions[2] rank is now 15 + + XCTAssertEqual(model.visibleActions, [actions[0], actions[2], actions[1]]) + + // Re-set up model with a new action + actions.append(.test(rank: 25, visibility: .visible)) + model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + XCTAssertEqual(model.visibleActions, [actions[0], actions[2], actions[1], actions[4]]) + } + + @MainActor func testMultipleReorders() { + let actions: [Action] = [ + .test(id: "alpha", rank: 1, visibility: .visible), + .test(id: "beta", rank: 2, visibility: .visible), + .test(id: "gamma", rank: 3, visibility: .visible), + .test(id: "delta", rank: 4, visibility: .visible), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + // move "gamma" above "beta" + model.reorderVisibleAction(actions[2], to: 1) + XCTAssertEqual(model.visibleActions.map(\.id.id), ["alpha", "gamma", "beta", "delta"]) + + // move "delta" above "alpha" + model.reorderVisibleAction(actions[3], to: 0) + XCTAssertEqual(model.visibleActions.map(\.id.id), ["delta", "alpha", "gamma", "beta"]) + + // move "beta" back below "delta" to its original spot + model.reorderVisibleAction(actions[1], to: 1) + XCTAssertEqual(model.visibleActions.map(\.id.id), ["delta", "beta", "alpha", "gamma"]) + + // move "alpha" below "gamma" + model.reorderVisibleAction(actions[0], to: 3) + XCTAssertEqual(model.visibleActions.map(\.id.id), ["delta", "beta", "gamma", "alpha"]) + } + + @MainActor func testReorderingHiddenActionsDoesNothing() { + let actions: [Action] = [ + .test(rank: 1, visibility: .visible), + .test(rank: 2, visibility: .visible), + .test(rank: 3, visibility: .visible), + .test(rank: 4, visibility: .hidden), + ] + let model = BrowserMenuModel( + actions: actions, + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + + model.reorderVisibleAction(actions[3], to: 1) + XCTAssertEqual(model.visibleActions, Array(actions[0..<3])) + } + + @MainActor func testVPNRegionPublishing() async throws { + let vpnStatusPublisher = CurrentValueSubject(.disconnected) + let model = BrowserMenuModel( + actions: [], + vpnStatusPublisher: vpnStatusPublisher.eraseToAnyPublisher(), + actionVisibility: actionVisibility, + actionRanks: actionRanks + ) + XCTAssertEqual(model.vpnStatus, .disconnected) + vpnStatusPublisher.send( + .connected(activeRegion: .init(countryCode: "CA", displayName: "ca-east")) + ) + // CurrentValueSubject vends its current value immediately to the stream so we want to ignore + // it as we already asserted the state of that before + for await _ in model.$vpnStatus.values.dropFirst() { + switch model.vpnStatus { + case .connected(let region): + XCTAssertEqual(region.flag, "🇨🇦") + XCTAssertEqual(region.displayName, "ca-east") + case .disconnected: + XCTFail() + } + break + } + } +} diff --git a/ios/browser/api/features/BUILD.gn b/ios/browser/api/features/BUILD.gn index 91d59a6f71c7..3f4cff0802d3 100644 --- a/ios/browser/api/features/BUILD.gn +++ b/ios/browser/api/features/BUILD.gn @@ -27,6 +27,7 @@ source_set("features") { "//brave/components/p3a:p3a", "//brave/components/skus/common:common", "//brave/ios/browser/playlist", + "//brave/ios/browser/ui/browser_menu:features", "//build:blink_buildflags", "//ios/components/security_interstitials/https_only_mode:feature", "//net", diff --git a/ios/browser/api/features/features.h b/ios/browser/api/features/features.h index 07d3451c9497..6b1a6f6d18e5 100644 --- a/ios/browser/api/features/features.h +++ b/ios/browser/api/features/features.h @@ -76,6 +76,7 @@ OBJC_EXPORT @property(class, nonatomic, readonly) Feature* kBraveHttpsByDefault; @property(class, nonatomic, readonly) Feature* kHttpsOnlyMode; @property(class, nonatomic, readonly) Feature* kBlockAllCookiesToggle; +@property(class, nonatomic, readonly) Feature* kModernBrowserMenuEnabled; @end NS_ASSUME_NONNULL_END diff --git a/ios/browser/api/features/features.mm b/ios/browser/api/features/features.mm index 4633e57ee0f3..f2c8d6dedb44 100644 --- a/ios/browser/api/features/features.mm +++ b/ios/browser/api/features/features.mm @@ -24,6 +24,7 @@ #include "brave/components/playlist/common/features.h" #include "brave/components/skus/common/features.h" #include "brave/ios/browser/playlist/features.h" +#include "brave/ios/browser/ui/browser_menu/features.h" #import "build/blink_buildflags.h" #include "build/build_config.h" #include "ios/components/security_interstitials/https_only_mode/feature.h" @@ -318,4 +319,9 @@ + (Feature*)kBlockAllCookiesToggle { initWithFeature:&brave_shields::features::kBlockAllCookiesToggle]; } ++ (Feature*)kModernBrowserMenuEnabled { + return [[Feature alloc] + initWithFeature:&brave::features::kModernBrowserMenuEnabled]; +} + @end diff --git a/ios/browser/flags/about_flags.mm b/ios/browser/flags/about_flags.mm index 126aa83a44ed..c4a11f01111e 100644 --- a/ios/browser/flags/about_flags.mm +++ b/ios/browser/flags/about_flags.mm @@ -17,6 +17,7 @@ #include "brave/components/ntp_background_images/browser/features.h" #include "brave/components/skus/common/features.h" #include "brave/ios/browser/playlist/features.h" +#include "brave/ios/browser/ui/browser_menu/features.h" #include "build/build_config.h" #include "components/flags_ui/feature_entry_macros.h" #include "components/flags_ui/flags_state.h" @@ -163,6 +164,13 @@ flags_ui::kOsIos, \ FEATURE_VALUE_TYPE(brave_component_updater::kUseDevUpdaterUrl), \ }, \ + { \ + "brave-use-modern-browser-menu", \ + "Use modern browser menu UI", \ + "Replace the standard more button menu with a modern replacement", \ + flags_ui::kOsIos, \ + FEATURE_VALUE_TYPE(brave::features::kModernBrowserMenuEnabled), \ + }, \ { \ "brave-ntp-branded-wallpaper-demo", \ "New Tab Page Demo Branded Wallpaper", \ diff --git a/ios/browser/flags/sources.gni b/ios/browser/flags/sources.gni index 4e7a22e05f9a..b343e7dfa2cc 100644 --- a/ios/browser/flags/sources.gni +++ b/ios/browser/flags/sources.gni @@ -16,6 +16,7 @@ brave_flags_deps = [ "//brave/components/ntp_background_images/browser", "//brave/components/skus/common", "//brave/ios/browser/playlist", + "//brave/ios/browser/ui/browser_menu:features", "//components/flags_ui", "//net", ] diff --git a/ios/browser/ui/browser_menu/BUILD.gn b/ios/browser/ui/browser_menu/BUILD.gn new file mode 100644 index 000000000000..934887b43d1f --- /dev/null +++ b/ios/browser/ui/browser_menu/BUILD.gn @@ -0,0 +1,12 @@ +# Copyright (c) 2024 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. + +source_set("features") { + sources = [ + "features.h", + "features.mm", + ] + deps = [ "//base" ] +} diff --git a/ios/browser/ui/browser_menu/features.h b/ios/browser/ui/browser_menu/features.h new file mode 100644 index 000000000000..1ad83ecd4500 --- /dev/null +++ b/ios/browser/ui/browser_menu/features.h @@ -0,0 +1,18 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#ifndef BRAVE_IOS_BROWSER_UI_BROWSER_MENU_FEATURES_H_ +#define BRAVE_IOS_BROWSER_UI_BROWSER_MENU_FEATURES_H_ + +#import "base/feature_list.h" + +namespace brave::features { + +// Whether or not to use the new browser menu UI +BASE_DECLARE_FEATURE(kModernBrowserMenuEnabled); + +} // namespace brave::features + +#endif // BRAVE_IOS_BROWSER_UI_BROWSER_MENU_FEATURES_H_ diff --git a/ios/browser/ui/browser_menu/features.mm b/ios/browser/ui/browser_menu/features.mm new file mode 100644 index 000000000000..d676b72452c4 --- /dev/null +++ b/ios/browser/ui/browser_menu/features.mm @@ -0,0 +1,14 @@ +// Copyright (c) 2024 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this file, +// You can obtain one at https://mozilla.org/MPL/2.0/. + +#include "brave/ios/browser/ui/browser_menu/features.h" + +namespace brave::features { + +BASE_FEATURE(kModernBrowserMenuEnabled, + "ModernBrowserMenuEnabled", + base::FEATURE_DISABLED_BY_DEFAULT); + +} diff --git a/ios/nala/BUILD.gn b/ios/nala/BUILD.gn index cdffdf4d777c..5d41fc8d44a7 100644 --- a/ios/nala/BUILD.gn +++ b/ios/nala/BUILD.gn @@ -40,6 +40,7 @@ nala_icons = [ "leo.carat.down.svg", "leo.carat.right.svg", "leo.carat.up.svg", + "leo.certificate.valid.svg", "leo.check.circle-filled.svg", "leo.check.circle-outline.svg", "leo.check.normal.svg", @@ -52,6 +53,7 @@ nala_icons = [ "leo.coins.alt1.svg", "leo.coins.alt2.svg", "leo.coins.svg", + "leo.copy.clean.svg", "leo.copy.plain-text.svg", "leo.copy.svg", "leo.crown.svg", @@ -67,7 +69,9 @@ nala_icons = [ "leo.face.id.svg", "leo.file.new.svg", "leo.file.svg", + "leo.file.warning.svg", "leo.filter.settings.svg", + "leo.folder.download.svg", "leo.folder.exchange.svg", "leo.folder.new.svg", "leo.folder.open.svg", @@ -92,6 +96,7 @@ nala_icons = [ "leo.import.arrow.svg", "leo.info.filled.svg", "leo.info.outline.svg", + "leo.internet.search.svg", "leo.internet.svg", "leo.key.lock.svg", "leo.key.svg", @@ -153,6 +158,7 @@ nala_icons = [ "leo.plus.add.svg", "leo.podcast.svg", "leo.previous.outline.svg", + "leo.print.svg", "leo.product.bat-outline.svg", "leo.product.bookmarks.svg", "leo.product.brave-leo.svg", @@ -201,6 +207,8 @@ nala_icons = [ "leo.thumb.down.svg", "leo.thumb.up.svg", "leo.timer.svg", + "leo.toggle.off.svg", + "leo.toggle.on.svg", "leo.trash.svg", "leo.tune.svg", "leo.user.accounts.svg", From a01109c7226fac3f10cf951f463dbf18a4734d8e Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Thu, 12 Dec 2024 14:33:52 -0500 Subject: [PATCH 2/2] Improve action handler spamming, fix nits & add comments --- .../Sources/BrowserMenu/BrowserMenu.swift | 12 ++++++++++- .../BrowserMenu/BrowserMenuModel.swift | 2 +- .../Sources/BrowserMenu/Identifiers.swift | 21 +++++++++++++++++++ .../Sources/BrowserMenu/VPNStatus.swift | 2 +- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift index 7f56595f0027..535d846e0eb6 100644 --- a/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenu.swift @@ -18,11 +18,20 @@ public struct BrowserMenu: View { /// Whether or not we may be viewing on a device that doesn't have as much horizontal space /// available (such as when Display Zoom is enabled) @State private var isHorizontalSpaceRestricted: Bool = false + @State private var activeActionHandlers: [Action.ID: Task] = [:] @Environment(\.dynamicTypeSize) private var dynamicTypeSize private func handleAction(_ action: Binding) { - Task { + let id = action.wrappedValue.id + if activeActionHandlers[id] != nil { + // The action is in progress + return + } + let actionTask = Task { + defer { + activeActionHandlers[id] = nil + } let result = await action.wrappedValue.handler(action.wrappedValue) switch result { case .updateAction(let replacement): @@ -34,6 +43,7 @@ public struct BrowserMenu: View { break } } + activeActionHandlers[id] = actionTask } private var numberOfQuickActions: Int { diff --git a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift index 281b1670fdee..930d47496e2d 100644 --- a/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift +++ b/ios/brave-ios/Sources/BrowserMenu/BrowserMenuModel.swift @@ -86,7 +86,7 @@ import SwiftUI } func updateActionVisibility(_ action: Action, visibility: Action.Visibility) { - actionVisibility.value[action.id.id] = visibility == .visible ? true : false + actionVisibility.value[action.id.id] = visibility == .visible if visibility == .visible { // If the user is making a hidden item visible and the visible list is empty, we won't // give it an overridden rank to ensure it ends at the bottom diff --git a/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift b/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift index 77017a3e4912..e10561534734 100644 --- a/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift +++ b/ios/brave-ios/Sources/BrowserMenu/Identifiers.swift @@ -6,6 +6,27 @@ import BraveWallet import Foundation +/// The list of action identifiers +/// +/// Every action that is shown in the menu must have an identifier created here so that it may be +/// properly displayed and ordered in the new menu customization. The new menu should use every +/// identifier that is usable even if the the action cannot be made in the current context so that +/// user ordering & visibility is consistent. +/// +/// **A note on rankings & visibility**: +/// The new menu relies on 2 things for default item display: default ranking & default visibility. +/// +/// Default rankings & visibility are currently based on a list in Figma and shouldn't be altered to +/// ensure items don't move around on the user. +/// +/// When a new menu item is added it should be given a new default ranking that isn't currently +/// used. Currently all rankings are incremented by 100 to allow for new items to be added in +/// between, so for example a new action identifier could be given a default rank of 175 if we +/// wanted it to be placed second in the list if no user customization has occured or third in the +/// list if it has (reordering sets overriden ranks based on the halfway point between items). +/// +/// Default visibility controls whether or not the item appears on the menu without the user tapping +/// "Show All…" to display all actions or explicitly adding it to the menu themselves. extension Action.Identifier { public static let vpn: Self = .init( diff --git a/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift b/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift index ad88fa030c92..2bbbbae0ee04 100644 --- a/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift +++ b/ios/brave-ios/Sources/BrowserMenu/VPNStatus.swift @@ -28,7 +28,7 @@ struct VPNRegion: Equatable { } private static func flagEmojiForCountryCode(code: String) -> String { - // Root Unicode flags index + // Regional indicator symbol root Unicode flags index let rootIndex: UInt32 = 127397 var unicodeScalarView = ""