Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Ads): add AdsStore #1007

Draft
wants to merge 6 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 189 additions & 0 deletions PocketKit/Sources/PocketKit/Ads/AdsStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// 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 Textile
import UIKit

enum PocketAdZone: String, Sendable {
case home
case saves
case banner // a banner that we can add to either reader or collection?
// TODO: ADS - do we want to add a collection type to insert ads in a collection?
}

struct PocketAdsSequence: Identifiable {
let id: String
let ads: [PocketAd]
}

struct PocketAd: Decodable {
let title: String
let description: String
let imageUrl: String
let buttonTitle: String
/// The destination url when a user taps on an ad
let targetUrl: String
// colors are coming in as hex strings but we should convert them.
// for now let's assume we'll have UIColor
let textColor: UIColor
let ctaTextColor: UIColor
let ctaBackgroundColor: UIColor
let backgroundColor: UIColor

enum CodingKeys: String, CodingKey {
case title = "alt"
case description = "copy"
case imageUrl = "image"
case buttonTitle = "cta"
case targetUrl = "click"
case textColor
case textColorDark
case ctaTextColor
case ctaTextColorDark
case ctaBackgroundColor
case ctaBackgroundColorDark
case backgroundColor
case backgroundColorDark
}

init(from decoder: any Decoder) throws {
// TODO: ADS - the actual json structure is not this flat, this will need to be updated accordingly
let container = try decoder.container(keyedBy: CodingKeys.self)
self.title = try container.decode(String.self, forKey: .title)
self.description = try container.decode(String.self, forKey: .description)
self.imageUrl = try container.decode(String.self, forKey: .imageUrl)
self.buttonTitle = try container.decode(String.self, forKey: .buttonTitle)
self.targetUrl = try container.decode(String.self, forKey: .targetUrl)
let textColor = try container.decode(String.self, forKey: .textColor)
let textColorDark = try container.decode(String.self, forKey: .textColorDark)
self.textColor = UIColor(
light: UIColor(hexString: textColor),
dark: UIColor(hexString: textColorDark)
)
let ctaTextColor = try container.decode(String.self, forKey: .ctaTextColor)
let ctaTextColorDark = try container.decode(String.self, forKey: .ctaTextColorDark)
self.ctaTextColor = UIColor(
light: UIColor(hexString: ctaTextColor),
dark: UIColor(hexString: ctaTextColorDark)
)
let ctaBackgroundColor = try container.decode(String.self, forKey: .ctaBackgroundColor)
let ctaBackgroundColorDark = try container.decode(String.self, forKey: .ctaBackgroundColorDark)
self.ctaBackgroundColor = UIColor(
light: UIColor(hexString: ctaBackgroundColor),
dark: UIColor(hexString: ctaBackgroundColorDark)
)
let backgroundColor = try container.decode(String.self, forKey: .backgroundColor)
let backgroundColorDark = try container.decode(String.self, forKey: .backgroundColorDark)
self.backgroundColor = UIColor(
light: UIColor(hexString: backgroundColor),
dark: UIColor(hexString: backgroundColorDark)
)
}

init(
title: String,
description: String,
imageUrl: String,
buttonTitle: String,
targetUrl: String,
textColor: UIColor,
ctaTextColor: UIColor,
ctaBackgroundColor: UIColor,
backgroundColor: UIColor
) {
self.title = title
self.description = description
self.imageUrl = imageUrl
self.buttonTitle = buttonTitle
self.targetUrl = targetUrl
self.textColor = textColor
self.ctaTextColor = ctaTextColor
self.ctaBackgroundColor = ctaBackgroundColor
self.backgroundColor = backgroundColor
}
}

// TODO: ADS - we might want to add a generic protocol here, but for now let's keep it simple
struct PocketAdsStore: Sendable {
func getAds() async -> [PocketAdsSequence] {
[
PocketAdsSequence(id: "ads_sequence_123", ads: [Self.mockAd1, Self.mockAd2, Self.mockAd3]),
PocketAdsSequence(id: "ads_sequence_213", ads: [Self.mockAd2, Self.mockAd1, Self.mockAd3]),
PocketAdsSequence(id: "ads_sequence_321", ads: [Self.mockAd3, Self.mockAd2, Self.mockAd1]),
PocketAdsSequence(id: "ads_sequence_132", ads: [Self.mockAd1, Self.mockAd3, Self.mockAd2]),
PocketAdsSequence(id: "ads_sequence_231", ads: [Self.mockAd2, Self.mockAd3, Self.mockAd1]),
PocketAdsSequence(id: "ads_sequence_312", ads: [Self.mockAd3, Self.mockAd1, Self.mockAd2])
]
}
}

