From 61f681ec537b2e8d49c11aacd29157939bf3bd75 Mon Sep 17 00:00:00 2001 From: Kyle Hickinson Date: Fri, 2 Jul 2021 11:21:23 -0400 Subject: [PATCH] Fix #3872: Adds Brave News Display Ads --- Client.xcodeproj/project.pbxproj | 4 + .../Shortcuts/ActivityShortcutManager.swift | 2 +- .../Brave Today/Cards/AdCardView.swift | 108 +++++++++++++ .../Brave Today/Composer/FeedCard.swift | 9 ++ .../Brave Today/Composer/FeedDataSource.swift | 55 +++++-- .../Frontend/Brave Today/FeedItemView.swift | 66 +++++++- .../Browser/BrowserViewController.swift | 15 +- .../NewTabPageViewController.swift | 30 +++- .../Sections/BraveNewsSectionProvider.swift | 151 +++++++++++++++++- .../BraveNewsSettingsViewController.swift | 21 ++- .../Settings/SettingsViewController.swift | 2 +- Client/Rewards/BraveRewards.swift | 60 +++++-- 12 files changed, 474 insertions(+), 49 deletions(-) create mode 100644 Client/Frontend/Brave Today/Cards/AdCardView.swift diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index f7fac2f4f2d..2330623d1e1 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -409,6 +409,7 @@ 27F94A3A21909A5900F4FADF /* SearchSuggestionsPromptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27F94A3921909A5900F4FADF /* SearchSuggestionsPromptView.swift */; }; 27FCA8E02447708300A8CA48 /* FavoritesSectionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FCA8DF2447708300A8CA48 /* FavoritesSectionProvider.swift */; }; 27FCA8E3244770AB00A8CA48 /* FavoritesOverflowSectionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FCA8E2244770AB00A8CA48 /* FavoritesOverflowSectionProvider.swift */; }; + 27FCFB0C2673F265008E43AB /* AdCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FCFB0B2673F265008E43AB /* AdCardView.swift */; }; 27FD2CAB2146C31C00A5A779 /* RequestDesktopSiteActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CA12146C31C00A5A779 /* RequestDesktopSiteActivity.swift */; }; 27FD2CAC2146C31C00A5A779 /* FindInPageActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CA92146C31C00A5A779 /* FindInPageActivity.swift */; }; 27FD2CAD2146C31C00A5A779 /* AddToFavoritesActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27FD2CAA2146C31C00A5A779 /* AddToFavoritesActivity.swift */; }; @@ -1898,6 +1899,7 @@ 27F94A3921909A5900F4FADF /* SearchSuggestionsPromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSuggestionsPromptView.swift; sourceTree = ""; }; 27FCA8DF2447708300A8CA48 /* FavoritesSectionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesSectionProvider.swift; sourceTree = ""; }; 27FCA8E2244770AB00A8CA48 /* FavoritesOverflowSectionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesOverflowSectionProvider.swift; sourceTree = ""; }; + 27FCFB0B2673F265008E43AB /* AdCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdCardView.swift; sourceTree = ""; }; 27FD2CA12146C31C00A5A779 /* RequestDesktopSiteActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestDesktopSiteActivity.swift; sourceTree = ""; }; 27FD2CA92146C31C00A5A779 /* FindInPageActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FindInPageActivity.swift; sourceTree = ""; }; 27FD2CAA2146C31C00A5A779 /* AddToFavoritesActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddToFavoritesActivity.swift; sourceTree = ""; }; @@ -3851,6 +3853,7 @@ 27A1AC0824859D0900344503 /* FeedHeadlineViews.swift */, 2719DA08249D37490080AB48 /* SponsorCardView.swift */, 27036EC2256718DA004EF6B6 /* PartnerCardView.swift */, + 27FCFB0B2673F265008E43AB /* AdCardView.swift */, ); path = Cards; sourceTree = ""; @@ -7255,6 +7258,7 @@ 27C647832550AF34006D72FC /* WalletTransferCompleteView.swift in Sources */, 4422D4B821BFFB7600BF1855 /* histogram.cc in Sources */, 27FD3FBA25C8D20200696156 /* FeedDataSource+RSS.swift in Sources */, + 27FCFB0C2673F265008E43AB /* AdCardView.swift in Sources */, 0A0D3D3921A4BD0600BEE65B /* SafeBrowsing.swift in Sources */, 278C6FFF24F6EA3700A246C8 /* ReportBrokenSiteView.swift in Sources */, 27676D472555F34D00BC955A /* BraveRewardsStatusView.swift in Sources */, diff --git a/Client/Application/Shortcuts/ActivityShortcutManager.swift b/Client/Application/Shortcuts/ActivityShortcutManager.swift index 9c09c66cfb3..2ba2e3c657c 100644 --- a/Client/Application/Shortcuts/ActivityShortcutManager.swift +++ b/Client/Application/Shortcuts/ActivityShortcutManager.swift @@ -157,7 +157,7 @@ class ActivityShortcutManager: NSObject { guard let newTabPageController = bvc.tabManager.selectedTab?.newTabPageViewController else { return } newTabPageController.scrollToBraveNews() } else { - let controller = BraveNewsSettingsViewController(dataSource: bvc.feedDataSource) + let controller = BraveNewsSettingsViewController(dataSource: bvc.feedDataSource, rewards: bvc.rewards) let container = UINavigationController(rootViewController: controller) bvc.present(container, animated: true) } diff --git a/Client/Frontend/Brave Today/Cards/AdCardView.swift b/Client/Frontend/Brave Today/Cards/AdCardView.swift new file mode 100644 index 00000000000..d0322e8ccbd --- /dev/null +++ b/Client/Frontend/Brave Today/Cards/AdCardView.swift @@ -0,0 +1,108 @@ +// Copyright 2021 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 http://mozilla.org/MPL/2.0/. + +import Foundation + +class AdCardView: FeedCardBackgroundButton, FeedCardContent { + var actionHandler: ((Int, FeedItemAction) -> Void)? + var contextMenu: FeedItemMenu? + + let feedView = FeedItemView(layout: .ad).then { + $0.thumbnailImageView.contentMode = .scaleAspectFit + } + + private let adCalloutView = BraveAdCalloutView() + private var contextMenuDelegate: NSObject? + + required init() { + super.init(frame: .zero) + + addSubview(feedView) + feedView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + addTarget(self, action: #selector(tappedSelf), for: .touchUpInside) + feedView.callToActionButton.addTarget(self, action: #selector(tappedSelf), for: .touchUpInside) + + let contextMenuDelegate = FeedContextMenuDelegate( + performedPreviewAction: { [weak self] in + self?.actionHandler?(0, .opened()) + }, + menu: { [weak self] in + return self?.contextMenu?.menu?(0) + } + ) + addInteraction(UIContextMenuInteraction(delegate: contextMenuDelegate)) + self.contextMenuDelegate = contextMenuDelegate + + isAccessibilityElement = false + accessibilityElements = [feedView, feedView.callToActionButton] + feedView.accessibilityTraits.insert(.button) + shouldGroupAccessibilityChildren = true + + addSubview(adCalloutView) + adCalloutView.snp.makeConstraints { + $0.top.trailing.equalToSuperview().inset(8) + $0.leading.greaterThanOrEqualToSuperview().inset(8) + $0.bottom.lessThanOrEqualToSuperview().inset(8) + } + } + + override var accessibilityLabel: String? { + get { feedView.accessibilityLabel } + set { assertionFailure("Accessibility label is inherited from a subview: \(String(describing: newValue)) ignored") } + } + + @objc private func tappedSelf() { + actionHandler?(0, .opened()) + } +} + +private class BraveAdCalloutView: UIView { + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .white + layer.cornerRadius = 4 + layer.cornerCurve = .continuous + layer.borderColor = UIColor.braveLighterBlurple.cgColor + layer.borderWidth = 1 + layer.masksToBounds = true + + let stackView = UIStackView() + stackView.spacing = 3 + stackView.alignment = .center + stackView.layoutMargins = .init(equalInset: 5) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.addStackViewItems( + .view(UIImageView(image: UIImage(imageLiteralResourceName: "bat-small")).then { + $0.contentMode = .scaleAspectFit + $0.snp.makeConstraints { + $0.size.equalTo(14) + } + }), + .view(UILabel().then { + $0.text = "Ad" + $0.textColor = .braveBlurple + $0.font = { + let metrics = UIFontMetrics(forTextStyle: .footnote) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .footnote) + let font = UIFont.systemFont(ofSize: desc.pointSize, weight: .semibold) + return metrics.scaledFont(for: font) + }() + $0.adjustsFontForContentSizeCategory = true + }) + ) + addSubview(stackView) + stackView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } +} diff --git a/Client/Frontend/Brave Today/Composer/FeedCard.swift b/Client/Frontend/Brave Today/Composer/FeedCard.swift index 2428d663f4e..3a938736dd1 100644 --- a/Client/Frontend/Brave Today/Composer/FeedCard.swift +++ b/Client/Frontend/Brave Today/Composer/FeedCard.swift @@ -4,6 +4,7 @@ // file, You can obtain one at http://mozilla.org/MPL/2.0/. import Foundation +import BraveCore /// A set of 2 items struct FeedPair: Equatable { @@ -26,6 +27,8 @@ enum FeedCard: Equatable { case deals(_ feeds: [FeedItem], title: String) /// A brave partner item case partner(_ feed: FeedItem) + /// A brave display ad card + case ad(InlineContentAd) /// A single item displayed prompinently with an image case headline(_ feed: FeedItem) /// A pair of `headline` items that should be displayed side by side horizontally with equal sizes @@ -44,6 +47,8 @@ enum FeedCard: Equatable { return FeedItemView.Layout.brandedHeadline.estimatedHeight(for: width) case .partner: return FeedItemView.Layout.partner.estimatedHeight(for: width) + case .ad: + return FeedItemView.Layout.ad.estimatedHeight(for: width) case .headlinePair: return 300 case .group, .numbered, .deals: @@ -60,6 +65,8 @@ enum FeedCard: Equatable { return [pair.first, pair.second] case .group(let items, _, _, _), .numbered(let items, _), .deals(let items, _): return items + case .ad: + return [] } } @@ -99,6 +106,8 @@ enum FeedCard: Equatable { return self case .partner: return .partner(replacementItem) + case .ad(let ad): + return .ad(ad) } } } diff --git a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift index 474b87d0920..d0065564603 100644 --- a/Client/Frontend/Brave Today/Composer/FeedDataSource.swift +++ b/Client/Frontend/Brave Today/Composer/FeedDataSource.swift @@ -9,6 +9,7 @@ import Data import Shared import BraveShared import FeedKit +import BraveCore // Named `logger` because we are using math function `log` private let logger = Logger.browserLogger @@ -53,6 +54,9 @@ class FeedDataSource { private(set) var sources: [FeedItem.Source] = [] private var items: [FeedItem.Content] = [] + /// An ads object to handle inserting Inline Content Ads within the Brave News sequence + var rewards: BraveRewards? + /// Add a closure that will execute when `state` is changed. /// /// Executes the closure on the main queue by default @@ -596,17 +600,18 @@ class FeedDataSource { category: \.content.offersCategory ) + rewards?.ads.purgeOrphanedAdEvents(.inlineContentAd) + var contentAdsQueryFailed = false + let rules: [FeedSequenceElement] = [ .sponsor, .fillUsing(FilteredFillStrategy(isIncluded: { $0.source.category == Self.topNewsCategory }), [ .headline(paired: false) ]), - .fillUsing(dealsCategoryFillStrategy, [ - .deals - ]), + .braveAd, .repeating([ .repeating([.headline(paired: false)], times: 2), - .repeating([.headline(paired: true)], times: 2), + .headline(paired: true), .partner, .fillUsing( CategoryFillStrategy( @@ -617,19 +622,22 @@ class FeedDataSource { .categoryGroup, ] ), - .headline(paired: false), + .repeating([.headline(paired: false)], times: 2), + .repeating([.headline(paired: true)], times: 2), + .braveAd, + .repeating([.headline(paired: false)], times: 2), + .brandedGroup(numbered: true), + .group, + .fillUsing(RandomizedFillStrategy(isIncluded: { Date().timeIntervalSince($0.content.publishTime) < 48.hours }), [ + .headline(paired: false) + ]), .fillUsing(dealsCategoryFillStrategy, [ .deals ]), - .headline(paired: false), - .headline(paired: true), - .brandedGroup(numbered: true), - .group, .fillUsing(RandomizedFillStrategy(isIncluded: { Date().timeIntervalSince($0.content.publishTime) < 48.hours }), [ - .headline(paired: false), .headline(paired: true), .headline(paired: false), - ]) + ]), ]) ] @@ -651,6 +659,29 @@ class FeedDataSource { return fillStrategy.next(from: &partners, where: imageExists).map { [.partner($0)] } + case .braveAd: + // If we fail to obtain inline content ads during a card gen it can be assumed that + // all further calls will fail since cards are generated all at once + guard !contentAdsQueryFailed, let rewards = rewards else { return nil } + let group = DispatchGroup() + group.enter() + var contentAd: InlineContentAd? + DispatchQueue.main.async { + rewards.ads.inlineContentAds(dimensions: "900x750", completion: { success, dimensions, ad in + if success { + contentAd = ad + } else { + contentAdsQueryFailed = true + logger.debug("Inline content ads could not be filled; Skipping for the rest of this feed generation") + } + group.leave() + }) + } + let result = group.wait(timeout: .now() + .seconds(1)) + if result == .success, let ad = contentAd { + return [.ad(ad)] + } + return nil case .headline(let paired): if articles.isEmpty { return nil } let imageExists = { (item: FeedItem) -> Bool in @@ -738,6 +769,8 @@ extension FeedDataSource { case sponsor /// Display a headline from a list of partnered items case partner + /// Displays a Brave ad from the ads catalog + case braveAd /// Displays a horizontal list of deals with the content type of `brave_offers` case deals /// Displays an `article` type item in a headline card. Can also be displayed as two (smaller) paired diff --git a/Client/Frontend/Brave Today/FeedItemView.swift b/Client/Frontend/Brave Today/FeedItemView.swift index db7058f72aa..fe6d4de0910 100644 --- a/Client/Frontend/Brave Today/FeedItemView.swift +++ b/Client/Frontend/Brave Today/FeedItemView.swift @@ -4,6 +4,7 @@ import Foundation import Shared +import BraveUI /// Defines the view for displaying a specific feed item given a specific layout /// @@ -50,6 +51,9 @@ class FeedItemView: UIView { lazy var promotedButton = PromotedButton().then { $0.setContentHuggingPriority(.required, for: .horizontal) } + lazy var callToActionButton = CallToActionButton().then { + $0.setContentHuggingPriority(.required, for: .horizontal) + } /// Generates the view hierarchy given a layout component private func view(for component: Layout.Component) -> UIView { @@ -83,6 +87,8 @@ class FeedItemView: UIView { return descriptionLabel case .promotedButton: return promotedButton + case .callToActionButton: + return callToActionButton case .stack(let stack): return UIStackView().then { $0.axis = stack.axis @@ -145,6 +151,28 @@ class FeedItemView: UIView { extension FeedItemView { + class CallToActionButton: BraveButton { + override init(frame: CGRect) { + super.init(frame: frame) + setTitleColor(.white, for: .normal) + titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) + layer.borderWidth = 1.0 + layer.borderColor = UIColor.white.withAlphaComponent(0.7).cgColor + layer.masksToBounds = true + contentEdgeInsets = UIEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + } + + @available(*, unavailable) + required init(coder: NSCoder) { + fatalError() + } + + override func layoutSubviews() { + super.layoutSubviews() + layer.cornerRadius = bounds.height / 2 + } + } + class PromotedButton: UIControl { private let image = UIImageView(image: UIImage(imageLiteralResourceName: "graph-up").template).then { @@ -260,6 +288,7 @@ extension FeedItemView { case description(_ labelConfiguration: LabelConfiguration = .description) case brand(viewingMode: BrandContainerView.ViewingMode = .automatic, labelConfiguration: LabelConfiguration = .brand) case promotedButton + case callToActionButton } /// The root stack for a given layout var root: Stack @@ -292,6 +321,8 @@ extension FeedItemView { } case .promotedButton: return 22.0 + case .callToActionButton: + return 36.0 } } return _height(for: .stack(root)) @@ -334,7 +365,7 @@ extension FeedItemView { ] ) ) - + /// Uses the same layout as a `brandedHeadline` but includes a promoted button static let partner = Layout( root: .init( @@ -367,6 +398,39 @@ extension FeedItemView { ] ) ) + /// Uses the same layout as a `brandedHeadline` but includes a call to action button and + /// has a slightly different thumbnail aspect ratio + static let ad = Layout( + root: .init( + axis: .vertical, + children: [ + .thumbnail(.aspectRatio(1.2)), + .stack( + .init( + axis: .vertical, + spacing: 4, + padding: UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12), + children: [ + .title(.init(numberOfLines: 5, font: .systemFont(ofSize: 18.0, weight: .semibold))), + .date(), + .flexibleSpace(minHeight: 12), + .stack( + .init( + axis: .horizontal, + spacing: 10, + alignment: .center, + children: [ + .brand(), + .callToActionButton + ] + ) + ) + ] + ) + ) + ] + ) + ) /// Defines a simple vertical feed item layout. Thumbnail title and date. /// /// ``` diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index 83bbd448650..655b215217b 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -249,12 +249,19 @@ class BrowserViewController: UIViewController { self?.setupLedger() } - // Only start ledger service automatically if ads is enabled - if rewards.isEnabled { - rewards.startLedgerService { - self.legacyWallet?.initializeLedgerService(nil) + let shouldStartAds = rewards.ads.isEnabled || Preferences.BraveNews.isEnabled.value + if shouldStartAds { + // Only start ledger service automatically if ads is enabled + if rewards.isEnabled { + rewards.startLedgerService { + self.legacyWallet?.initializeLedgerService(nil) + } + } else { + rewards.ads.initialize { _ in } } } + + feedDataSource.rewards = rewards } static func legacyWallet(for config: BraveRewards.Configuration) -> BraveLedger? { diff --git a/Client/Frontend/Browser/New Tab Page/NewTabPageViewController.swift b/Client/Frontend/Browser/New Tab Page/NewTabPageViewController.swift index 0522b68c9f8..9dce93fbecb 100644 --- a/Client/Frontend/Browser/New Tab Page/NewTabPageViewController.swift +++ b/Client/Frontend/Browser/New Tab Page/NewTabPageViewController.swift @@ -136,7 +136,7 @@ class NewTabPageViewController: UIViewController { sections.append( BraveNewsSectionProvider( dataSource: feedDataSource, - ads: rewards.ads, + rewards: rewards, actionHandler: { [weak self] in self?.handleBraveNewsAction($0) } @@ -480,7 +480,10 @@ class NewTabPageViewController: UIViewController { Preferences.BraveNews.userOptedIn.value = true Preferences.BraveNews.isShowingOptIn.value = false Preferences.BraveNews.isEnabled.value = true - loadFeedContents() + rewards.ads.initialize { [weak self] _ in + // Initialize ads if it hasn't already been done + self?.loadFeedContents() + } case .emptyCardTappedSourcesAndSettings: tappedBraveNewsSettings() case .errorCardTappedRefresh: @@ -529,6 +532,23 @@ class NewTabPageViewController: UIViewController { ) alert.present(on: self) } + case .inlineContentAdAction(.opened(let inNewTab, let switchingToPrivateMode), let ad): + guard let url = ad.targetURL.asURL else { return } + if !switchingToPrivateMode { + rewards.ads.reportInlineContentAdEvent( + ad.uuid, + creativeInstanceId: ad.creativeInstanceID, + eventType: .clicked + ) + } + delegate?.navigateToInput( + url.absoluteString, + inNewTab: inNewTab, + switchingToPrivateMode: switchingToPrivateMode + ) + case .inlineContentAdAction(.toggledSource, _): + // Inline content ads have no source + break } } @@ -629,6 +649,7 @@ class NewTabPageViewController: UIViewController { if !feedDataSource.shouldLoadContent { return } + rewards.ads.purgeOrphanedAdEvents(.inlineContentAd) feedDataSource.load(completion) } @@ -651,7 +672,7 @@ class NewTabPageViewController: UIViewController { } @objc private func tappedBraveNewsSettings() { - let controller = BraveNewsSettingsViewController(dataSource: feedDataSource) + let controller = BraveNewsSettingsViewController(dataSource: feedDataSource, rewards: rewards) let container = UINavigationController(rootViewController: controller) present(container, animated: true) } @@ -744,6 +765,9 @@ extension NewTabPageViewController { #endif } func scrollViewDidScroll(_ scrollView: UIScrollView) { + for section in sections { + section.scrollViewDidScroll?(scrollView) + } guard isBraveNewsVisible, let newsSection = layout.braveNewsSection else { return } if collectionView.numberOfItems(inSection: newsSection) > 0 { // Hide the buttons as Brave News feeds appear diff --git a/Client/Frontend/Browser/New Tab Page/Sections/BraveNewsSectionProvider.swift b/Client/Frontend/Browser/New Tab Page/Sections/BraveNewsSectionProvider.swift index 2dd9c9e0e46..581929203bb 100644 --- a/Client/Frontend/Browser/New Tab Page/Sections/BraveNewsSectionProvider.swift +++ b/Client/Frontend/Browser/New Tab Page/Sections/BraveNewsSectionProvider.swift @@ -35,18 +35,20 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { case bravePartnerLearnMoreTapped /// The user performed an action on a feed item case itemAction(FeedItemAction, context: FeedItemActionContext) + /// The user performed an action on an inline content ad + case inlineContentAdAction(FeedItemAction, ad: InlineContentAd) } let dataSource: FeedDataSource - let ads: BraveAds + let rewards: BraveRewards var sectionDidChange: (() -> Void)? var actionHandler: (Action) -> Void init(dataSource: FeedDataSource, - ads: BraveAds, + rewards: BraveRewards, actionHandler: @escaping (Action) -> Void) { self.dataSource = dataSource - self.ads = ads + self.rewards = rewards self.actionHandler = actionHandler super.init() @@ -64,6 +66,7 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { collectionView.register(FeedCardCell.self) collectionView.register(FeedCardCell.self) collectionView.register(FeedCardCell.self) + collectionView.register(FeedCardCell.self) } var landscapeBehavior: NTPLandscapeSizingBehavior { @@ -117,6 +120,55 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { return 20 } + private var iabTrackedCellContexts: [IndexPath: ViewportTrackedCardContext] = [:] + + /// Information about a IAB tracked card to determine if a user viewed the ad by scrolling + /// at least 50% of the cell into the viewport for at least 1 second + private class ViewportTrackedCardContext { + var collectionView: UICollectionView + var action: () -> Void + var runningTimer: Timer? + + deinit { + runningTimer?.invalidate() + } + + init(collectionView: UICollectionView, action: @escaping () -> Void) { + self.collectionView = collectionView + self.action = action + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + func cellAtIndexPathIsMostlyVisible( + _ indexPath: IndexPath, + context: ViewportTrackedCardContext + ) -> Bool { + if let cell = context.collectionView.cellForItem(at: indexPath) { + if cell.frame.intersection(context.collectionView.bounds).height > + cell.bounds.height / 2.0 { + return true + } + } + return false + } + if iabTrackedCellContexts.isEmpty { return } + for (indexPath, context) in iabTrackedCellContexts { + if cellAtIndexPathIsMostlyVisible(indexPath, context: context), + context.runningTimer == nil { + context.runningTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false, block: { [weak self] timer in + guard let self = self else { return } + if let context = self.iabTrackedCellContexts[indexPath], + context.runningTimer == timer, + cellAtIndexPathIsMostlyVisible(indexPath, context: context) { + // Still at least 50% visible + context.action() + } + }) + } + } + } + func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if indexPath.item == 0, let cell = cell as? FeedCardCell { cell.content.graphicAnimationView.play() @@ -124,16 +176,28 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { if let card = dataSource.state.cards?[safe: indexPath.item] { if case .partner(let item) = card, let creativeInstanceID = item.content.creativeInstanceID { - ads.reportPromotedContentAdEvent( - item.content.urlHash, - creativeInstanceId: creativeInstanceID, - eventType: .viewed - ) + iabTrackedCellContexts[indexPath] = .init(collectionView: collectionView) { [weak self] in + self?.rewards.ads.reportPromotedContentAdEvent( + item.content.urlHash, + creativeInstanceId: creativeInstanceID, + eventType: .viewed + ) + } + } + if case .ad(let ad) = card { + iabTrackedCellContexts[indexPath] = .init(collectionView: collectionView) { [weak self] in + self?.rewards.ads.reportInlineContentAdEvent( + ad.uuid, + creativeInstanceId: ad.creativeInstanceID, + eventType: .viewed + ) + } } } } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + iabTrackedCellContexts[indexPath] = nil if indexPath.item == 0, let cell = cell as? FeedCardCell { cell.content.graphicAnimationView.stop() } @@ -229,6 +293,14 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { self?.actionHandler(.bravePartnerLearnMoreTapped) } return cell + case .ad(let ad): + let cell = collectionView.dequeueReusableCell(for: indexPath) as FeedCardCell + cell.content.feedView.setupWithInlineContentAd(ad) + cell.content.contextMenu = inlineContentAdContextMenu(ad) + cell.content.actionHandler = { [weak self] index, action in + self?.actionHandler(.inlineContentAdAction(action, ad: ad)) + } + return cell case .headlinePair(let pair): let cell = collectionView.dequeueReusableCell(for: indexPath) as FeedCardCell cell.content.smallHeadelineCardViews.left.feedView.setupWithItem(pair.first) @@ -285,6 +357,47 @@ class BraveNewsSectionProvider: NSObject, NTPObservableSectionProvider { return handler(from: { _ in item }, card: card, indexPath: indexPath) } + private func inlineContentAdContextMenu(_ ad: InlineContentAd) -> FeedItemMenu { + typealias MenuActionHandler = (_ ad: InlineContentAd) -> Void + + func itemActionHandler(_ action: FeedItemAction, _ ad: InlineContentAd) { + self.actionHandler(.inlineContentAdAction(action, ad: ad)) + } + + let openInNewTabHandler: MenuActionHandler = { ad in + itemActionHandler(.opened(inNewTab: true), ad) + } + let openInNewPrivateTabHandler: MenuActionHandler = { ad in + itemActionHandler(.opened(inNewTab: true, switchingToPrivateMode: true), ad) + } + return .init { index -> UIMenu? in + func mapDeferredHandler(_ handler: @escaping MenuActionHandler) -> UIActionHandler { + return UIAction.deferredActionHandler { _ in + handler(ad) + } + } + var openInNewTab: UIAction { + .init(title: Strings.openNewTabButtonTitle, image: UIImage(named: "brave.plus"), handler: mapDeferredHandler(openInNewTabHandler)) + } + + var openInNewPrivateTab: UIAction { + .init(title: Strings.openNewPrivateTabButtonTitle, image: UIImage(named: "brave.shades"), handler: mapDeferredHandler(openInNewPrivateTabHandler)) + } + let openActions: [UIAction] = [ + openInNewTab, + // Brave News is only available in normal tabs, so this isn't technically required + // but good to be on the safe side + !PrivateBrowsingManager.shared.isPrivateBrowsing ? + openInNewPrivateTab : + nil + ].compactMap { $0 } + let children: [UIMenu] = [ + UIMenu(title: "", options: [.displayInline], children: openActions), + ] + return UIMenu(title: ad.targetURL, children: children) + } + } + private func contextMenu(from feedList: @escaping (Int) -> FeedItem, card: FeedCard, indexPath: IndexPath) -> FeedItemMenu { typealias MenuActionHandler = (_ context: FeedItemActionContext) -> Void @@ -390,3 +503,25 @@ extension FeedItemView { } } } + +extension FeedItemView { + func setupWithInlineContentAd(_ ad: InlineContentAd) { + titleLabel.text = ad.title + thumbnailImageView.sd_setImage(with: ad.imageURL.asURL, placeholderImage: nil, options: .avoidAutoSetImage, completed: { (image, _, cacheType, _) in + if cacheType == .none { + UIView.transition( + with: self.thumbnailImageView, + duration: 0.35, + options: [.transitionCrossDissolve, .curveEaseInOut], + animations: { + self.thumbnailImageView.image = image + } + ) + } else { + self.thumbnailImageView.image = image + } + }) + brandContainerView.textLabel.text = ad.message + callToActionButton.setTitle(ad.ctaText, for: .normal) + } +} diff --git a/Client/Frontend/Settings/Brave News/BraveNewsSettingsViewController.swift b/Client/Frontend/Settings/Brave News/BraveNewsSettingsViewController.swift index a3d0bc5e8ef..dcda30d6489 100644 --- a/Client/Frontend/Settings/Brave News/BraveNewsSettingsViewController.swift +++ b/Client/Frontend/Settings/Brave News/BraveNewsSettingsViewController.swift @@ -8,6 +8,7 @@ import Static import Shared import BraveShared import Data +import BraveCore /// Displays relevant Brave News settings such as toggling the feature on/off, and selecting sources /// @@ -16,9 +17,11 @@ import Data class BraveNewsSettingsViewController: TableViewController { private let feedDataSource: FeedDataSource + private let rewards: BraveRewards? - init(dataSource: FeedDataSource) { + init(dataSource: FeedDataSource, rewards: BraveRewards?) { feedDataSource = dataSource + self.rewards = rewards super.init(style: .insetGrouped) } @@ -58,7 +61,13 @@ class BraveNewsSettingsViewController: TableViewController { Preferences.BraveNews.userOptedIn.value = true Preferences.BraveNews.isEnabled.value = true if self.feedDataSource.shouldLoadContent { - self.feedDataSource.load() + if let rewards = rewards { + rewards.ads.initialize { [weak self] _ in + self?.feedDataSource.load() + } + } else { + feedDataSource.load() + } } self.reloadSections() }, @@ -74,7 +83,13 @@ class BraveNewsSettingsViewController: TableViewController { rows: [ .boolRow( title: Strings.BraveNews.isEnabledToggleLabel, - option: Preferences.BraveNews.isEnabled + option: Preferences.BraveNews.isEnabled, + onValueChange: { [unowned self] value in + Preferences.BraveNews.isEnabled.value = value + if value { + self.rewards?.ads.initialize { _ in } + } + } ) ] ), diff --git a/Client/Frontend/Settings/SettingsViewController.swift b/Client/Frontend/Settings/SettingsViewController.swift index aa87fdc28f4..fa540967412 100644 --- a/Client/Frontend/Settings/SettingsViewController.swift +++ b/Client/Frontend/Settings/SettingsViewController.swift @@ -193,7 +193,7 @@ class SettingsViewController: TableViewController { #if !NO_BRAVE_NEWS section.rows.append( Row(text: Strings.BraveNews.braveNews, selection: { - let todaySettings = BraveNewsSettingsViewController(dataSource: self.feedDataSource) + let todaySettings = BraveNewsSettingsViewController(dataSource: self.feedDataSource, rewards: self.rewards) self.navigationController?.pushViewController(todaySettings, animated: true) }, image: #imageLiteral(resourceName: "settings-brave-today").template, accessory: .disclosureIndicator) ) diff --git a/Client/Rewards/BraveRewards.swift b/Client/Rewards/BraveRewards.swift index a06fadb2546..50da1bd3c8a 100644 --- a/Client/Rewards/BraveRewards.swift +++ b/Client/Rewards/BraveRewards.swift @@ -6,6 +6,7 @@ import Foundation import BraveCore import BraveShared +import Combine class BraveRewards: NSObject { @@ -42,6 +43,14 @@ class BraveRewards: NSObject { ads = BraveAds(stateStoragePath: configuration.storageURL.appendingPathComponent("ads").path) super.init() + + braveNewsObservation = Preferences.BraveNews.isEnabled.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] value in + if !value { + self?.proposeAdsShutdown() + } + } } func startLedgerService(_ completion: (() -> Void)?) { @@ -55,9 +64,13 @@ class BraveRewards: NSObject { ledger?.initializeLedgerService { [weak self] in guard let self = self, let ledger = self.ledger else { return } if self.ads.isEnabled { - self.ads.initialize { success in - if success { - self.updateAdsWithWalletInfo() + if self.ads.isAdsServiceRunning() { + self.updateAdsWithWalletInfo() + } else { + self.ads.initialize { success in + if success { + self.updateAdsWithWalletInfo() + } } } } @@ -78,6 +91,21 @@ class BraveRewards: NSObject { } } + private var braveNewsObservation: AnyCancellable? + + private var shouldShutdownAds: Bool { + ads.isAdsServiceRunning() && !ads.isEnabled && !Preferences.BraveNews.isEnabled.value + } + + /// Propose that the ads service should be shutdown based on whether or not that all features + /// that use it are disabled + private func proposeAdsShutdown() { + if !shouldShutdownAds { return } + ads.shutdown { + self.ads = BraveAds(stateStoragePath: self.configuration.storageURL.appendingPathComponent("ads").path) + } + } + // MARK: - State /// Whether or not rewards is enabled @@ -93,10 +121,7 @@ class BraveRewards: NSObject { self.ledger?.isAutoContributeEnabled = newValue self.ads.isEnabled = newValue if !newValue { - // TODO: Do not shutdown the ads service if Brave News is enabled (#3872) - self.ads.shutdown { - self.ads = BraveAds(stateStoragePath: self.configuration.storageURL.appendingPathComponent("ads").path) - } + self.proposeAdsShutdown() } else { if self.ads.isAdsServiceRunning() { self.updateAdsWithWalletInfo() @@ -130,16 +155,17 @@ class BraveRewards: NSObject { } func reset() { - // TODO: Do not shutdown the ads service if Brave News is enabled (#3872) - ads.shutdown { [self] in - try? FileManager.default.removeItem( - at: configuration.storageURL.appendingPathComponent("ledger") - ) - try? FileManager.default.removeItem( - at: configuration.storageURL.appendingPathComponent("ads") - ) - if ads.isEnabled { - ads.initialize { _ in } + try? FileManager.default.removeItem( + at: configuration.storageURL.appendingPathComponent("ledger") + ) + if ads.isAdsServiceRunning(), !Preferences.BraveNews.isEnabled.value { + ads.shutdown { [self] in + try? FileManager.default.removeItem( + at: configuration.storageURL.appendingPathComponent("ads") + ) + if ads.isEnabled { + ads.initialize { _ in } + } } } }