Skip to content

Commit

Permalink
chore: display inline messages fetched after View constructed
Browse files Browse the repository at this point in the history
Part of: https://linear.app/customerio/issue/MBL-310/create-a-uikit-uiview-that-displays-an-inline-in-app-message-sent-from

Fetching of in-app message is an async operation running in the background. There is a chance that in-app messages are not available when the inline View is constructed but become available after. This commit gets notified when a fetch is complete so it can check if there are in-app messages to display after the View has already been constructed.

Testing:
* Automated tests are added.
* Verified that messages display in sample app.
If you want to test this commit in a sample app build, follow these instructions:
1. Kill the app on device.
2. Send yourself a test inline message from Fly.
3. Open app. After you wait a few seconds, the inline message you sent yourself will display.

commit-id:aea90dff
  • Loading branch information
levibostian committed May 28, 2024
1 parent aeaaabe commit 69ef079
Show file tree
Hide file tree
Showing 9 changed files with 149 additions and 22 deletions.
28 changes: 13 additions & 15 deletions Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,21 +33,19 @@ class DashboardViewController: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()

// In a future PR, we will remove the asyncAfter(). this is only for testing in sample apps because when app opens, the local queue is empty. so wait to check messages until first fetch is done.
DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [self] in
inlineInAppViewCreatedInStoryboard.elementId = "dashboard-announcement"

// We want to test that Inline Views can be used by customers who prefer to use code to make the UI.
// Construct a new instance of the View, add it to the ViewController, then set constraints to make it visible.
let newInlineViewUsingUIAsCode = InAppMessageView(elementId: "dashboard-announcement-code")
// Because the Dashboard screen contains a lot of Views and it's designed using Storyboard, we are
// adding this inline View into the UI by adding to a StackView. This allows us to dyanamically add to the Dashboard screen without complexity or breaking any of the constraints set in Storyboard.
buttonStackView.addArrangedSubview(newInlineViewUsingUIAsCode)

// Customers are responsible for setting the width of the View.
newInlineViewUsingUIAsCode.translatesAutoresizingMaskIntoConstraints = false
newInlineViewUsingUIAsCode.widthAnchor.constraint(equalTo: buttonStackView.widthAnchor).isActive = true
}
// For inline Views added with Storyboard, set the elementId to finish setup of the View and begin showing messages.
inlineInAppViewCreatedInStoryboard.elementId = "dashboard-announcement"

// We want to test that Inline Views can be used by customers who prefer to use code to make the UI.
// Construct a new instance of the View, add it to the ViewController, then set constraints to make it visible.
let newInlineViewUsingUIAsCode = InAppMessageView(elementId: "dashboard-announcement-code")
// Because the Dashboard screen contains a lot of Views and it's designed using Storyboard, we are
// adding this inline View into the UI by adding to a StackView. This allows us to dyanamically add to the Dashboard screen without complexity or breaking any of the constraints set in Storyboard.
buttonStackView.addArrangedSubview(newInlineViewUsingUIAsCode)

// Customers are responsible for setting the width of the View.
newInlineViewUsingUIAsCode.translatesAutoresizingMaskIntoConstraints = false
newInlineViewUsingUIAsCode.widthAnchor.constraint(equalTo: buttonStackView.widthAnchor).isActive = true

configureDashboardRouter()
addNotifierObserver()
Expand Down
13 changes: 13 additions & 0 deletions Sources/Common/Communication/Event.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,16 @@ public struct NewSubscriptionEvent: EventRepresentable {
self.params = params
}
}

/// When in-app SDK has fetched in-app messages from the server.
public struct InAppMessagesFetchedEvent: EventRepresentable {
public let storageId: String
public let params: [String: String]
public let timestamp: Date

public init(storageId: String = UUID().uuidString, timestamp: Date = Date(), params: [String: String] = [:]) {
self.storageId = storageId
self.timestamp = timestamp
self.params = params
}
}
4 changes: 0 additions & 4 deletions Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,6 @@ public class Gist: GistInstance, GistDelegate {
delegate?.action(message: message, currentRoute: currentRoute, action: action, name: name)
}

public func embedMessage(message: Message, elementId: String) {
delegate?.embedMessage(message: message, elementId: elementId)
}

