diff --git a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift index f288e3170..c432ec399 100644 --- a/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift +++ b/swift-sdk/Internal/Utilities/DependencyContainerProtocol.swift @@ -39,6 +39,7 @@ extension DependencyContainerProtocol { displayer: inAppDisplayer, persister: inAppPersister, inAppDelegate: config.inAppDelegate, + inAppDisplayDelegate: config.inAppDisplayDelegate, urlDelegate: config.urlDelegate, customActionDelegate: config.customActionDelegate, urlOpener: urlOpener, diff --git a/swift-sdk/Internal/in-app/InAppDisplayer.swift b/swift-sdk/Internal/in-app/InAppDisplayer.swift index a3e4bc134..f1803229e 100644 --- a/swift-sdk/Internal/in-app/InAppDisplayer.swift +++ b/swift-sdk/Internal/in-app/InAppDisplayer.swift @@ -44,12 +44,13 @@ class InAppDisplayer: InAppDisplayerProtocol { return .notShown("In-app notification is being presented.") } - guard let topViewController = getTopViewController() else { + // Initial basic checks - detailed checks will be done after delay + guard InAppDisplayer.getTopViewController() != nil else { return .notShown("No top view controller.") } - if topViewController is IterableHtmlMessageViewController { - return .notShown("Skipping the in-app notification. Another notification is already being displayed.") + guard let message = messageMetadata?.message else { + return .notShown("No message available for display validation.") } let parameters = IterableHtmlMessageViewController.Parameters(html: htmlString, @@ -58,11 +59,10 @@ class InAppDisplayer: InAppDisplayerProtocol { isModal: true) let htmlMessageVC = IterableHtmlMessageViewController.create(parameters: parameters, onClickCallback: onClickCallback) - topViewController.definesPresentationContext = true - htmlMessageVC.modalPresentationStyle = .overFullScreen - let presenter = InAppPresenter(topViewController: topViewController, htmlMessageViewController: htmlMessageVC) + let presenter = InAppPresenter(htmlMessageViewController: htmlMessageVC, message: message) + presenter.show() return .shown @@ -81,7 +81,7 @@ class InAppDisplayer: InAppDisplayerProtocol { return topViewController is IterableHtmlMessageViewController } - private static func getTopViewController() -> UIViewController? { + static func getTopViewController() -> UIViewController? { guard let rootViewController = IterableUtil.rootViewController else { return nil } diff --git a/swift-sdk/Internal/in-app/InAppManager.swift b/swift-sdk/Internal/in-app/InAppManager.swift index c8cc328cd..5a3a52430 100644 --- a/swift-sdk/Internal/in-app/InAppManager.swift +++ b/swift-sdk/Internal/in-app/InAppManager.swift @@ -43,6 +43,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { displayer: InAppDisplayerProtocol, persister: InAppPersistenceProtocol, inAppDelegate: IterableInAppDelegate, + inAppDisplayDelegate: IterableInAppDisplayDelegate?, urlDelegate: IterableURLDelegate?, customActionDelegate: IterableCustomActionDelegate?, urlOpener: UrlOpenerProtocol, @@ -59,6 +60,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { self.displayer = displayer self.persister = persister self.inAppDelegate = inAppDelegate + self.inAppDisplayDelegate = inAppDisplayDelegate self.urlDelegate = urlDelegate self.customActionDelegate = customActionDelegate self.urlOpener = urlOpener @@ -555,6 +557,7 @@ class InAppManager: NSObject, IterableInternalInAppManagerProtocol { private let fetcher: InAppFetcherProtocol private let displayer: InAppDisplayerProtocol private let inAppDelegate: IterableInAppDelegate + private let inAppDisplayDelegate: IterableInAppDisplayDelegate? private let urlDelegate: IterableURLDelegate? private let customActionDelegate: IterableCustomActionDelegate? private let urlOpener: UrlOpenerProtocol @@ -654,7 +657,10 @@ extension InAppManager: InAppNotifiable { extension InAppManager: InAppDisplayChecker { func isOkToShowNow(message: IterableInAppMessage) -> Bool { - guard !isAutoDisplayPaused else { + // Check delegate first if available, otherwise fall back to isAutoDisplayPaused property + let autoDisplayPaused = inAppDisplayDelegate?.isAutoDisplayPaused?(for: message) ?? isAutoDisplayPaused + + guard !autoDisplayPaused else { ITBInfo("automatic in-app display has been paused") return false } diff --git a/swift-sdk/Internal/in-app/InAppPresenter.swift b/swift-sdk/Internal/in-app/InAppPresenter.swift index 03938e2bf..eec90b062 100644 --- a/swift-sdk/Internal/in-app/InAppPresenter.swift +++ b/swift-sdk/Internal/in-app/InAppPresenter.swift @@ -8,16 +8,17 @@ class InAppPresenter { static var isPresenting = false private let maxDelay: TimeInterval - - private let topViewController: UIViewController private let htmlMessageViewController: IterableHtmlMessageViewController + private let message: IterableInAppMessage private var delayTimer: Timer? - init(topViewController: UIViewController, htmlMessageViewController: IterableHtmlMessageViewController, maxDelay: TimeInterval = 0.75) { + init(htmlMessageViewController: IterableHtmlMessageViewController, + message: IterableInAppMessage, + maxDelay: TimeInterval = 0.75) { ITBInfo() - self.topViewController = topViewController self.htmlMessageViewController = htmlMessageViewController + self.message = message self.maxDelay = maxDelay // shouldn't be necessary, but in case there's some kind of race condition @@ -41,7 +42,7 @@ class InAppPresenter { ITBInfo("delayTimer called") self.delayTimer = nil - self.present() + self.presentAfterDelayValidation() } } } @@ -55,15 +56,27 @@ class InAppPresenter { delayTimer?.invalidate() delayTimer = nil - present() + presentAfterDelayValidation() } } - private func present() { + private func presentAfterDelayValidation() { ITBInfo() InAppPresenter.isPresenting = false + guard let topViewController = InAppDisplayer.getTopViewController() else { + ITBInfo("No top view controller available after delay") + return + } + + if topViewController is IterableHtmlMessageViewController { + ITBInfo("Another Iterable message is already being displayed") + return + } + + topViewController.definesPresentationContext = true + topViewController.present(htmlMessageViewController, animated: false) htmlMessageViewController.presenter = nil diff --git a/swift-sdk/SDK/IterableConfig.swift b/swift-sdk/SDK/IterableConfig.swift index 62f89c14e..4ff06ce51 100644 --- a/swift-sdk/SDK/IterableConfig.swift +++ b/swift-sdk/SDK/IterableConfig.swift @@ -4,6 +4,25 @@ import Foundation +/// Protocol to provide dynamic control over in-app message display timing. +/// This delegate allows the application to determine at the exact moment +/// whether an in-app message should be displayed, providing more granular +/// control than the static `isAutoDisplayPaused` property. +@objc public protocol IterableInAppDisplayDelegate { + + /// Called to determine if in-app messages should be automatically displayed at this moment. + /// This method is called just before an in-app message would be shown, allowing the app + /// to make a real-time decision based on current app state. + /// + /// - Parameter message: The in-app message that is about to be displayed + /// - Returns: `true` if automatic display should be paused (message will not show), `false` if display should proceed + /// + /// - Note: This method is called in addition to other display checks. If this returns `true`, + /// the message will not be shown regardless of other conditions. + /// - Note: If this delegate is not set, the default behavior uses the `isAutoDisplayPaused` property. + @objc optional func isAutoDisplayPaused(for message: IterableInAppMessage) -> Bool +} + public enum IterableAPIMobileFrameworkType: String, Codable { case flutter = "flutter" case reactNative = "reactnative" @@ -126,6 +145,12 @@ public class IterableConfig: NSObject { /// If more than 1 in-app is available, we show the first. public var inAppDelegate: IterableInAppDelegate = DefaultInAppDelegate() + /// Implement this protocol to provide dynamic control over when in-app messages can be displayed. + /// This delegate allows real-time decision making about display timing, providing more granular + /// control than the static `isAutoDisplayPaused` property. If not set, the SDK will fall back + /// to using the `isAutoDisplayPaused` property. + public var inAppDisplayDelegate: IterableInAppDisplayDelegate? + /// How many seconds to wait before showing the next in-app, if there are more than one present public var inAppDisplayInterval: Double = 30.0 diff --git a/tests/unit-tests/InAppPresenterTests.swift b/tests/unit-tests/InAppPresenterTests.swift index a152aab86..cec8399a8 100644 --- a/tests/unit-tests/InAppPresenterTests.swift +++ b/tests/unit-tests/InAppPresenterTests.swift @@ -12,8 +12,8 @@ class InAppPresenterTests: XCTestCase { func testInAppPresenterDelegateExistence() { let htmlMessageViewController = IterableHtmlMessageViewController.create(parameters: getEmptyParameters(), onClickCallback: nil) - let inAppPresenter = InAppPresenter(topViewController: UIViewController(), - htmlMessageViewController: htmlMessageViewController) + let inAppPresenter = InAppPresenter(htmlMessageViewController: htmlMessageViewController, + message: getEmptyInAppMessage()) // a "no-op" to suppress warning _ = inAppPresenter.self @@ -22,19 +22,18 @@ class InAppPresenterTests: XCTestCase { } func testInAppPresenterIsPresentingOnInit() { - _ = InAppPresenter(topViewController: UIViewController(), - htmlMessageViewController: getEmptyHtmlMessageViewController()) + _ = InAppPresenter(htmlMessageViewController: getEmptyHtmlMessageViewController(), + message: getEmptyInAppMessage()) XCTAssertFalse(InAppPresenter.isPresenting) } - func testInAppPresenterTimerFinished() { + func testInAppPresenterShowMethod() { let expectation1 = expectation(description: "delay timer executed") - let topViewController = UIViewController() let maxDelay = 0.75 - let inAppPresenter = InAppPresenter(topViewController: topViewController, - htmlMessageViewController: getEmptyHtmlMessageViewController(), + let inAppPresenter = InAppPresenter(htmlMessageViewController: getEmptyHtmlMessageViewController(), + message: getEmptyInAppMessage(), maxDelay: maxDelay) inAppPresenter.show()