diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift index 321e26317708..057238a22504 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+TabManagerDelegate.swift @@ -211,6 +211,10 @@ extension BrowserViewController: TabManagerDelegate { self.downloadToast = downloadToast } + if let searchResultAdClickedInfoBar = toast as? SearchResultAdClickedInfoBar { + self.searchResultAdClickedInfoBar = searchResultAdClickedInfoBar + } + // If BVC isnt visible hold on to this toast until viewDidAppear if view.window == nil { pendingToast = toast @@ -241,6 +245,13 @@ extension BrowserViewController: TabManagerDelegate { ) } + func hideToastsOnNavigationStartIfNeeded(_ tabManager: TabManager) { + if tabManager.selectedTab?.braveSearchResultAdManager == nil { + searchResultAdClickedInfoBar?.dismiss(false) + searchResultAdClickedInfoBar = nil + } + } + func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) { guard let toast = toast, !privateBrowsingManager.isPrivateBrowsing else { return diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift index 972ef3a88c57..ffde7ba0ec32 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BVC+WKNavigationDelegate.swift @@ -97,6 +97,8 @@ extension BrowserViewController: WKNavigationDelegate { } } + hideToastsOnNavigationStartIfNeeded(tabManager) + resetRedirectChain(webView) // Append source URL to redirect chain @@ -414,13 +416,25 @@ extension BrowserViewController: WKNavigationDelegate { if navigationAction.targetFrame?.isMainFrame == true, BraveSearchManager.isValidURL(requestURL) { - if let braveSearchResultAdManager = tab?.braveSearchResultAdManager, braveSearchResultAdManager.isSearchResultAdClickedURL(requestURL), navigationAction.navigationType == .linkActivated { - braveSearchResultAdManager.maybeTriggerSearchResultAdClickedEvent(requestURL) - tab?.braveSearchResultAdManager = nil + let showSearchResultAdClickedPrivacyNotice = + rewards.ads.shouldShowSearchResultAdClickedInfoBar() + + braveSearchResultAdManager.maybeTriggerSearchResultAdClickedEvent( + requestURL, + completion: { [weak self] success in + guard let self, success, showSearchResultAdClickedPrivacyNotice else { + return + } + let searchResultClickedInfobar = SearchResultAdClickedInfoBar( + tabManager: self.tabManager + ) + self.show(toast: searchResultClickedInfobar, duration: nil) + } + ) } else { // The Brave-Search-Ads header should be added with a negative value when all // of the following conditions are met: diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift index 9f2f925dcc21..abe227c14517 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/BrowserViewController/BrowserViewController.swift @@ -210,6 +210,8 @@ public class BrowserViewController: UIViewController { var downloadToast: DownloadToast? /// A toast which is active and not yet dismissed var activeButtonToast: Toast? + /// An infobar displaying a privacy notice when a search result ad is clicked + var searchResultAdClickedInfoBar: SearchResultAdClickedInfoBar? /// A boolean to determine If AddToListActivity should be added var addToPlayListActivityItem: (enabled: Bool, item: PlaylistInfo?)? /// A boolean to determine if OpenInPlaylistActivity should be shown diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchResultAdManager.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchResultAdManager.swift index f33e65a57ce2..d961f504bec8 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchResultAdManager.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Search/BraveSearchResultAdManager.swift @@ -46,7 +46,7 @@ class BraveSearchResultAdManager: NSObject { ) } - func maybeTriggerSearchResultAdClickedEvent(_ url: URL) { + func maybeTriggerSearchResultAdClickedEvent(_ url: URL, completion: @escaping ((Bool) -> Void)) { guard let placementId = getPlacementID(url) else { return } @@ -58,7 +58,7 @@ class BraveSearchResultAdManager: NSObject { rewards.ads.triggerSearchResultAdEvent( searchResultAd, eventType: .clicked, - completion: { _ in } + completion: completion ) } diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/SearchResultAdClickedInfoBar.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/SearchResultAdClickedInfoBar.swift new file mode 100644 index 000000000000..a9b94923a895 --- /dev/null +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/SearchResultAdClickedInfoBar.swift @@ -0,0 +1,171 @@ +// 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 DesignSystem +import Foundation +import Shared +import SnapKit +import SwiftUI +import UIKit + +struct SearchResultAdClickedInfoBarUX { + static let toastHeight: CGFloat = 100.0 + static let toastPadding: CGFloat = 10.0 + static let toastCloseButtonWidth: CGFloat = 20.0 + static let toastLabelFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + static let toastBackgroundColor = UIColor(braveSystemName: .schemesOnPrimaryFixed) + static let learnMoreUrl = "https://support.brave.com/hc/en-us/articles/360026361072-Brave-Ads-FAQ" +} + +class SearchResultAdClickedInfoBar: Toast, UITextViewDelegate { + let tabManager: TabManager + + init(tabManager: TabManager) { + self.tabManager = tabManager + + super.init(frame: .zero) + + self.tapDismissalMode = .outsideTap + + self.clipsToBounds = true + self.addSubview( + createView( + Strings.searchResultAdsClickedInfoBarTitle + " " + ) + ) + + self.toastView.backgroundColor = SearchResultAdClickedInfoBarUX.toastBackgroundColor + + self.toastView.snp.makeConstraints { make in + make.left.right.height.equalTo(self) + self.animationConstraint = + make.top.equalTo(self).offset(SearchResultAdClickedInfoBarUX.toastHeight).constraint + } + + self.snp.makeConstraints { make in + make.height.equalTo(SearchResultAdClickedInfoBarUX.toastHeight) + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + fileprivate func createView( + _ labelText: String + ) -> UIView { + let horizontalStackView = UIStackView() + horizontalStackView.axis = .horizontal + horizontalStackView.alignment = .center + horizontalStackView.spacing = SearchResultAdClickedInfoBarUX.toastPadding + + let label = UITextView() + label.textAlignment = .left + label.textColor = .white + label.font = SearchResultAdClickedInfoBarUX.toastLabelFont + label.backgroundColor = SearchResultAdClickedInfoBarUX.toastBackgroundColor + label.isEditable = false + label.isScrollEnabled = false + label.isSelectable = true + label.delegate = self + + let learnMoreText = Strings.learnMore.withNonBreakingSpace + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.white, + .font: SearchResultAdClickedInfoBarUX.toastLabelFont, + ] + + let linkAttributes: [NSAttributedString.Key: Any] = [ + .font: SearchResultAdClickedInfoBarUX.toastLabelFont, + .foregroundColor: UIColor.white, + .underlineStyle: 1, + ] + label.linkTextAttributes = linkAttributes + + let nsLabelAttributedString = NSMutableAttributedString( + string: labelText, + attributes: attributes + ) + let nsLinkAttributedString = NSMutableAttributedString( + string: learnMoreText, + attributes: linkAttributes + ) + + if let url = URL(string: SearchResultAdClickedInfoBarUX.learnMoreUrl) { + let learnMoreRange = NSRange(location: 0, length: learnMoreText.count) + nsLinkAttributedString.addAttribute(.link, value: url, range: learnMoreRange) + nsLabelAttributedString.append(nsLinkAttributedString) + label.isUserInteractionEnabled = true + } + label.attributedText = nsLabelAttributedString + + horizontalStackView.addArrangedSubview(label) + + if let buttonImage = UIImage(braveSystemNamed: "leo.close") { + let button = UIButton() + button.setImage(buttonImage, for: .normal) + button.imageView?.contentMode = .scaleAspectFit + button.imageView?.tintColor = .white + button.imageView?.preferredSymbolConfiguration = .init( + font: .preferredFont(for: .title3, weight: .regular), + scale: .small + ) + + button.imageView?.snp.makeConstraints { + $0.width.equalTo(SearchResultAdClickedInfoBarUX.toastCloseButtonWidth) + } + + button.addGestureRecognizer( + UITapGestureRecognizer(target: self, action: #selector(buttonPressed)) + ) + + horizontalStackView.addArrangedSubview(button) + } + + toastView.addSubview(horizontalStackView) + + horizontalStackView.snp.makeConstraints { make in + make.centerX.equalTo(toastView) + make.centerY.equalTo(toastView) + make.width.equalTo(toastView.snp.width).offset( + -2 * SearchResultAdClickedInfoBarUX.toastPadding + ) + } + + return toastView + } + + func textView( + _ textView: UITextView, + shouldInteractWith url: URL, + in characterRange: NSRange, + interaction: UITextItemInteraction + ) -> Bool { + self.tabManager.addTabAndSelect( + URLRequest(url: URL(string: SearchResultAdClickedInfoBarUX.learnMoreUrl)!), + isPrivate: false + ) + dismiss(true) + return false + } + + @objc func buttonPressed(_ gestureRecognizer: UIGestureRecognizer) { + dismiss(true) + } + + override func showToast( + viewController: UIViewController? = nil, + delay: DispatchTimeInterval = SimpleToastUX.toastDelayBefore, + duration: DispatchTimeInterval?, + makeConstraints: @escaping (ConstraintMaker) -> Void, + completion: (() -> Void)? = nil + ) { + super.showToast( + viewController: viewController, + delay: delay, + duration: duration, + makeConstraints: makeConstraints + ) + } +} diff --git a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift index 61f376d17586..c4fd1266cc79 100644 --- a/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift +++ b/ios/brave-ios/Sources/Brave/Frontend/Browser/Toast.swift @@ -14,6 +14,8 @@ class Toast: UIView { var displayState = State.dismissed + var tapDismissalMode: TapDismissalMode = .anyTap + lazy var gestureRecognizer: UITapGestureRecognizer = { let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) gestureRecognizer.cancelsTouchesInView = false @@ -97,6 +99,14 @@ class Toast: UIView { } @objc func handleTap(_ gestureRecognizer: UIGestureRecognizer) { + if tapDismissalMode == .outsideTap { + let location = gestureRecognizer.location(in: self) + // Check if the tap was inside the toast view + if self.point(inside: location, with: nil) { + return + } + } + dismiss(false) } @@ -106,4 +116,9 @@ class Toast: UIView { case pendingDismiss case dismissed } + + enum TapDismissalMode { + case outsideTap + case anyTap + } } diff --git a/ios/brave-ios/Sources/Shared/SharedStrings.swift b/ios/brave-ios/Sources/Shared/SharedStrings.swift index 7e720d1ff293..3383efe62981 100644 --- a/ios/brave-ios/Sources/Shared/SharedStrings.swift +++ b/ios/brave-ios/Sources/Shared/SharedStrings.swift @@ -337,3 +337,13 @@ extension Strings { comment: "Question shown to user when tapping a link that opens the App Store app" ) } + +// Search result ad clicked InfoBar title +extension Strings { + public static let searchResultAdsClickedInfoBarTitle = NSLocalizedString( + "SearchResultAdsClickedInfoBarTitle", + bundle: .module, + value: "You’ve just clicked on a Brave Search ad. Unlike Big Tech, we measure ad performance anonymously and preserve your privacy.", + comment: "InfoBar displayed after a user clicked a search result ad for the first time." + ) +} diff --git a/ios/browser/api/ads/brave_ads.h b/ios/browser/api/ads/brave_ads.h index 728d4fcee230..83dd5fc7fe2d 100644 --- a/ios/browser/api/ads/brave_ads.h +++ b/ios/browser/api/ads/brave_ads.h @@ -70,6 +70,11 @@ OBJC_EXPORT /// Returns `true` if the user opted-in to search result ads. - (BOOL)isOptedInToSearchResultAds; +/// Returns `true` if the privacy notice infobar should be displayed when a user +/// clicks on a search result ad. This should be called before calling +/// `triggerSearchResultAdEvent` for the click. +- (BOOL)shouldShowSearchResultAdClickedInfoBar; + /// Used to notify the ads service that the user has opted-in/opted-out to /// Brave News. - (void)notifyBraveNewsIsEnabledPreferenceDidChange:(BOOL)isEnabled; diff --git a/ios/browser/api/ads/brave_ads.mm b/ios/browser/api/ads/brave_ads.mm index 89cf45ee0909..6bd55572b690 100644 --- a/ios/browser/api/ads/brave_ads.mm +++ b/ios/browser/api/ads/brave_ads.mm @@ -237,6 +237,11 @@ - (BOOL)isOptedInToSearchResultAds { brave_ads::prefs::kOptedInToSearchResultAds); } +- (BOOL)shouldShowSearchResultAdClickedInfoBar { + return self.profilePrefService->GetBoolean( + brave_ads::prefs::kShouldShowSearchResultAdClickedInfoBar); +} + - (void)notifyBraveNewsIsEnabledPreferenceDidChange:(BOOL)isEnabled { [self setProfilePref:brave_news::prefs::kBraveNewsOptedIn value:base::Value(isEnabled)]; @@ -1633,10 +1638,19 @@ - (void)triggerSearchResultAdEvent: return; } + const auto mojom_event_type = + static_cast(eventType); ads->TriggerSearchResultAdEvent( - searchResultAd.cppObjPtr, - static_cast(eventType), - base::BindOnce(completion)); + searchResultAd.cppObjPtr, mojom_event_type, + base::BindOnce(^(const bool success) { + if (success && + mojom_event_type == + brave_ads::mojom::SearchResultAdEventType::kClicked) { + self.profilePrefService->SetBoolean( + brave_ads::prefs::kShouldShowSearchResultAdClickedInfoBar, false); + } + completion(success); + })); } - (void)purgeOrphanedAdEventsForType:(BraveAdsAdType)adType