From 4d63f69655e1d8888dc8bf12588e5e625d7ea1d5 Mon Sep 17 00:00:00 2001 From: Levi Bostian Date: Mon, 20 May 2024 09:13:45 -0500 Subject: [PATCH] chore: get WebView for inline message and add to Inline View to display 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 --- Sources/MessagingInApp/Gist/Gist.swift | 7 +- .../Gist/Managers/MessageManager.swift | 119 ++++++++++++------ .../Views/InAppMessageView.swift | 46 ++++++- .../AutoMockable.generated.swift | 38 ++++++ .../Views/InAppMessageViewTest.swift | 33 +++++ 5 files changed, 203 insertions(+), 40 deletions(-) diff --git a/Sources/MessagingInApp/Gist/Gist.swift b/Sources/MessagingInApp/Gist/Gist.swift index c18d86bcc..24b033067 100644 --- a/Sources/MessagingInApp/Gist/Gist.swift +++ b/Sources/MessagingInApp/Gist/Gist.swift @@ -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 = [] // 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 = "" @@ -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 diff --git a/Sources/MessagingInApp/Gist/Managers/MessageManager.swift b/Sources/MessagingInApp/Gist/Managers/MessageManager.swift index 9de96ca75..8791fb59d 100644 --- a/Sources/MessagingInApp/Gist/Managers/MessageManager.swift +++ b/Sources/MessagingInApp/Gist/Managers/MessageManager.swift @@ -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! @@ -39,9 +108,16 @@ 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 { @@ -49,25 +125,8 @@ class MessageManager: EngineWebDelegate { 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() { @@ -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 { diff --git a/Sources/MessagingInApp/Views/InAppMessageView.swift b/Sources/MessagingInApp/Views/InAppMessageView.swift index e7f4b6680..0d0d58a25 100644 --- a/Sources/MessagingInApp/Views/InAppMessageView.swift +++ b/Sources/MessagingInApp/Views/InAppMessageView.swift @@ -23,6 +23,10 @@ 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 { @@ -30,6 +34,8 @@ public class InAppMessageView: UIView { } } + private var inlineMessageManager: InlineMessageManager? + public init(elementId: String) { super.init(frame: .zero) self.elementId = elementId @@ -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) {} } diff --git a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift index 4369abbfd..5c6ca190f 100644 --- a/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift +++ b/Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift @@ -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 = [] diff --git a/Tests/MessagingInApp/Views/InAppMessageViewTest.swift b/Tests/MessagingInApp/Views/InAppMessageViewTest.swift index 5887e5a2b..98297f5aa 100644 --- a/Tests/MessagingInApp/Views/InAppMessageViewTest.swift +++ b/Tests/MessagingInApp/Views/InAppMessageViewTest.swift @@ -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 + } }