Skip to content

Commit

Permalink
chore: get WebView for inline message and add to Inline View to display
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

Create a WebView to display inline in-app message and display in the Inline UIView. To do this, we can re-use a lot of the same logic that Modal messages use to create WebViews and display them.

Testing:
There are some automated tests added in this commit.

Reviewer notes:
This commit will not show an in-app message if you were to add it to a sample app. Autolayout constraints need to be added to have Views resize and that will come in a future commit.

commit-id:025c7bf5
  • Loading branch information
levibostian committed May 22, 2024
1 parent 2635ed8 commit 4d63f69
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 40 deletions.
7 changes: 4 additions & 3 deletions Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import Foundation
import UIKit

protocol GistInstance: AutoMockable {
var siteId: String { get }
func showMessage(_ message: Message, position: MessagePosition) -> Bool
}

public class Gist: GistInstance, GistDelegate {
var messageQueueManager: MessageQueueManager = DIGraphShared.shared.messageQueueManager
var shownModalMessageQueueIds: Set<String> = [] // all modal messages that have been shown in the app already.
private var messageManagers: [MessageManager] = []
private var messageManagers: [ModalMessageManager] = []
public var siteId: String = ""
public var dataCenter: String = ""

Expand Down Expand Up @@ -144,8 +145,8 @@ public class Gist: GistInstance, GistDelegate {

// Message Manager

private func createMessageManager(siteId: String, message: Message) -> MessageManager {
let messageManager = MessageManager(siteId: siteId, message: message)
private func createMessageManager(siteId: String, message: Message) -> ModalMessageManager {
let messageManager = ModalMessageManager(siteId: siteId, message: message)
messageManager.delegate = self
messageManagers.append(messageManager)
return messageManager
Expand Down
119 changes: 83 additions & 36 deletions Sources/MessagingInApp/Gist/Managers/MessageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,81 @@ public enum GistMessageActions: String {
case close = "gist://close"
}

/**
Class that implements the business logic for a inline message being displayed.
*/
class InlineMessageManager: MessageManager {}

/**
Class that implements the business logic for a modal message being displayed.
*/
class ModalMessageManager: MessageManager {
private var messageLoaded = false
private var modalViewManager: ModalViewManager?
var messagePosition: MessagePosition = .top

override func routeLoaded(route: String) {
super.routeLoaded(route: route)

if route == currentMessage.messageId, !messageLoaded {
messageLoaded = true
if isMessageEmbed {
delegate?.messageShown(message: currentMessage)
} else {
if UIApplication.shared.applicationState == .active {
loadModalMessage()
} else {
Gist.shared.removeMessageManager(instanceId: currentMessage.instanceId)
}
}
}
}

func showMessage(position: MessagePosition) {
startLoadingMessage()
messagePosition = position
}

override func dismissMessage(completionHandler: (() -> Void)? = nil) {
if let modalViewManager = modalViewManager {
modalViewManager.dismissModalView { [weak self] in
guard let self = self else { return }
self.delegate?.messageDismissed(message: self.currentMessage)
completionHandler?()
}
}
}

private func loadModalMessage() {
if messageLoaded {
modalViewManager = ModalViewManager(gistView: gistView, position: messagePosition)
modalViewManager?.showModalView { [weak self] in
guard let self = self else { return }
self.delegate?.messageShown(message: self.currentMessage)
self.doneLoadingMessage()
}
}
}
}

/**
Class that handles a lot of the business logic for modal and inline in-app messages.

This class is meant to be extended and not constructed directly. It holds the common logic between all in-app message types.

Usage:
* When you have a Message that should be displayed, create a new instance of manager. You create 1 manager instance per 1 in-app message to display:
```
// Keep a strong reference to manager instance.
let messageManager = MessageManager(siteId: Gist.shared.siteId, message: message)
```
* Get the WebView instance that displays the in-app message: `messageManager.gistView`
* Set the delegate to listen for events from the WebView: `messageManager.gistView.delegate = self`
* Display the WebView in your view: `addSubview(messageManager.gistView)`
*/
class MessageManager: EngineWebDelegate {
private var engine: EngineWeb?
private let siteId: String
private var messagePosition: MessagePosition = .top
private var messageLoaded = false
private var modalViewManager: ModalViewManager?
var isMessageEmbed = false
let currentMessage: Message
var gistView: GistView!
Expand Down Expand Up @@ -39,35 +108,25 @@ class MessageManager: EngineWebDelegate {
}
}

func showMessage(position: MessagePosition) {
elapsedTimer.start(title: "Displaying modal for message: \(currentMessage.messageId)")
messagePosition = position
// MARK: Timer determining how long message took to load.

// The manager subclasses are expected to call these functions to determine how long the messages took to load.

func startLoadingMessage() {
elapsedTimer.start(title: "Loading message with id: \(currentMessage.messageId)")
}

func doneLoadingMessage() {
elapsedTimer.end()
}

func getMessageView() -> GistView {
isMessageEmbed = true
return gistView
}

private func loadModalMessage() {
if messageLoaded {
modalViewManager = ModalViewManager(gistView: gistView, position: messagePosition)
modalViewManager?.showModalView { [weak self] in
guard let self = self else { return }
self.delegate?.messageShown(message: self.currentMessage)
self.elapsedTimer.end()
}
}
}

func dismissMessage(completionHandler: (() -> Void)? = nil) {
if let modalViewManager = modalViewManager {
modalViewManager.dismissModalView { [weak self] in
guard let self = self else { return }
self.delegate?.messageDismissed(message: self.currentMessage)
completionHandler?()
}
}
// expect subclass implements this.
}

func removePersistentMessage() {
Expand Down Expand Up @@ -208,18 +267,6 @@ class MessageManager: EngineWebDelegate {
Logger.instance.info(message: "Message loaded with route: \(route)")

currentRoute = route
if route == currentMessage.messageId, !messageLoaded {
messageLoaded = true
if isMessageEmbed {
delegate?.messageShown(message: currentMessage)
} else {
if UIApplication.shared.applicationState == .active {
loadModalMessage()
} else {
Gist.shared.removeMessageManager(instanceId: currentMessage.instanceId)
}
}
}
}

deinit {
Expand Down
46 changes: 45 additions & 1 deletion Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,19 @@ public class InAppMessageView: UIView {
DIGraphShared.shared.messageQueueManager
}

private var gist: GistInstance {
DIGraphShared.shared.gist
}

// Can set in the constructor or can set later (like if you use Storyboards)
public var elementId: String? {
didSet {
checkIfMessageAvailableToDisplay()
}
}

private var inlineMessageManager: InlineMessageManager?

public init(elementId: String) {
super.init(frame: .zero)
self.elementId = elementId
Expand Down Expand Up @@ -65,5 +71,43 @@ public class InAppMessageView: UIView {
// }
}

private func displayInAppMessage(_ message: Message) {}
private func displayInAppMessage(_ message: Message) {
guard inlineMessageManager == nil else {
// We are already displaying a messsage. In the future, we are planning on swapping the web content if there is another message in the local queue to display
// and an inline message is dismissed. Until we add this feature, exit early.
return
}

/** Code commented out to be used for reference when we implement replacing of 1 message with another */
// There might already be a message displayed. If so, remove it and cleanup the resources.
// First, remove the subview that the inline manager has a reference to.
// Lastly, cleanup the inline manager.
// subviews.first?.removeFromSuperview()
// inlineMessageManager = nil

// Create a new manager for this new message to display and then display the manager's WebView.
let newInlineMessageManager = InlineMessageManager(siteId: gist.siteId, message: message)
newInlineMessageManager.startLoadingMessage()

guard let webView = newInlineMessageManager.gistView else {
return // we dont expect this to happen, but better to handle it gracefully instead of force unwrapping
}

webView.delegate = self
addSubview(webView)

inlineMessageManager = newInlineMessageManager
}
}

extension InAppMessageView: GistViewDelegate {
// This function is called by WebView when the content's size changes.
public func sizeChanged(message: Message, width: CGFloat, height: CGFloat) {
// When this function is called, it's an indicator that the web content has been loaded into the WebView. Report to the manager that we are done loading the message.
inlineMessageManager?.doneLoadingMessage()

// In a future commit, we will change the height of the View to display the web content.
}

public func action(message: Message, currentRoute: String, action: String, name: String) {}
}
38 changes: 38 additions & 0 deletions Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,45 @@ class GistInstanceMock: GistInstance, Mock {
Mocks.shared.add(mock: self)
}

/**
When setter of the property called, the value given to setter is set here.
When the getter of the property called, the value set here will be returned. Your chance to mock the property.
*/
var underlyingSiteId: String!
/// `true` if the getter or setter of property is called at least once.
var siteIdCalled: Bool {
siteIdGetCalled || siteIdSetCalled
}

/// `true` if the getter called on the property at least once.
var siteIdGetCalled: Bool {
siteIdGetCallsCount > 0
}

var siteIdGetCallsCount = 0
/// `true` if the setter called on the property at least once.
var siteIdSetCalled: Bool {
siteIdSetCallsCount > 0
}

var siteIdSetCallsCount = 0
/// The mocked property with a getter and setter.
var siteId: String {
get {
mockCalled = true
siteIdGetCallsCount += 1
return underlyingSiteId
}
set(value) {
mockCalled = true
siteIdSetCallsCount += 1
underlyingSiteId = value
}
}

public func resetMock() {
siteIdGetCallsCount = 0
siteIdSetCallsCount = 0
showMessageCallsCount = 0
showMessageReceivedArguments = nil
showMessageReceivedInvocations = []
Expand Down
33 changes: 33 additions & 0 deletions Tests/MessagingInApp/Views/InAppMessageViewTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,37 @@ class InAppMessageViewTest: UnitTest {
let actualElementId = queueMock.getInlineMessagesReceivedArguments
XCTAssertEqual(actualElementId, givenElementId)
}

// MARK: Display in-app message

func test_displayInAppMessage_givenNoMessageAvailable_expectDoNotDisplayAMessage() {
queueMock.getInlineMessagesReturnValue = []

let inlineView = InAppMessageView(elementId: .random)

XCTAssertNil(getInAppMessageWebView(fromInlineView: inlineView))
}

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

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

XCTAssertNotNil(getInAppMessageWebView(fromInlineView: inlineView))
}
}

extension InAppMessageViewTest {
func getInAppMessageWebView(fromInlineView view: InAppMessageView) -> GistView? {
let gistViews: [GistView] = view.subviews.map { $0 as? GistView }.mapNonNil()

if gistViews.isEmpty {
return nil
}

XCTAssertEqual(gistViews.count, 1)

return gistViews.first
}
}

0 comments on commit 4d63f69

Please sign in to comment.