func logMessageView(message: Message) {
// This function body reports metrics and makes sure that messages are not shown 2+ times.
// For inline messages, we have not yet implemented either of these features.
Expand Down
1 change: 0 additions & 1 deletion Sources/MessagingInApp/Gist/GistDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import UIKit

public protocol GistDelegate: AnyObject {
func embedMessage(message: Message, elementId: String)
func messageShown(message: Message)
func messageDismissed(message: Message)
func messageError(message: Message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class MessageQueueManagerImpl: MessageQueueManager {
DIGraphShared.shared.gist
}

private var eventBus: EventBusHandler {
DIGraphShared.shared.eventBusHandler
}

func getInterval() -> Double {
interval
}
Expand Down Expand Up @@ -118,6 +122,10 @@ class MessageQueueManagerImpl: MessageQueueManager {
for message in fetchedMessages {
handleMessage(message: message)
}

// Notify observers that a fetch has completed and the local queue has been modified.
// This is useful for inline Views that may need to display or dismiss messages.
eventBus.postEvent(InAppMessagesFetchedEvent())
}

private func handleMessage(message: Message) {
Expand Down
2 changes: 0 additions & 2 deletions Sources/MessagingInApp/MessagingInAppImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ class MessagingInAppImplementation: MessagingInAppInstance {
}

extension MessagingInAppImplementation: GistDelegate {
public func embedMessage(message: Message, elementId: String) {}

// Aka: message opened
public func messageShown(message: Message) {
logger.debug("in-app message opened. \(message.describeForLogs)")
Expand Down
17 changes: 17 additions & 0 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public class InAppMessageView: UIView {
DIGraphShared.shared.gist
}

private var eventBus: EventBusHandler {
DIGraphShared.shared.eventBusHandler
}

// Can set in the constructor or can set later (like if you use Storyboards)
public var elementId: String? {
didSet {
Expand Down Expand Up @@ -82,6 +86,14 @@ public class InAppMessageView: UIView {
heightConstraint.priority = .required
heightConstraint.isActive = true
layoutIfNeeded()

// Begin listening to the queue for new messages.
eventBus.addObserver(InAppMessagesFetchedEvent.self) { [weak self] _ in
// We are unsure what thread this code will run on. We want to ensure that the View is updated on the main thread.
Task { @MainActor in
self?.checkIfMessageAvailableToDisplay()
}
}
}

private func checkIfMessageAvailableToDisplay() {
Expand All @@ -94,6 +106,11 @@ public class InAppMessageView: UIView {
return // no messages to display, exit early. In the future we will dismiss the View.
}

// Do not re-show the existing message if already shown to prevent the UI from flickering as it loads the same message again.
if let currentlyShownMessage = inlineMessageManager?.currentMessage, currentlyShownMessage.messageId == messageToDisplay.messageId {
return // already showing this message, exit early.
}

displayInAppMessage(messageToDisplay)
}

Expand Down
11 changes: 11 additions & 0 deletions Tests/MessagingInApp/Gist/Managers/MessageQueueManagerTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ class MessageQueueManagerTest: UnitTest {
private var manager: MessageQueueManagerImpl!

private let gistMock = GistInstanceMock()
private let eventBusMock = EventBusHandlerMock()

override func setUp() {
super.setUp()

DIGraphShared.shared.override(value: gistMock, forType: GistInstance.self)
DIGraphShared.shared.override(value: eventBusMock, forType: EventBusHandler.self)

manager = MessageQueueManagerImpl()
}
Expand Down Expand Up @@ -89,6 +91,15 @@ class MessageQueueManagerTest: UnitTest {
XCTAssertEqual(modalMessageProcessed.count, 1)
XCTAssertTrue(inlineMessagesProcessed.isEmpty)
}

func test_processFetchedMessages_expectSendEventBusEventAfterProcessing() {
XCTAssertEqual(eventBusMock.postEventCallsCount, 0)

manager.processFetchedMessages([Message.randomInline])

XCTAssertEqual(eventBusMock.postEventCallsCount, 1)
XCTAssertTrue(eventBusMock.postEventArguments is InAppMessagesFetchedEvent)
}
}

extension MessageQueueManagerTest {
Expand Down
87 changes: 87 additions & 0 deletions Tests/MessagingInApp/Views/InAppMessageViewTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,81 @@ class InAppMessageViewTest: UnitTest {

XCTAssertNotNil(getInAppMessageWebView(fromInlineView: inlineView))
}

// MARK: Async fetching of in-app messages

// The in-app SDK fetches for new messages in the background in an async manner.
// We need to test that the View is updated when new messages are fetched.

func test_givenInAppMessageFetchedAfterViewConstructed_expectShowInAppMessageFetched() {
// start with no messages available.
queueMock.getInlineMessagesReturnValue = []

let view = InAppMessageView(elementId: .random)
XCTAssertNil(getInAppMessageWebView(fromInlineView: view))

// Modify queue to return a message after the UI has been constructed and not showing a WebView.
simulateSdkFetchedMessages([Message.random])

XCTAssertNotNil(getInAppMessageWebView(fromInlineView: view))
}

// Test that the eventbus listening does not impact memory management of the View instance.
func test_deinit_givenObservingEventBusEvent_expectNoMemoryLeaks() {
// Before we try to deinit the View, make sure the eventbus observer has executed at least once.
// This is important if the observer holds a strong reference to something preventing the View deinit.
let expectToCheckIfInAppMessagesAvailableToDisplay = expectation(description: "expect to check for in-app messages")
expectToCheckIfInAppMessagesAvailableToDisplay.expectedFulfillmentCount = 2 // once on View init() and once on observer action.
queueMock.getInlineMessagesClosure = { _ in
expectToCheckIfInAppMessagesAvailableToDisplay.fulfill()
return []
}

var view: InAppMessageView? = InAppMessageView(elementId: .random)

DIGraphShared.shared.eventBusHandler.postEvent(InAppMessagesFetchedEvent())

// Wait for the observer to be called.
waitForExpectations()

// Deinit the View and asert deinit actually cleared the instance.
view = nil
XCTAssertNil(view)
}

func test_givenAlreadyShowingInAppMessage_whenNewMessageFetched_expectShowNewMessage() {
let givenOldInlineMessage = Message.randomInline
queueMock.getInlineMessagesReturnValue = [givenOldInlineMessage]

let inlineView = InAppMessageView(elementId: givenOldInlineMessage.elementId!)
let webViewBeforeFetch = getInAppMessageWebView(fromInlineView: inlineView)

// Make sure message is unique, but has same elementId.
let givenNewInlineMessage = Message(messageId: .random, campaignId: .random, elementId: givenOldInlineMessage.elementId)

simulateSdkFetchedMessages([givenNewInlineMessage])

let webViewAfterFetch = getInAppMessageWebView(fromInlineView: inlineView)

// If the WebViews are different, it means the message was reloaded.
XCTAssertTrue(webViewBeforeFetch !== webViewAfterFetch)

Check failure on line 122 in Tests/MessagingInApp/Views/InAppMessageViewTest.swift

View workflow job for this annotation

GitHub Actions / automated-tests

test_givenAlreadyShowingInAppMessage_whenNewMessageFetched_expectShowNewMessage, XCTAssertTrue failed
}

func test_givenAlreadyShowingMessage_whenSameMessageFetched_expectDoNotReloadTheMessageAgain() {
let givenInlineMessage = Message.randomInline
queueMock.getInlineMessagesReturnValue = [givenInlineMessage]

let inlineView = InAppMessageView(elementId: givenInlineMessage.elementId!)

let webViewBeforeFetch = getInAppMessageWebView(fromInlineView: inlineView)

simulateSdkFetchedMessages([givenInlineMessage])

let webViewAfterFetch = getInAppMessageWebView(fromInlineView: inlineView)

// If the WebViews are the same instance, it means the message was not reloaded.
XCTAssertTrue(webViewBeforeFetch === webViewAfterFetch)
}
}

extension InAppMessageViewTest {
Expand All @@ -76,4 +151,16 @@ extension InAppMessageViewTest {

return gistViews.first
}

func simulateSdkFetchedMessages(_ messages: [Message]) {
// Because eventbus operations are async, use an expectation that waits until eventbus event is posted and observer is called.
let expectToCheckIfInAppMessagesAvailableToDisplay = expectation(description: "expect to check for in-app messages")
queueMock.getInlineMessagesClosure = { _ in
expectToCheckIfInAppMessagesAvailableToDisplay.fulfill()
return messages
}
// Imagine the in-app SDK has fetched new messages. It sends an event to the eventbus.
DIGraphShared.shared.eventBusHandler.postEvent(InAppMessagesFetchedEvent())
waitForExpectations()
}
}

0 comments on commit 69ef079

Please sign in to comment.