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 17, 2024
1 parent 7498c5d commit cc11137
Show file tree
Hide file tree
Showing 5 changed files with 186 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 shownMessageQueueIds: Set<String> = []
private var messageManagers: [MessageManager] = []
private var messageManagers: [ModalMessageManager] = []
public var siteId: String = ""
public var dataCenter: String = ""

Expand Down Expand Up @@ -137,8 +138,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
115 changes: 79 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)

Check warning on line 22 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L21-L22

Added lines #L21 - L22 were not covered by tests

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)

Check warning on line 32 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L24-L32

Added lines #L24 - L32 were not covered by tests
}
}
}
}

func showMessage(position: MessagePosition) {
loadingMessage()
messagePosition = position

Check warning on line 40 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L38-L40

Added lines #L38 - L40 were not covered by tests
}

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?()

Check warning on line 48 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L43-L48

Added lines #L43 - L48 were not covered by tests
}
}
}

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()

Check warning on line 59 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L53-L59

Added lines #L53 - L59 were not covered by tests
}
}
}
}

/**
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,21 @@ class MessageManager: EngineWebDelegate {
}
}

func showMessage(position: MessagePosition) {
elapsedTimer.start(title: "Displaying modal for message: \(currentMessage.messageId)")
messagePosition = position
func loadingMessage() {
elapsedTimer.start(title: "Loading message with id: \(currentMessage.messageId)")

Check warning on line 112 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L111-L112

Added lines #L111 - L112 were not covered by tests
}

func doneLoadingMessage() {
elapsedTimer.end()

Check warning on line 116 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L115-L116

Added lines #L115 - L116 were not covered by tests
}

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.

Check warning on line 125 in Sources/MessagingInApp/Gist/Managers/MessageManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageManager.swift#L125

Added line #L125 was not covered by tests
}

func removePersistentMessage() {
Expand Down Expand Up @@ -208,18 +263,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
33 changes: 32 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,30 @@ public class InAppMessageView: UIView {
// }
}

private func displayInAppMessage(_ message: Message) {}
private func displayInAppMessage(_ message: Message) {
// 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)

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

Check warning on line 85 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L85

Added line #L85 was not covered by tests
}

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) {}

Check warning on line 97 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L97

Added line #L97 was not covered by tests

public func action(message: Message, currentRoute: String, action: String, name: String) {}

Check warning on line 99 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L99

Added line #L99 was not covered by tests
}
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.filter { $0 is GistView }.map { $0 as! GistView }

if gistViews.isEmpty {
return nil
}

XCTAssertEqual(gistViews.count, 1)

return gistViews.first
}
}

0 comments on commit cc11137

Please sign in to comment.