From 0294dacfca085f5fecf45374c53662d6647875a9 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:03:22 +0200 Subject: [PATCH 01/12] chore: add strings --- firefox-ios/Shared/Strings.swift | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/firefox-ios/Shared/Strings.swift b/firefox-ios/Shared/Strings.swift index ab37792131291..d2babffb2cf9f 100644 --- a/firefox-ios/Shared/Strings.swift +++ b/firefox-ios/Shared/Strings.swift @@ -3362,6 +3362,18 @@ extension String { value: "Media", comment: "This is the title for Media customization under the Browsing settings section." ) + public static let Content = MZLocalizedString( + key: "Settings.Browsing.Content.v152", + tableName: "Settings", + value: "Content", + comment: "This is the title for the Content customization section under the Browsing settings page." + ) + public static let BlockAds = MZLocalizedString( + key: "Settings.Browsing.BlockAds.Title.v152", + tableName: "Settings", + value: "Block Ads", + comment: "Title for the Block Ads toggle in the Browsing settings page. When enabled, the app blocks advertisements." + ) } public struct AIControls { @@ -5861,6 +5873,16 @@ extension String { } public struct SiteProtection { + public static let Protections = MZLocalizedString( + key: "MainMenu.SiteProtection.Protections.Title.v200", + tableName: "MainMenu", + value: "Protections", + comment: "On the main menu, at the top, title for a badge that opens site protection settings.") + public static let AdBlocker = MZLocalizedString( + key: "MainMenu.SiteProtection.AdBlocker.Title.v200", + tableName: "MainMenu", + value: "Ad Blocker", + comment: "On the main menu, at the top, title for a badge next to Protections that opens the ad blocker setting.") public static let ProtectionsOn = MZLocalizedString( key: "MainMenu.SiteProtection.ProtectionsOn.Title.v141", tableName: "MainMenu", From 873e76219204a0b1f4a59baffaf9d63471471b06 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:24:44 +0200 Subject: [PATCH 02/12] chore: add feature flag --- .../Client/FeatureFlags/FeatureFlagID.swift | 4 +++- .../FeatureFlagsDebugViewController.swift | 7 +++++++ .../Nimbus/NimbusFeatureFlagLayer.swift | 7 +++++++ .../nimbus-features/adBlockerFeature.yaml | 19 +++++++++++++++++++ firefox-ios/nimbus.fml.yaml | 1 + 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 firefox-ios/nimbus-features/adBlockerFeature.yaml diff --git a/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift b/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift index e4b38048dda4e..705f3c78c4db3 100644 --- a/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift +++ b/firefox-ios/Client/FeatureFlags/FeatureFlagID.swift @@ -7,6 +7,7 @@ import Shared /// An enum describing the featureID of all features found in Nimbus. /// Please add new features alphabetically. enum FeatureFlagID: String, CaseIterable { + case adBlocker case addressAutofillEdit case addressBarMenu case adsClient @@ -84,7 +85,8 @@ enum FeatureFlagID: String, CaseIterable { // Add in alphabetical order. var debugKey: String? { switch self { - case .addressBarMenu, + case .adBlocker, + .addressBarMenu, .adsClient, .aiKillSwitch, .appearanceMenu, diff --git a/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift b/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift index 450f7e6fa32c5..3721af36a96ae 100644 --- a/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift @@ -31,6 +31,13 @@ final class FeatureFlagsDebugViewController: SettingsTableViewController, Featur // For better code readability and parsability in-app, please keep in alphabetical // order by titleText let children: [Setting] = [ + FeatureFlagsBoolSetting( + with: .adBlocker, + titleText: format(string: "Ad Blocker"), + statusText: format(string: "Toggle to show the Ad Blocker feature.") + ) { [weak self] _ in + self?.reloadView() + }, FeatureFlagsBoolSetting( with: .adsClient, titleText: format(string: "Ads Client"), diff --git a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift index 4a2a2a5867c47..4ea6e5c0f5b79 100644 --- a/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift +++ b/firefox-ios/Client/Nimbus/NimbusFeatureFlagLayer.swift @@ -21,6 +21,9 @@ final class NimbusFeatureFlagLayer: NimbusFeatureFlagLayerProviding, Sendable { public func checkNimbusConfigFor(_ featureID: FeatureFlagID) -> Bool { // For better code readability, please keep in alphabetical order by FeatureFlagID switch featureID { + case .adBlocker: + return checkAdBlockerFeature() + case .addressAutofillEdit: return checkAddressAutofillEditing() @@ -437,6 +440,10 @@ final class NimbusFeatureFlagLayer: NimbusFeatureFlagLayerProviding, Sendable { return nimbus.features.worldCupWidgetFeature.value().enabled } + private func checkAdBlockerFeature() -> Bool { + return nimbus.features.adBlockerFeature.value().enabled + } + func checkStartAtHomeConfiguration() -> StartAtHome { return nimbus.features.startAtHomeFeature.value().setting } diff --git a/firefox-ios/nimbus-features/adBlockerFeature.yaml b/firefox-ios/nimbus-features/adBlockerFeature.yaml new file mode 100644 index 0000000000000..464921bf85902 --- /dev/null +++ b/firefox-ios/nimbus-features/adBlockerFeature.yaml @@ -0,0 +1,19 @@ +# The configuration for the adBlocker feature +features: + ad-blocker-feature: + description: > + The feature flag to manage the roll out of the Ad Blocker badge in the + Site Menu and the corresponding Block Ads toggle in Browsing settings. + variables: + enabled: + description: > + Whether or not this feature is enabled + type: Boolean + default: false + defaults: + - channel: beta + value: + enabled: false + - channel: developer + value: + enabled: false diff --git a/firefox-ios/nimbus.fml.yaml b/firefox-ios/nimbus.fml.yaml index 55fcaf4203ce5..39913008b0ead 100644 --- a/firefox-ios/nimbus.fml.yaml +++ b/firefox-ios/nimbus.fml.yaml @@ -15,6 +15,7 @@ channels: include: + - nimbus-features/adBlockerFeature.yaml - nimbus-features/addressAutofillFeature.yaml - nimbus-features/adsClient.yaml - nimbus-features/aiKillSwitchFeature.yaml From 4b677accd68f61183c1701228acdc3fc997ad3f1 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:26:12 +0200 Subject: [PATCH 03/12] feat: add toggle to settings --- BrowserKit/Sources/Shared/Prefs.swift | 1 + .../BrowsingSettingsViewController.swift | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/BrowserKit/Sources/Shared/Prefs.swift b/BrowserKit/Sources/Shared/Prefs.swift index 306065f08f007..7cc7035fcb3e1 100644 --- a/BrowserKit/Sources/Shared/Prefs.swift +++ b/BrowserKit/Sources/Shared/Prefs.swift @@ -50,6 +50,7 @@ public struct PrefsKeys { public static let ShowClipboardBar = "showClipboardBar" public static let ShowRelayMaskSuggestions = "showRelayMaskSuggestions" public static let BlockOpeningExternalApps = "blockOpeningExternalApps" + public static let BlockAds = "blockAds" public static let NewTabCustomUrlPrefKey = "HomePageURLPref" public static let GoogleTopSiteAddedKey = "googleTopSiteAddedKey" public static let GoogleTopSiteHideKey = "googleTopSiteHideKey" diff --git a/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift b/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift index 7077f426fea5f..c888dd8e2a38c 100644 --- a/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift +++ b/firefox-ios/Client/Frontend/Settings/BrowsingSettingsViewController.swift @@ -14,7 +14,7 @@ protocol BrowsingSettingsDelegate: AnyObject { func pressedAutoPlay() } -class BrowsingSettingsViewController: SettingsTableViewController { +class BrowsingSettingsViewController: SettingsTableViewController, FeatureFlaggable { weak var parentCoordinator: BrowsingSettingsDelegate? init(profile: Profile, @@ -40,7 +40,7 @@ class BrowsingSettingsViewController: SettingsTableViewController { var settings = [SettingSection]() var linksSettings: [Setting] = [OpenWithSetting(settings: self, settingsDelegate: parentCoordinator)] - var mediaSection = [Setting]() + var contentSection = [Setting]() if let profile { let theme = themeManager.getCurrentTheme(for: windowUUID) let offerToOpenCopiedLinksSettings = BoolSetting( @@ -76,18 +76,31 @@ class BrowsingSettingsViewController: SettingsTableViewController { prefs: profile.prefs, settingsDelegate: parentCoordinator) - mediaSection += [ - autoplaySetting, + contentSection.append(autoplaySetting) + if featureFlagsProvider.isEnabled(.adBlocker) { + contentSection.append(BoolSetting( + prefs: profile.prefs, + theme: theme, + prefKey: PrefsKeys.BlockAds, + defaultValue: false, + titleText: .Settings.Browsing.BlockAds + )) + } + contentSection += [ BlockPopupSetting(prefs: profile.prefs), NoImageModeSetting(profile: profile), blockOpeningExternalAppsSettings ] } + let contentSectionTitle = featureFlagsProvider.isEnabled(.adBlocker) + ? String.Settings.Browsing.Content + : String.Settings.Browsing.Media + settings += [SettingSection(title: NSAttributedString(string: .Settings.Browsing.Links), children: linksSettings), - SettingSection(title: NSAttributedString(string: .Settings.Browsing.Media), - children: mediaSection)] + SettingSection(title: NSAttributedString(string: contentSectionTitle), + children: contentSection)] return settings } From 891edeeb890fa9f8b143a34fb148c3ceb719e967 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:55:36 +0200 Subject: [PATCH 04/12] feat: add tests --- .../BrowsingSettingsViewControllerTests.swift | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift index db5164718e1b7..c2cc62ce7c5bb 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Settings/BrowsingSettingsViewControllerTests.swift @@ -12,10 +12,12 @@ import XCTest final class BrowsingSettingsViewControllerTests: XCTestCase { private var profile: Profile! private var delegate: MockSettingsDelegate! + private var featureFlags: MockNimbusFeatureFlags! override func setUp() async throws { try await super.setUp() - DependencyHelperMock().bootstrapDependencies() + self.featureFlags = MockNimbusFeatureFlags() + DependencyHelperMock().bootstrapDependencies(injectedFeatureFlagProvider: featureFlags) self.profile = MockProfile() self.delegate = MockSettingsDelegate() } @@ -24,6 +26,7 @@ final class BrowsingSettingsViewControllerTests: XCTestCase { DependencyHelperMock().reset() self.profile = nil self.delegate = nil + self.featureFlags = nil try await super.tearDown() } @@ -32,6 +35,30 @@ final class BrowsingSettingsViewControllerTests: XCTestCase { trackForMemoryLeaks(subject) } + func testGenerateSettings_whenAdBlockerFlagOff_omitsBlockAdsAndUsesMediaSection() { + featureFlags.enabledFlags = [] + let subject = createSubject() + + let sections = subject.generateSettings() + let contentSection = sections.last + let titles = contentSection?.children.compactMap { $0.title?.string } ?? [] + + XCTAssertEqual(contentSection?.title?.string, String.Settings.Browsing.Media) + XCTAssertFalse(titles.contains(String.Settings.Browsing.BlockAds)) + } + + func testGenerateSettings_whenAdBlockerFlagOn_includesBlockAdsAndUsesContentSection() { + featureFlags.enabledFlags = [.adBlocker] + let subject = createSubject() + + let sections = subject.generateSettings() + let contentSection = sections.last + let titles = contentSection?.children.compactMap { $0.title?.string } ?? [] + + XCTAssertEqual(contentSection?.title?.string, String.Settings.Browsing.Content) + XCTAssertTrue(titles.contains(String.Settings.Browsing.BlockAds)) + } + // MARK: - Helper private func createSubject() -> BrowsingSettingsViewController { let subject = BrowsingSettingsViewController(profile: profile, From 3ef26066bbdfa2b411b8020e392be3b8af00b350 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:31:31 +0200 Subject: [PATCH 05/12] chore: add icons --- .../Sources/Common/Constants/StandardImageIdentifiers.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift b/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift index 3d9e409f06667..ca5b7f6a94368 100644 --- a/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift +++ b/BrowserKit/Sources/Common/Constants/StandardImageIdentifiers.swift @@ -25,6 +25,8 @@ public struct StandardImageIdentifiers { // Icon size 20x20 public struct Medium { + public static let adBlockerCheckmark = "adBlockerCheckmarkMedium" + public static let adBlockerCross = "adBlockerCrossMedium" public static let arrowClockwise = "arrowClockwiseMedium" public static let bookmarkBadgeFillBlue50 = "bookmarkBadgeFillMediumBlue50" public static let cross = "crossMedium" From c1d843a6e790fba975ad72bcfe3bd57a5812eebe Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:33:33 +0200 Subject: [PATCH 06/12] chore: add badge telemetry action --- .../Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift | 4 ++++ .../Browser/MainMenu/Redux/MenuNavigationDestination.swift | 2 ++ 2 files changed, 6 insertions(+) diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift index cd65e0cfd9c5e..867ed990c551c 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MainMenuMiddleware.swift @@ -12,6 +12,7 @@ final class MainMenuMiddleware { private enum TelemetryAction { static let addToShortcuts = "add_to_shortcuts" static let bookmarks = "bookmarks" + static let adBlocker = "ad_blocker" static let bookmarkThisPage = "bookmark_this_page" static let defaultBrowserSettings = "default_browser_settings" static let downloads = "downloads" @@ -231,6 +232,9 @@ final class MainMenuMiddleware { case .siteProtections: self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.siteProtections) + case .adBlocker: + self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.adBlocker) + case .defaultBrowser: self.telemetry.mainMenuOptionTapped(with: isHomepage, and: TelemetryAction.defaultBrowserSettings) diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift index 70d4777cae40c..96fece4ff670f 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Redux/MenuNavigationDestination.swift @@ -6,6 +6,7 @@ import Foundation import SummarizeKit enum MainMenuNavigationDestination: Equatable { + case adBlocker case bookmarks case defaultBrowser case downloads @@ -31,6 +32,7 @@ enum MainMenuNavigationDestination: Equatable { /// revert to using CaseIterable on the enum. public static var allCasesForTests: [MainMenuNavigationDestination] { [ + .adBlocker, .bookmarks, .defaultBrowser, .downloads, From 77a3f983374dade8d341c8c2a80e6954cd87a5f6 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:34:52 +0200 Subject: [PATCH 07/12] fix: refactor badge into reusable component --- .../Sources/MenuKit/MenuSiteBadge.swift | 121 ++++++++++++++ .../MenuKit/MenuSiteProtectionsHeader.swift | 148 +++++++----------- 2 files changed, 180 insertions(+), 89 deletions(-) create mode 100644 BrowserKit/Sources/MenuKit/MenuSiteBadge.swift diff --git a/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift b/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift new file mode 100644 index 0000000000000..cdcf6a2dab34a --- /dev/null +++ b/BrowserKit/Sources/MenuKit/MenuSiteBadge.swift @@ -0,0 +1,121 @@ +// 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 http://mozilla.org/MPL/2.0/ + +import UIKit +import Common + +final class MenuSiteBadge: UIView, ThemeApplicable { + private struct UX { + static let cornerRadius: CGFloat = 12 + static let borderWidth: CGFloat = 1 + static let horizontalPadding: CGFloat = 10 + static let verticalPadding: CGFloat = 6 + static let contentSpacing: CGFloat = 4 + static let iconSize: CGFloat = 16 + static let chevronSize: CGFloat = 20 + } + + var tapHandler: (() -> Void)? + private let mainMenuHelper: MainMenuInterface + + private lazy var stack: UIStackView = .build { [weak self] stack in + guard let self else { return } + stack.isLayoutMarginsRelativeArrangement = true + stack.layoutMargins = UIEdgeInsets(top: UX.verticalPadding, + left: UX.horizontalPadding, + bottom: UX.verticalPadding, + right: UX.horizontalPadding) + stack.distribution = .fill + stack.axis = .horizontal + stack.clipsToBounds = true + stack.spacing = UX.contentSpacing + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(self.tapped)) + stack.isUserInteractionEnabled = true + stack.addGestureRecognizer(tapGesture) + } + + private var label: UILabel = .build { label in + label.font = FXFontStyles.Regular.footnote.scaledFont() + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + label.adjustsFontForContentSizeCategory = true + label.accessibilityTraits = .button + } + + private var icon: UIImageView = .build { imageView in + imageView.contentMode = .scaleAspectFit + } + + private var chevron: UIImageView = .build { imageView in + let imageName = StandardImageIdentifiers.Large.chevronRight + let image = UIImage(named: imageName)? + .withRenderingMode(.alwaysTemplate) + .imageFlippedForRightToLeftLayoutDirection() ?? UIImage() + imageView.image = image + imageView.contentMode = .scaleAspectFit + } + + init(mainMenuHelper: MainMenuInterface) { + self.mainMenuHelper = mainMenuHelper + super.init(frame: .zero) + setupViews() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + if #available(iOS 26.0, *) { + stack.layer.cornerRadius = stack.frame.height / 2 + } else { + stack.layer.cornerRadius = UX.cornerRadius + stack.layer.borderWidth = UX.borderWidth + } + } + + private func setupViews() { + addSubview(stack) + stack.addArrangedSubview(icon) + stack.addArrangedSubview(label) + stack.addArrangedSubview(chevron) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + + icon.widthAnchor.constraint(equalToConstant: UX.iconSize), + chevron.widthAnchor.constraint(equalToConstant: UX.chevronSize) + ]) + } + + func configure(text: String, iconName: String, useTemplate: Bool) { + label.text = text + let image: UIImage = useTemplate + ? UIImage(named: iconName)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + : UIImage(named: iconName) ?? UIImage() + icon.image = image + } + + func applyTheme(theme: Theme) { + label.textColor = theme.colors.textSecondary + stack.layer.borderColor = theme.colors.actionSecondaryHover.cgColor + if #available(iOS 26.0, *) { + stack.backgroundColor = theme.colors.layerSurfaceMedium + .withAlphaComponent(mainMenuHelper.backgroundAlpha()) + } else { + stack.backgroundColor = .clear + } + icon.tintColor = theme.colors.iconSecondary + chevron.tintColor = theme.colors.iconSecondary + } + + @objc + private func tapped() { + tapHandler?() + } +} diff --git a/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift b/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift index 4b52b59cd3b53..6528683ce1136 100644 --- a/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift +++ b/BrowserKit/Sources/MenuKit/MenuSiteProtectionsHeader.swift @@ -7,24 +7,31 @@ import Common import SiteImageView import ComponentLibrary +public struct MenuSiteAdBlockerBadgeData { + public let title: String + public let image: String + public let shouldUseRenderMode: Bool + + public init(title: String, image: String, shouldUseRenderMode: Bool) { + self.title = title + self.image = image + self.shouldUseRenderMode = shouldUseRenderMode + } +} + public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { private struct UX { static let closeButtonSize: CGFloat = 30 static let contentLabelsSpacing: CGFloat = 1 static let horizontalContentMargin: CGFloat = 16 static let favIconSize: CGFloat = 40 - static let siteProtectionsContentTopMargin: CGFloat = 4 - static let siteProtectionsContentCornerRadius: CGFloat = 12 - static let siteProtectionsContentBorderWidth: CGFloat = 1 - static let siteProtectionsContentHorizontalPadding: CGFloat = 10 - static let siteProtectionsContentVerticalPadding: CGFloat = 6 - static let siteProtectionsIcon: CGFloat = 16 - static let siteProtectionsMoreSettingsIcon: CGFloat = 20 - static let siteProtectionsContentSpacing: CGFloat = 4 + static let badgesTopMargin: CGFloat = 4 + static let badgesSpacing: CGFloat = 8 } public var closeButtonCallback: (() -> Void)? public var siteProtectionsButtonCallback: (() -> Void)? + public var adBlockerButtonCallback: (() -> Void)? public var mainMenuHelper: MainMenuInterface = MainMenuHelper() private var contentLabels: UIStackView = .build { stack in @@ -57,42 +64,26 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { button.setImage(UIImage(named: imageName)?.withRenderingMode(.alwaysTemplate) ?? UIImage(), for: .normal) } - private lazy var siteProtectionsContent: UIStackView = .build { [weak self] stack in - guard let self else { return } - stack.isLayoutMarginsRelativeArrangement = true - stack.layoutMargins = UIEdgeInsets(top: UX.siteProtectionsContentVerticalPadding, - left: UX.siteProtectionsContentHorizontalPadding, - bottom: UX.siteProtectionsContentVerticalPadding, - right: UX.siteProtectionsContentHorizontalPadding) - stack.distribution = .fill + private lazy var badgesStack: UIStackView = .build { stack in stack.axis = .horizontal - stack.clipsToBounds = true - stack.spacing = UX.siteProtectionsContentSpacing - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(siteProtectionsTapped)) - stack.isUserInteractionEnabled = true - stack.addGestureRecognizer(tapGesture) - } - - private var siteProtectionsLabel: UILabel = .build { label in - label.font = FXFontStyles.Regular.footnote.scaledFont() - label.numberOfLines = 0 - label.lineBreakMode = .byWordWrapping - label.adjustsFontForContentSizeCategory = true - label.accessibilityTraits = .button + stack.alignment = .center + stack.distribution = .fill + stack.spacing = UX.badgesSpacing } - private var siteProtectionsIcon: UIImageView = .build { imageView in - imageView.contentMode = .scaleAspectFit - } + private lazy var siteProtectionsBadge: MenuSiteBadge = { + let badge = MenuSiteBadge(mainMenuHelper: mainMenuHelper) + badge.translatesAutoresizingMaskIntoConstraints = false + badge.tapHandler = { [weak self] in self?.siteProtectionsButtonCallback?() } + return badge + }() - private var siteProtectionsMoreSettingsIcon: UIImageView = .build { imageView in - let imageName = StandardImageIdentifiers.Large.chevronRight - let image = UIImage(named: imageName)? - .withRenderingMode(.alwaysTemplate) - .imageFlippedForRightToLeftLayoutDirection() ?? UIImage() - imageView.image = image - imageView.contentMode = .scaleAspectFit - } + private lazy var adBlockerBadge: MenuSiteBadge = { + let badge = MenuSiteBadge(mainMenuHelper: mainMenuHelper) + badge.translatesAutoresizingMaskIntoConstraints = false + badge.tapHandler = { [weak self] in self?.adBlockerButtonCallback?() } + return badge + }() init() { super.init(frame: .zero) @@ -103,34 +94,22 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { fatalError("init(coder:) has not been implemented") } - override public func layoutSubviews() { - super.layoutSubviews() - if #available(iOS 26.0, *) { - siteProtectionsContent.layer.cornerRadius = siteProtectionsContent.frame.height / 2 - } else { - siteProtectionsContent.layer.cornerRadius = UX.siteProtectionsContentCornerRadius - siteProtectionsContent.layer.borderWidth = UX.siteProtectionsContentBorderWidth - } - } - private func setupViews() { contentLabels.addArrangedSubview(titleLabel) contentLabels.addArrangedSubview(subtitleLabel) - addSubviews(contentLabels, favicon, closeButton, siteProtectionsContent) - siteProtectionsContent.addArrangedSubview(siteProtectionsIcon) - siteProtectionsContent.addArrangedSubview(siteProtectionsLabel) - siteProtectionsContent.addArrangedSubview(siteProtectionsMoreSettingsIcon) + addSubviews(contentLabels, favicon, closeButton, badgesStack) + badgesStack.addArrangedSubview(siteProtectionsBadge) - let siteProtectionsTopFromFavicon = siteProtectionsContent.topAnchor.constraint( + let badgesTopFromFavicon = badgesStack.topAnchor.constraint( greaterThanOrEqualTo: favicon.bottomAnchor, - constant: UX.siteProtectionsContentTopMargin + constant: UX.badgesTopMargin ) - let siteProtectionsTopFromLabels = siteProtectionsContent.topAnchor.constraint( + let badgesTopFromLabels = badgesStack.topAnchor.constraint( equalTo: contentLabels.bottomAnchor, - constant: UX.siteProtectionsContentTopMargin + constant: UX.badgesTopMargin ) - siteProtectionsTopFromLabels.priority = .defaultHigh + badgesTopFromLabels.priority = .defaultHigh NSLayoutConstraint.activate([ contentLabels.topAnchor.constraint(equalTo: self.topAnchor), contentLabels.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, @@ -145,17 +124,14 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { closeButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -UX.horizontalContentMargin), closeButton.topAnchor.constraint(equalTo: self.topAnchor), - siteProtectionsIcon.widthAnchor.constraint(equalToConstant: UX.siteProtectionsIcon), - siteProtectionsMoreSettingsIcon.widthAnchor.constraint(equalToConstant: UX.siteProtectionsMoreSettingsIcon), - - siteProtectionsTopFromLabels, - siteProtectionsTopFromFavicon, - siteProtectionsContent.trailingAnchor.constraint(lessThanOrEqualTo: closeButton.leadingAnchor), - siteProtectionsContent.bottomAnchor.constraint(equalTo: self.bottomAnchor), + badgesTopFromLabels, + badgesTopFromFavicon, + badgesStack.leadingAnchor.constraint(equalTo: favicon.leadingAnchor), + badgesStack.trailingAnchor.constraint(lessThanOrEqualTo: closeButton.leadingAnchor), + badgesStack.bottomAnchor.constraint(equalTo: self.bottomAnchor), closeButton.widthAnchor.constraint(equalToConstant: UX.closeButtonSize), - closeButton.heightAnchor.constraint(equalToConstant: UX.closeButtonSize), - siteProtectionsContent.leadingAnchor.constraint(equalTo: favicon.leadingAnchor) + closeButton.heightAnchor.constraint(equalToConstant: UX.closeButtonSize) ]) closeButton.layer.cornerRadius = 0.5 * UX.closeButtonSize @@ -167,17 +143,24 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { image: String?, state: String, stateImage: String, - shouldUseRenderMode: Bool + shouldUseRenderMode: Bool, + adBlocker: MenuSiteAdBlockerBadgeData? = nil ) { titleLabel.text = title subtitleLabel.text = subtitle - siteProtectionsLabel.text = state - let siteProtectionsImage: UIImage = if shouldUseRenderMode { - UIImage(named: stateImage)?.withRenderingMode(.alwaysTemplate) ?? UIImage() + siteProtectionsBadge.configure(text: state, + iconName: stateImage, + useTemplate: shouldUseRenderMode) + if let adBlocker { + if adBlockerBadge.superview == nil { + badgesStack.addArrangedSubview(adBlockerBadge) + } + adBlockerBadge.configure(text: adBlocker.title, + iconName: adBlocker.image, + useTemplate: adBlocker.shouldUseRenderMode) } else { - UIImage(named: stateImage) ?? UIImage() + adBlockerBadge.removeFromSuperview() } - siteProtectionsIcon.image = siteProtectionsImage let image = FaviconImageViewModel(siteURLString: image, faviconCornerRadius: UX.favIconSize / 2) @@ -197,25 +180,12 @@ public final class MenuSiteProtectionsHeader: UIView, ThemeApplicable { closeButtonCallback?() } - @objc - func siteProtectionsTapped() { - siteProtectionsButtonCallback?() - } - public func applyTheme(theme: Theme) { titleLabel.textColor = theme.colors.textPrimary subtitleLabel.textColor = theme.colors.textSecondary closeButton.tintColor = theme.colors.iconSecondary closeButton.backgroundColor = theme.colors.actionCloseButton.withAlphaComponent(mainMenuHelper.backgroundAlpha()) - siteProtectionsLabel.textColor = theme.colors.textSecondary - siteProtectionsContent.layer.borderColor = theme.colors.actionSecondaryHover.cgColor - if #available(iOS 26.0, *) { - let backgroundColor = theme.colors.layerSurfaceMedium.withAlphaComponent(mainMenuHelper.backgroundAlpha()) - siteProtectionsContent.backgroundColor = backgroundColor - } else { - siteProtectionsContent.backgroundColor = .clear - } - siteProtectionsIcon.tintColor = theme.colors.iconSecondary - siteProtectionsMoreSettingsIcon.tintColor = theme.colors.iconSecondary + siteProtectionsBadge.applyTheme(theme: theme) + adBlockerBadge.applyTheme(theme: theme) } } From f3820bcb8155a6f728a7d63304a727cf2097e20c Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:35:23 +0200 Subject: [PATCH 08/12] feat: ad block navigation --- .../MainMenu/MainMenuCoordinator.swift | 3 ++ .../Views/MainMenuViewController.swift | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift index 0d0e8c03ab2a5..ed294be68ff49 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift @@ -157,6 +157,9 @@ class MainMenuCoordinator: BaseCoordinator { case .siteProtections: navigationHandler?.presentSiteProtections() + case .adBlocker: + navigationHandler?.showSettings(at: .browser) + case .defaultBrowser: DefaultApplicationHelper().openSettings() diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift index 474850f8d0cb3..c5187fb8bbf68 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/Views/MainMenuViewController.swift @@ -15,6 +15,7 @@ class MainMenuViewController: UIViewController, UIScrollViewDelegate, Themeable, Notifiable, + FeatureFlaggable, StoreSubscriber { private struct UX { static let hintViewCornerRadius: CGFloat = 20 @@ -172,6 +173,10 @@ class MainMenuViewController: UIViewController, self?.dispatchSiteProtectionAction() } + menuContent.siteProtectionHeader.adBlockerButtonCallback = { [weak self] in + self?.dispatchAdBlockerAction() + } + menuContent.closeButtonCallback = { [weak self] in self?.dispatchCloseMenuAction() } @@ -448,6 +453,17 @@ class MainMenuViewController: UIViewController, ) } + private func dispatchAdBlockerAction() { + store.dispatch( + MainMenuAction( + windowUUID: self.windowUUID, + actionType: MainMenuActionType.tapNavigateToDestination, + navigationDestination: MenuNavigationDestination(.adBlocker), + currentTabInfo: menuState.currentTabInfo + ) + ) + } + private func dispatchDefaultBrowserAction() { store.dispatch( MainMenuAction( @@ -467,28 +483,38 @@ class MainMenuViewController: UIViewController, } private func updateSiteProtectionsHeaderWith(siteProtectionsData: SiteProtectionsData) { - var state = String.MainMenu.SiteProtection.ProtectionsOn + let state = String.MainMenu.SiteProtection.Protections var stateImage = StandardImageIdentifiers.Small.shieldCheckmarkFill var shouldUseRenderMode = false switch siteProtectionsData.state { case .notSecure: - state = String.MainMenu.SiteProtection.ConnectionNotSecure stateImage = StandardImageIdentifiers.Small.shieldSlashFillMulticolor case .on: shouldUseRenderMode = true case .off: - state = String.MainMenu.SiteProtection.ProtectionsOff stateImage = StandardImageIdentifiers.Small.shieldSlashFillMulticolor } + let adBlocker: MenuSiteAdBlockerBadgeData? = { + guard featureFlagsProvider.isEnabled(.adBlocker) else { return nil } + let isAdBlockerOn = profile.prefs.boolForKey(PrefsKeys.BlockAds) ?? false + let adBlockerImage = isAdBlockerOn + ? StandardImageIdentifiers.Medium.adBlockerCheckmark + : StandardImageIdentifiers.Medium.adBlockerCross + return MenuSiteAdBlockerBadgeData(title: String.MainMenu.SiteProtection.AdBlocker, + image: adBlockerImage, + shouldUseRenderMode: true) + }() + menuContent.siteProtectionHeader.setupDetails( title: siteProtectionsData.title, subtitle: siteProtectionsData.subtitle, image: siteProtectionsData.image, state: state, stateImage: stateImage, - shouldUseRenderMode: shouldUseRenderMode) + shouldUseRenderMode: shouldUseRenderMode, + adBlocker: adBlocker) } // MARK: - A11y From e7fe9dcef066ff992634fef98f153ed68a2c7103 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 14:54:58 +0200 Subject: [PATCH 09/12] feat: add tests --- .../MenuKitTests/MenuSiteBadgeTests.swift | 53 +++++++++++++++++++ .../MainMenu/MainMenuMiddlewareTests.swift | 21 ++++++++ 2 files changed, 74 insertions(+) create mode 100644 BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift diff --git a/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift b/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift new file mode 100644 index 0000000000000..370b54e15a37c --- /dev/null +++ b/BrowserKit/Tests/MenuKitTests/MenuSiteBadgeTests.swift @@ -0,0 +1,53 @@ +// 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 http://mozilla.org/MPL/2.0/ + +import XCTest +@testable import MenuKit + +@MainActor +final class MenuSiteBadgeTests: XCTestCase { + var badge: MenuSiteBadge! + + override func setUp() async throws { + try await super.setUp() + badge = MenuSiteBadge(mainMenuHelper: MainMenuHelper()) + } + + override func tearDown() async throws { + badge = nil + try await super.tearDown() + } + + func testTapHandlerCallback() { + let expectation = XCTestExpectation(description: "Tap handler should be called") + badge.tapHandler = { expectation.fulfill() } + + badge.tapHandler?() + + wait(for: [expectation], timeout: 1.0) + } + + func testConfigure_setsVisibleLabelText() { + badge.configure(text: "Protections", iconName: "", useTemplate: false) + + let label = firstLabel(in: badge) + XCTAssertEqual(label?.text, "Protections") + } + + func testConfigure_updatesLabelOnSubsequentCalls() { + badge.configure(text: "Protections", iconName: "", useTemplate: false) + badge.configure(text: "Ad Blocker", iconName: "", useTemplate: false) + + let label = firstLabel(in: badge) + XCTAssertEqual(label?.text, "Ad Blocker") + } + + private func firstLabel(in view: UIView) -> UILabel? { + for subview in view.subviews { + if let label = subview as? UILabel { return label } + if let found = firstLabel(in: subview) { return found } + } + return nil + } +} diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift index eb81856fc07d3..0c4726d3c25c5 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/MainMenu/MainMenuMiddlewareTests.swift @@ -320,6 +320,27 @@ final class MainMenuMiddlewareTests: XCTestCase, StoreTestUtility { XCTAssertEqual(savedExtras.option, "site_protections") } + func test_tapNavigateToDestination_adBlockerAction_sendTelemetryData() throws { + let action = getNavigationDestinationAction(for: .adBlocker) + let subject = createSubject() + + subject.mainMenuProvider(AppState(), action) + + let savedMetric = try XCTUnwrap( + mockGleanWrapper.savedEvents.first as? EventMetricType + ) + let savedExtras = try XCTUnwrap( + mockGleanWrapper.savedExtras.first as? GleanMetrics.AppMenu.MainMenuOptionSelectedExtra + ) + let expectedMetricType = type(of: GleanMetrics.AppMenu.mainMenuOptionSelected) + let resultMetricType = type(of: savedMetric) + let debugMessage = TelemetryDebugMessage(expectedMetric: expectedMetricType, resultMetric: resultMetricType) + + XCTAssertEqual(mockGleanWrapper.recordEventCalled, 1) + XCTAssert(resultMetricType == expectedMetricType, debugMessage.text) + XCTAssertEqual(savedExtras.option, "ad_blocker") + } + func test_tapNavigateToDestination_defaultBrowserAction_sendTelemetryData() throws { let action = getNavigationDestinationAction(for: .defaultBrowser) let subject = createSubject() From 82140ce126f4b96f3c649e938a8c9dd02c292876 Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 15:30:33 +0200 Subject: [PATCH 10/12] fix: add image assets --- .../Contents.json | 15 +++++++++++++++ .../adBlockerCheckmarkMedium.pdf | Bin 0 -> 5901 bytes .../adBlockerCrossMedium.imageset/Contents.json | 15 +++++++++++++++ .../adBlockerCrossMedium.pdf | Bin 0 -> 6419 bytes 4 files changed, 30 insertions(+) create mode 100644 firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json create mode 100644 firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf create mode 100644 firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json create mode 100644 firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json new file mode 100644 index 0000000000000..316a92e972a08 --- /dev/null +++ b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "adBlockerCheckmarkMedium.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCheckmarkMedium.imageset/adBlockerCheckmarkMedium.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f7e6cdb31015d17024d8a6a8a6507163017d3c16 GIT binary patch literal 5901 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 afHni%AtnpVdG7Y0)iqEs1qI{1CjSBZ3kYWb literal 0 HcmV?d00001 diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json new file mode 100644 index 0000000000000..9141d709ac9af --- /dev/null +++ b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "adBlockerCrossMedium.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf b/firefox-ios/Client/Assets/Images.xcassets/adBlockerCrossMedium.imageset/adBlockerCrossMedium.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e11b2a9154ad1e477228d46dc3f3c7f74f12c57f GIT binary patch literal 6419 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 literal 0 HcmV?d00001 From 0d2ca16ef97bd0e5a550aea96c7675c1fb9f6b2f Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 15:34:01 +0200 Subject: [PATCH 11/12] fix: add present method to coordinator --- .../Client/Coordinators/Browser/BrowserCoordinator.swift | 8 ++++++++ .../Frontend/Browser/MainMenu/MainMenuCoordinator.swift | 5 ++++- .../Mocks/MockMainMenuCoordinatorDelegate.swift | 5 +++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift index 1723f558d24fc..a0024136e5328 100644 --- a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift +++ b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift @@ -629,6 +629,14 @@ final class BrowserCoordinator: BaseCoordinator, showETPMenu(sourceView: browserViewController.addressToolbarContainer) } + func presentAdBlockerSettings() { + let browsingSettings = BrowsingSettingsViewController(profile: profile, windowUUID: windowUUID) + let navigationController = DismissableNavigationViewController(rootViewController: browsingSettings) + navigationController.sheetPresentationController?.detents = [.medium(), .large()] + navigationController.sheetPresentationController?.prefersGrabberVisible = true + router.present(navigationController, animated: true) + } + func presentSavePDFController() { guard let selectedTab = browserViewController.tabManager.selectedTab else { return } diff --git a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift index ed294be68ff49..2ae25d652e558 100644 --- a/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift +++ b/firefox-ios/Client/Frontend/Browser/MainMenu/MainMenuCoordinator.swift @@ -32,6 +32,9 @@ protocol MainMenuCoordinatorDelegate: AnyObject { @MainActor func presentSiteProtections() + @MainActor + func presentAdBlockerSettings() + @MainActor func showPrintSheet() @@ -158,7 +161,7 @@ class MainMenuCoordinator: BaseCoordinator { navigationHandler?.presentSiteProtections() case .adBlocker: - navigationHandler?.showSettings(at: .browser) + navigationHandler?.presentAdBlockerSettings() case .defaultBrowser: DefaultApplicationHelper().openSettings() diff --git a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift index 02d25b160b54c..00305705839e7 100644 --- a/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift +++ b/firefox-ios/firefox-ios-tests/Tests/ClientTests/Mocks/MockMainMenuCoordinatorDelegate.swift @@ -14,6 +14,7 @@ class MockMainMenuCoordinatorDelegate: MainMenuCoordinatorDelegate { private(set) var updateZoomPageBarVisibilityCalled = 0 private(set) var presentSavePDFControllerCalled = 0 private(set) var presentSiteProtectionsCalled = 0 + private(set) var presentAdBlockerSettingsCalled = 0 private(set) var showPrintSheetCalled = 0 private(set) var showReaderModeCalled = 0 private(set) var showShareSheetForCurrentlySelectedTabCalled = 0 @@ -52,6 +53,10 @@ class MockMainMenuCoordinatorDelegate: MainMenuCoordinatorDelegate { presentSiteProtectionsCalled += 1 } + func presentAdBlockerSettings() { + presentAdBlockerSettingsCalled += 1 + } + func showPrintSheet() { showPrintSheetCalled += 1 } From c7f33c455474f63e37b5571f9de7a7210888f50a Mon Sep 17 00:00:00 2001 From: Issam Mani Date: Tue, 19 May 2026 15:45:55 +0200 Subject: [PATCH 12/12] feat: setup custom detents --- .../Coordinators/Browser/BrowserCoordinator.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift index a0024136e5328..0f0190cb8bb9a 100644 --- a/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift +++ b/firefox-ios/Client/Coordinators/Browser/BrowserCoordinator.swift @@ -632,11 +632,24 @@ final class BrowserCoordinator: BaseCoordinator, func presentAdBlockerSettings() { let browsingSettings = BrowsingSettingsViewController(profile: profile, windowUUID: windowUUID) let navigationController = DismissableNavigationViewController(rootViewController: browsingSettings) - navigationController.sheetPresentationController?.detents = [.medium(), .large()] + setupAdBlockerSettingsDetents(for: navigationController) navigationController.sheetPresentationController?.prefersGrabberVisible = true router.present(navigationController, animated: true) } + private func setupAdBlockerSettingsDetents(for controller: UIViewController) { + if #available(iOS 16.0, *) { + let customDetent = UISheetPresentationController.Detent.custom( + identifier: .init("threeQuarter") + ) { context in + context.maximumDetentValue * 0.75 + } + controller.sheetPresentationController?.detents = [customDetent, .large()] + } else { + controller.sheetPresentationController?.detents = [.medium(), .large()] + } + } + func presentSavePDFController() { guard let selectedTab = browserViewController.tabManager.selectedTab else { return }