// TODO: - ADS - remove this mock when the implementation is complete
private extension PocketAdsStore {
static let mockAd1 = PocketAd(
title: "Get Pocket Premium!",
description: "Subscribe to premium to get the most out of Pocket",
imageUrl: "https://assets-prod.sumo.prod.webservices.mozgcp.net/media/uploads/products/2023-08-22-06-28-55-65dfd5.png",
buttonTitle: "Get Premium",
targetUrl: "https://getpocket.com",
textColor: UIColor(.ui.white1),
ctaTextColor: UIColor(.ui.white1),
ctaBackgroundColor: UIColor(.ui.teal1),
backgroundColor: UIColor(.ui.black1)
)

static let mockAd2 = PocketAd(
title: "Get Pocket Extra!",
description: "Subscribe to extra to get the fanciest Pocket features",
imageUrl: "https://assets-prod.sumo.prod.webservices.mozgcp.net/media/uploads/products/2023-08-22-06-28-55-65dfd5.png",
buttonTitle: "Get Premium",
targetUrl: "https://getpocket.com",
textColor: UIColor(.ui.white1),
ctaTextColor: UIColor(.ui.white1),
ctaBackgroundColor: UIColor(.ui.coral1),
backgroundColor: UIColor(.ui.black1)
)

static let mockAd3 = PocketAd(
title: "Get Pocket Ultra!",
description: "Subscribe to ultra to get everything you can from Pocket",
imageUrl: "https://assets-prod.sumo.prod.webservices.mozgcp.net/media/uploads/products/2023-08-22-06-28-55-65dfd5.png",
buttonTitle: "Get Premium",
targetUrl: "https://getpocket.com",
textColor: UIColor(.ui.white1),
ctaTextColor: UIColor(.ui.white1),
ctaBackgroundColor: UIColor(.ui.apricot1),
backgroundColor: UIColor(.ui.black1)
)
}

