diff --git a/PocketKit/Sources/PocketKit/Ads/AdsStore.swift b/PocketKit/Sources/PocketKit/Ads/AdsStore.swift new file mode 100644 index 000000000..90fda9378 --- /dev/null +++ b/PocketKit/Sources/PocketKit/Ads/AdsStore.swift @@ -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) + } +} diff --git a/PocketKit/Sources/PocketKit/Home/Cells/Carousel/AdCarouselCellConfiguration.swift b/PocketKit/Sources/PocketKit/Home/Cells/Carousel/AdCarouselCellConfiguration.swift new file mode 100644 index 000000000..c6b326ebf --- /dev/null +++ b/PocketKit/Sources/PocketKit/Home/Cells/Carousel/AdCarouselCellConfiguration.swift @@ -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 + } +} diff --git a/PocketKit/Sources/PocketKit/Home/HomeViewController.swift b/PocketKit/Sources/PocketKit/Home/HomeViewController.swift index 810983821..ae12dd09d 100644 --- a/PocketKit/Sources/PocketKit/Home/HomeViewController.swift +++ b/PocketKit/Sources/PocketKit/Home/HomeViewController.swift @@ -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 } } diff --git a/PocketKit/Sources/PocketKit/Home/HomeViewModel.swift b/PocketKit/Sources/PocketKit/Home/HomeViewModel.swift index 569e8e9a0..18cd930a9 100644 --- a/PocketKit/Sources/PocketKit/Home/HomeViewModel.swift +++ b/PocketKit/Sources/PocketKit/Home/HomeViewModel.swift @@ -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 @@ -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 private let recomendationsController: RichFetchedResultsController @@ -220,6 +230,9 @@ class HomeViewModel: NSObject { homeRefreshCoordinator.refresh(isForced: isForced) { completion() } + Task { + adSequences = await adStore.getAds() + } } } @@ -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 @@ -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) ) } @@ -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 } } @@ -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 } } } @@ -930,6 +955,7 @@ extension HomeViewModel { case recommendationHero(NSManagedObjectID) case recommendationCarousel(NSManagedObjectID) case sharedWithYou(NSManagedObjectID) + case ad(String) case offline } } @@ -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() {