Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ class FixedFrequencyAdsInjector {
Log.add(event: .info, "[\(index)] - 🐶 Ad \(ad.adId)")
case .content(let value):
Log.add(event: .info, "[\(index)] - Content \(modelIdentifier(value))")
case .bannerAd(let placement):
Log.add(event: .info, "[\(index)] - 📢 BannerAd \(placement.id)")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,56 @@
import Foundation
import AmitySDK
import Combine
import SwiftUI

// MARK: - Banner Ad Models
// Defined here so every file that sees UIKitPaginator also sees these types.

/// Represents a single banner-ad slot that will be rendered inside the feed.
public struct BannerAdPlacement: Identifiable {
public let id: String
public let adUnitID: String

public init(adUnitID: String) {
self.id = "banner-ad-\(UUID().uuidString)"
self.adUnitID = adUnitID
}
}

/// Controls how / whether banner ads are injected into the news feed.
/// Configure from your host app **before** the feed loads.
public class BannerAdFeedConfig {
public static let shared = BannerAdFeedConfig()

/// Master switch – set to `true` to start injecting ads.
public var isEnabled: Bool = false

/// The ad-unit ID forwarded to every `BannerAdPlacement`.
public var adUnitID: String = "ca-app-pub-3940256099942544/2934735716"

/// Insert a banner ad after every *frequency* content posts.
public var frequency: Int = 5

/// Maximum number of banner ads in the feed at any time.
public var maxAdsCount: Int = 3

/// Height (points) for the ad row. Defaults to 250.
public var adHeight: CGFloat = 250

/// Closure that returns the SwiftUI view for a given placement.
/// The host app **must** set this to supply the actual ad view.
///
/// ```swift
/// BannerAdFeedConfig.shared.adViewBuilder = { placement in
/// AnyView(BannerAdView(adUnitID: placement.adUnitID))
/// }
/// ```
public var adViewBuilder: ((BannerAdPlacement) -> AnyView)?

private init() {}
}

// MARK: - Paginated Item

enum PaginatedItemType {
case content
Expand All @@ -18,6 +68,7 @@ class PaginatedItem<Content>: Identifiable, Equatable {
enum ItemType {
case content(_ value: Content)
case ad(_ value: AmityAd)
case bannerAd(_ value: BannerAdPlacement)
}

var id: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ public struct AmityCommunityFeedComponent: AmityComponentView {
.frame(height: 8)
.opacity(postFeedViewModel.postItems.count - 1 == index ? 0 : 1)
}

case .bannerAd(let placement):
if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) {
VStack(spacing: 0) {
adView
.frame(height: BannerAdFeedConfig.shared.adHeight)
.frame(maxWidth: .infinity)

Rectangle()
.fill(Color(viewConfig.theme.baseColorShade4))
.frame(height: 8)
.opacity(postFeedViewModel.postItems.count - 1 == index ? 0 : 1)
}
}

case .content(let post):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ public struct AmityGlobalFeedComponent: AmityComponentView {
.fill(Color(viewConfig.theme.baseColorShade4))
.frame(height: 8)
}

case .bannerAd(let placement):
if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) {
VStack(spacing: 0) {
adView
.frame(height: BannerAdFeedConfig.shared.adHeight)
.frame(maxWidth: .infinity)

Rectangle()
.fill(Color(viewConfig.theme.baseColorShade4))
.frame(height: 8)
}
}

case .content(let post):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,29 @@ public struct AmityNewsFeedComponent: AmityComponentView {
.frame(height: 8)
}

case .bannerAd(let placement):
if let adView = BannerAdFeedConfig.shared.adViewBuilder?(placement) {
VStack(spacing: 0) {
// "Sponsored" header
HStack {
AdSponsorLabel()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(viewConfig.theme.backgroundColor))

// The actual ad view supplied by the host app
adView
.frame(height: BannerAdFeedConfig.shared.adHeight)
.frame(maxWidth: .infinity)

Rectangle()
.fill(Color(viewConfig.theme.baseColorShade4))
.frame(height: 8)
}
}

case .content(let post):

VStack(spacing: 0){
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI
import GoogleMobileAds

struct BannerAdView: UIViewRepresentable {

let adUnitID: String

func makeUIView(context: Context) -> BannerView {
let banner = BannerView(adSize: AdSizeBanner)
banner.adUnitID = adUnitID
banner.rootViewController = UIApplication.shared.rootViewController
banner.load(Request())
return banner
}

func updateUIView(_ uiView: BannerView, context: Context) {}
}

// Helper to get root view controller
extension UIApplication {
var rootViewController: UIViewController? {
guard let scene = connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController else {
return nil
}
return root
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ class PostFeedViewModel: ObservableObject {
private var globalPinnedPostsIds: Set<String> = []
private var feedPosts: [PaginatedItem<AmityPost>] = []

/// Cached banner-ad slots keyed by their position index (0, 1, 2 …).
/// Reusing the same instances keeps their `id` stable so SwiftUI does
/// not recreate (and therefore "blink") the ad views on every re-render.
private var cachedBannerAds: [Int: PaginatedItem<AmityPostModel>] = [:]

public enum FeedType: Equatable {
case community(communityId: String)
case globalFeed
Expand Down Expand Up @@ -229,6 +234,9 @@ extension PostFeedViewModel {
let feedPosts = prepareFeedPosts()
listItems.append(contentsOf: feedPosts)

// Inject external banner ads (e.g. Google Ads) into the feed
listItems = injectBannerAds(into: listItems)

self.postItems = listItems
}

Expand Down Expand Up @@ -258,6 +266,8 @@ extension PostFeedViewModel {
if canRenderPost(post: post) && !globalPinnedPostsIds.contains(post.postId) {
finalItems.append(PaginatedItem(id: $0.id, type: .content(AmityPostModel(post: post))))
}
case .bannerAd:
break // banner ads are injected later in renderFeed()
}
}

Expand All @@ -268,6 +278,45 @@ extension PostFeedViewModel {
let filterCondition = !post.childrenPosts.contains { $0.dataType == "file" || $0.dataType == "audio" || $0.structureType == "mixed" }
return filterCondition
}

// MARK: - External Banner Ad Injection

/// Inserts `BannerAdPlacement` items into the list at a fixed frequency
/// controlled by `BannerAdFeedConfig`.
/// Placements are cached so their IDs stay stable across re-renders,
/// preventing SwiftUI from recreating (blinking) the ad views.
private func injectBannerAds(into items: [PaginatedItem<AmityPostModel>]) -> [PaginatedItem<AmityPostModel>] {
let config = BannerAdFeedConfig.shared
guard config.isEnabled, config.frequency > 0, config.adViewBuilder != nil else {
return items
}

var result = [PaginatedItem<AmityPostModel>]()
var adsInserted = 0
var contentCount = 0

for item in items {
result.append(item)

if case .content = item.type {
contentCount += 1
}

if contentCount > 0,
contentCount % config.frequency == 0,
adsInserted < config.maxAdsCount {

// Reuse the cached item for this slot index, or create once
let slotIndex = adsInserted
if cachedBannerAds[slotIndex] == nil {
let placement = BannerAdPlacement(adUnitID: config.adUnitID)
cachedBannerAds[slotIndex] = PaginatedItem(id: placement.id, type: .bannerAd(placement))
}
result.append(cachedBannerAds[slotIndex]!)
adsInserted += 1
}
}

return result
}
}


Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ class CommentCoreViewModel: ObservableObject {
return PaginatedItem(id: $0.id, type: .content(AmityCommentModel(comment: comment)))
case .ad(let ad):
return PaginatedItem(id: $0.id, type: .ad(ad))
case .bannerAd(let placement):
return PaginatedItem(id: $0.id, type: .bannerAd(placement))
}
}
items.append(contentsOf: mappedLoadedItems)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ struct CommentListView<Content>: View where Content: View {
AmityCommentAdComponent(ad: ad, selctedAdInfoAction: { ad in
commentCoreViewModel.adSeetState = (true, ad)
})
case .bannerAd:
EmptyView()
case .content(let comment):
Section {
if !comment.isDeleted {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ public class AmityStoryTargetModel: ObservableObject, Identifiable, Equatable {
videoURLs.append(videoURL)
}
return PaginatedItem<AmityStoryModel>(id: story.storyId, type: .content(AmityStoryModel(story: story)))
case .bannerAd(let placement):
return PaginatedItem<AmityStoryModel>(id: placement.id, type: .bannerAd(placement))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ struct StoryCoreView: View, AmityViewIdentifiable {
StoryAdView(ad: ad, gestureView: getGestureView)
case .content(let storyModel):
getStoryView(storyModel)
case .bannerAd:
EmptyView()
}

HStack(spacing: 0) {
Expand Down