// TODO: ADS - move this to SharedPocketKit
extension UIColor {
convenience init(light: UIColor, dark: UIColor) {
self.init { traitCollection in
if traitCollection.userInterfaceStyle == .dark {
return dark
} else {
return light
}
}
}

convenience init(hexString: String) {
let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int = UInt64()
Scanner(string: hex).scanHexInt64(&int)
let a, r, g, b: UInt64
switch hex.count {
case 3: // RGB (12-bit)
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
case 6: // RGB (24-bit)
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
case 8: // ARGB (32-bit)
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
default:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(red: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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
import UIKit
import Textile
import Localization

struct AdCarouselCellConfiguration: HomeCarouselCellConfiguration {
private let ads: [PocketAd]

private var currentIndex = 0

private var currentAd: PocketAd {
ads[currentIndex]
}

init(sequence: PocketAdsSequence) {
self.ads = sequence.ads
}
var thumbnailURL: URL? {
URL(string: currentAd.imageUrl)
}

var saveButtonMode: ItemCellSaveButton.Mode? {
nil
}

var favoriteAction: ItemAction? {
nil
}

var overflowActions: [ItemAction]? {
// TODO: ADS - tbd overflow actions for ads
nil
}

var saveAction: ItemAction? {
nil
}

var attributedCollection: NSAttributedString? {
nil
}

var attributedTitle: NSAttributedString {
let content = NSMutableAttributedString()
content.append(NSAttributedString(string: currentAd.title, style: .recommendation.title))
content.append(NSAttributedString(string: "\n" + currentAd.description, style: .recommendation.title))
return content
}

var attributedDomain: NSAttributedString {
NSMutableAttributedString(string: "Sponsored", style: .recommendation.domain)
}

var attributedTimeToRead: NSAttributedString {
return NSAttributedString(string: "", style: .recommendation.timeToRead)
}

var sharedWithYouUrlString: String? {
nil
}
}
8 changes: 8 additions & 0 deletions PocketKit/Sources/PocketKit/Home/HomeViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ extension HomeViewController {

cell.configure(with: configuration)
return cell
case .ad(let ID):
let cell: HomeCarouselCell = collectionView.dequeueCell(for: indexPath)
guard let sequence = model.getAdsSequence(ID) else {
return cell
}
let config = AdCarouselCellConfiguration(sequence: sequence)
cell.configure(with: config)
return cell
}
}

Expand Down
41 changes: 38 additions & 3 deletions PocketKit/Sources/PocketKit/Home/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,15 @@ class HomeViewModel: NSObject {
}
}

private var adSequences: [PocketAdsSequence] = [] {
didSet {
guard !oldValue.isEmpty else {
return
}
snapshot = buildSnapshot()
}
}

private let source: Source
let tracker: Tracker
private let user: User
Expand All @@ -137,6 +146,7 @@ class HomeViewModel: NSObject {
private let store: SubscriptionStore
private let recentSavesWidgetUpdateService: RecentSavesWidgetUpdateService
private let recommendationsWidgetUpdateService: RecommendationsWidgetUpdateService
private let adStore = PocketAdsStore()

private let recentSavesController: NSFetchedResultsController<SavedItem>
private let recomendationsController: RichFetchedResultsController<Recommendation>
Expand Down Expand Up @@ -220,6 +230,9 @@ class HomeViewModel: NSObject {
homeRefreshCoordinator.refresh(isForced: isForced) {
completion()
}
Task {
adSequences = await adStore.getAds()
}
}
}

Expand Down Expand Up @@ -256,8 +269,8 @@ extension HomeViewModel {
return snapshot
}

for slateSection in slateSections {
guard var recommendations = slateSection.objects as? [Recommendation],
for slateSection in slateSections.enumerated() {
guard var recommendations = slateSection.element.objects as? [Recommendation],
let slateId = recommendations.first?.slate?.objectID
else {
continue
Expand All @@ -283,8 +296,14 @@ extension HomeViewModel {
}

snapshot.appendSections([.slateCarousel(slateId)])
var items = recommendations.prefix(4).map { Cell.recommendationCarousel($0.objectID) }
// TODO: ADS - insert ads in the carousel here
if slateSection.offset != 0, let ID = adSequences[safe: slateSection.offset]?.id {
items.insert(.ad(ID), at: 0)
}

snapshot.appendItems(
recommendations.prefix(4).map { .recommendationCarousel($0.objectID) },
items,
toSection: .slateCarousel(slateId)
)
}
Expand Down Expand Up @@ -315,6 +334,9 @@ extension HomeViewModel {
return
}
select(sharedWithYouItem: sharedWithYouItem, at: indexPath)
case .ad(let ID):
// TODO: ADS - add logic for tapping on an ad here
return
}
}

Expand Down Expand Up @@ -910,6 +932,9 @@ extension HomeViewModel {

let givenURL = item.givenURL
tracker.track(event: Events.Home.SlateArticleImpression(url: givenURL, positionInList: indexPath.item, slateId: slate.remoteID, slateRequestId: slate.requestID, slateExperimentId: slate.experimentID, slateIndex: indexPath.section, slateLineupId: slateLineup.remoteID, slateLineupRequestId: slateLineup.requestID, slateLineupExperimentId: slateLineup.experimentID, recommendationId: recommendation.analyticsID))
case .ad(let ID):
// TODO: ADS - add logic for tracking add tapped here
return
}
}
}
Expand All @@ -930,6 +955,7 @@ extension HomeViewModel {
case recommendationHero(NSManagedObjectID)
case recommendationCarousel(NSManagedObjectID)
case sharedWithYou(NSManagedObjectID)
case ad(String)
case offline
}
}
Expand Down Expand Up @@ -1039,6 +1065,15 @@ extension HomeViewModel: NSFetchedResultsControllerDelegate {
}
}

// MARK: Ads
extension HomeViewModel {
func getAdsSequence(_ ID: String) -> PocketAdsSequence? {
adSequences.first {
$0.id == ID
}
}
}

// MARK: recent saves widget
private extension HomeViewModel {
func updateRecentSavesWidget() {
Expand Down