Skip to content

Commit

Permalink
chore: create inline UIView that checks if there are inline messages …
Browse files Browse the repository at this point in the history
…in local queue

Part of: https://linear.app/customerio/issue/MBL-310/create-a-uikit-uiview-that-displays-an-inline-in-app-message-sent-from

Create a new public UIView that customers can add to their app's UI to display inline Views. This commit does not display messages yet. It only checks if there is an in-app message available to display inline with the equal elementId.

Testing:
Automated tests added in the commit. Some boilerplate code was added to allow mocking some dependencies and testing functions that were previously not very testable.

Reviewer notes:
This commit only handles the use case of inline Views being able to display inline messages after a fetch has already completed in the SDK. Future changes will handle when an inline View is visible and a fetch completes.

commit-id:60639329
  • Loading branch information
levibostian committed May 17, 2024
1 parent 490593e commit 930f336
Show file tree
Hide file tree
Showing 11 changed files with 699 additions and 26 deletions.
32 changes: 29 additions & 3 deletions Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import CioInternalCommon
import Foundation
import UIKit

public class Gist: GistDelegate {
var messageQueueManager = MessageQueueManager()
protocol GistInstance: AutoMockable {
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] = []
public var siteId: String = ""
Expand Down Expand Up @@ -56,7 +61,7 @@ public class Gist: GistDelegate {

// MARK: Message Actions

public func showMessage(_ message: Message, position: MessagePosition = .center) -> Bool {
func showMessage(_ message: Message, position: MessagePosition) -> Bool {

Check warning on line 64 in Sources/MessagingInApp/Gist/Gist.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L64

Added line #L64 was not covered by tests
if let messageManager = getModalMessageManager() {
Logger.instance.info(message: "Message cannot be displayed, \(messageManager.currentMessage.messageId) is being displayed.")
} else {
Expand All @@ -67,6 +72,10 @@ public class Gist: GistDelegate {
return false
}

public func showMessage(_ message: Message) -> Bool {
showMessage(message, position: .center)

Check warning on line 76 in Sources/MessagingInApp/Gist/Gist.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L75-L76

Added lines #L75 - L76 were not covered by tests
}

public func getMessageView(_ message: Message) -> GistView {
let messageManager = createMessageManager(siteId: siteId, message: message)
return messageManager.getMessageView()
Expand Down Expand Up @@ -146,4 +155,21 @@ public class Gist: GistDelegate {
func removeMessageManager(instanceId: String) {
messageManagers.removeAll(where: { $0.currentMessage.instanceId == instanceId })
}

// Inline messages

func getInlineMessages(forElementId elementId: String) -> [Message] {
messageQueueManager.getInlineMessages(forElementId: elementId)

Check warning on line 162 in Sources/MessagingInApp/Gist/Gist.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L161-L162

Added lines #L161 - L162 were not covered by tests
}
}

// Convenient way for other modules to access instance as well as being able to mock instance in tests.
extension DIGraphShared {
var gist: GistInstance {
if let override: GistInstance = getOverriddenInstance() {
return override
}

return Gist.shared

Check warning on line 173 in Sources/MessagingInApp/Gist/Gist.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L173

Added line #L173 was not covered by tests
}
}
71 changes: 55 additions & 16 deletions Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import CioInternalCommon
import Foundation
import UIKit

class MessageQueueManager {
protocol MessageQueueManager: AutoMockable {
var interval: Double { get set }
func setup()
// sourcery:Name=setupSkipQueueCheck
// sourcery:DuplicateMethod=setup
func setup(skipQueueCheck: Bool)
func fetchUserMessagesFromLocalStore()
func removeMessageFromLocalStore(message: Message)
func clearUserMessagesFromLocalStore()
func getInlineMessages(forElementId elementId: String) -> [Message]
}

// sourcery: InjectRegisterShared = "MessageQueueManager"
class MessageQueueManagerImpl: MessageQueueManager {
var interval: Double = 600
private var queueTimer: Timer?
// The local message store is used to keep messages that can't be displayed because the route rule doesnt match.
private var localMessageStore: [String: Message] = [:]
var localMessageStore: [String: Message] = [:]
private var gist: GistInstance {
DIGraphShared.shared.gist
}

func setup() {
setup(skipQueueCheck: false)
}

func setup(skipQueueCheck: Bool = false) {
func setup(skipQueueCheck: Bool) {
queueTimer?.invalidate()
queueTimer = nil

Expand Down Expand Up @@ -59,7 +80,11 @@ class MessageQueueManager {
localMessageStore.removeValue(forKey: queueId)
}

private func addMessageToLocalStore(message: Message) {
func getInlineMessages(forElementId elementId: String) -> [Message] {
localMessageStore.filter { $0.value.elementId == elementId }.map(\.value)
}

func addMessageToLocalStore(message: Message) {
guard let queueId = message.queueId else {
return
}
Expand All @@ -72,21 +97,16 @@ class MessageQueueManager {
Logger.instance.info(message: "Checking Gist queue service")
if let userToken = UserManager().getUserToken() {
QueueManager(siteId: Gist.shared.siteId, dataCenter: Gist.shared.dataCenter)
.fetchUserQueue(userToken: userToken, completionHandler: { response in
.fetchUserQueue(userToken: userToken, completionHandler: { [weak self] response in

Check warning on line 100 in Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L100

Added line #L100 was not covered by tests
switch response {
case .success(nil):
Logger.instance.info(message: "No changes to remote queue")
case .success(let responses):
guard let responses else {
return
}
// To prevent us from showing expired / revoked messages, clear user messages from local queue.
self.clearUserMessagesFromLocalStore()
Logger.instance.info(message: "Gist queue service found \(responses.count) new messages")
for queueMessage in responses {
let message = queueMessage.toMessage()
self.handleMessage(message: message)
}

self?.processFetchedMessages(responses.map { $0.toMessage() })

Check warning on line 109 in Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift#L109

Added line #L109 was not covered by tests
case .failure(let error):
Logger.instance.error(message: "Error fetching messages from Gist queue service. \(error.localizedDescription)")
}
Expand All @@ -99,6 +119,15 @@ class MessageQueueManager {
}
}

func processFetchedMessages(_ fetchedMessages: [Message]) {
// To prevent us from showing expired / revoked messages, clear user messages from local queue.
clearUserMessagesFromLocalStore()
Logger.instance.info(message: "Gist queue service found \(fetchedMessages.count) new messages")
for message in fetchedMessages {
handleMessage(message: message)
}
}

private func handleMessage(message: Message) {
// Skip shown messages
if let queueId = message.queueId, Gist.shared.shownMessageQueueIds.contains(queueId) {
Expand All @@ -123,12 +152,22 @@ class MessageQueueManager {
}
}

if let elementId = message.gistProperties.elementId {
Logger.instance.info(message: "Embedding message with Element Id \(elementId)")
Gist.shared.embedMessage(message: message, elementId: elementId)
if message.isInlineMessage {
// If message has an element id, it's meant to be shown inline.
// Inline Views show inline messages by getting messages stored in the local queue on device.
// So, add the message to the local store and when inline Views are constructed, they will check the store.

addMessageToLocalStore(message: message)

// In a future PR, we will want to notify all currently visible inline Views that new messages are available in local store.
//
// At that time, we may decide we do not need these lines anymore. Keeping them in until we implement this notify piece.
// Logger.instance.info(message: "Found a message meant to be shown inline. Element Id \(elementId)")
// Gist.shared.embedMessage(message: message, elementId: elementId)

return
} else {
_ = Gist.shared.showMessage(message, position: position)
_ = gist.showMessage(message, position: position)
}
}
}
17 changes: 16 additions & 1 deletion Sources/MessagingInApp/Gist/Managers/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public class GistProperties {

public class Message {
public private(set) var instanceId = UUID().uuidString.lowercased()
public let queueId: String?
public let queueId: String? // uniquely identifies an in-app message in the backend system
public let priority: Int?
public let messageId: String
public private(set) var gistProperties: GistProperties
Expand Down Expand Up @@ -92,3 +92,18 @@ public class Message {
return engineRoute
}
}

// Convenient functions for Inline message feature
extension Message {
var elementId: String? {
gistProperties.elementId
}

var isInlineMessage: Bool {
elementId != nil
}

var isModalMessage: Bool {
!isInlineMessage
}
}
69 changes: 69 additions & 0 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import CioInternalCommon
import Foundation
import UIKit

/**
View that can be added to a customer's app UI to display inline in-app messages.

Usage:
1. Create an instance of this View and add it to the customer's app UI.
```
// you can construct an instance with code:
let inAppMessageView = InAppMessageView(elementId: "elementId")
view.addSubView(inAppMessageView)

// Or, if you use Storyboards:
@IBOutlet weak var inAppMessageView: InAppMessageView!
inAppMessageView.elementId = "elementId"
```
2. Position and set size of the View in app's UI. The View will adjust it's height automatically, but all other constraints are the responsibilty of app developer. You can set a height constraint if you want autolayout warnings to go away but know that the View will ignore this set height.
*/
public class InAppMessageView: UIView {
private var localMessageQueue: MessageQueueManager {
DIGraphShared.shared.messageQueueManager
}

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

public init(elementId: String) {
super.init(frame: .zero)
self.elementId = elementId

// Setup the View and display a message, if one available. Since an elementId has been set.
setupView()
checkIfMessageAvailableToDisplay()
}

// This is called when the View is created from a Storyboard.
required init?(coder: NSCoder) {
super.init(coder: coder)

setupView()
// An element id will not be set yet. No need to check for messages to display.
}

private func setupView() {}

private func checkIfMessageAvailableToDisplay() {
// 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
guard let elementId = elementId else {
return

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L56

Added line #L56 was not covered by tests
}

let queueOfMessagesForGivenElementId = localMessageQueue.getInlineMessages(forElementId: elementId)
guard let messageToDisplay = queueOfMessagesForGivenElementId.first else {
return // no messages to display, exit early. In the future we will dismiss the View.
}

displayInAppMessage(messageToDisplay)
// }

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L64-L65

Added lines #L64 - L65 were not covered by tests
}

private func displayInAppMessage(_ message: Message) {}

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L68

Added line #L68 was not covered by tests
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ extension DIGraphShared {
_ = inAppProvider
countDependenciesResolved += 1

_ = messageQueueManager
countDependenciesResolved += 1

return countDependenciesResolved
}

Expand All @@ -70,6 +73,16 @@ extension DIGraphShared {
private var newInAppProvider: InAppProvider {
GistInAppProvider()
}

// MessageQueueManager
var messageQueueManager: MessageQueueManager {
getOverriddenInstance() ??
newMessageQueueManager
}

private var newMessageQueueManager: MessageQueueManager {
MessageQueueManagerImpl()
}
}

// swiftlint:enable all
Loading

0 comments on commit 930f336

Please sign in to comment.