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 24, 2024
1 parent 490593e commit a7c9c1f
Show file tree
Hide file tree
Showing 11 changed files with 738 additions and 51 deletions.
39 changes: 33 additions & 6 deletions Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import CioInternalCommon
import Foundation
import UIKit

public class Gist: GistDelegate {
var messageQueueManager = MessageQueueManager()
var shownMessageQueueIds: Set<String> = []
protocol GistInstance: AutoMockable {
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] = []
public var siteId: String = ""
public var dataCenter: String = ""
Expand All @@ -22,7 +27,7 @@ public class Gist: GistDelegate {
self.siteId = siteId
self.dataCenter = dataCenter
Logger.instance.enabled = logging
messageQueueManager.setup()
messageQueueManager.setup(skipQueueCheck: false)

// Initialising Gist web with an empty message to fetch fonts and other assets.
_ = Gist.shared.getMessageView(Message(messageId: ""))
Expand Down Expand Up @@ -56,7 +61,7 @@ public class Gist: GistDelegate {

// MARK: Message Actions

public func showMessage(_ message: Message, position: MessagePosition = .center) -> Bool {
public 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 @@ -113,9 +122,16 @@ public class Gist: GistDelegate {
}

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.
// Therefore, if the message is not a modal, exit early.
guard message.isModalMessage else {
return

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L125-L129

Added lines #L125 - L129 were not covered by tests
}

messageQueueManager.removeMessageFromLocalStore(message: message)
if let queueId = message.queueId {
shownMessageQueueIds.insert(queueId)
shownModalMessageQueueIds.insert(queueId)

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L134

Added line #L134 was not covered by tests
}
let userToken = UserManager().getUserToken()
LogManager(siteId: siteId, dataCenter: dataCenter)
Expand Down Expand Up @@ -147,3 +163,14 @@ public class Gist: GistDelegate {
messageManagers.removeAll(where: { $0.currentMessage.instanceId == instanceId })
}
}

// 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 174 in Sources/MessagingInApp/Gist/Gist.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Gist.swift#L174

Added line #L174 was not covered by tests
}
}
89 changes: 52 additions & 37 deletions Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import CioInternalCommon
import Foundation
import UIKit

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

// sourcery: InjectRegisterShared = "MessageQueueManager"
class MessageQueueManagerImpl: MessageQueueManager {
@Atomic 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] = [:]

func setup(skipQueueCheck: Bool = false) {
// The local message store is used to keep messages that can't be displayed because the route rule doesnt match and inline messages.
@Atomic var localMessageStore: [String: Message] = [:]

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

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

Expand All @@ -30,21 +46,8 @@ class MessageQueueManager {

func fetchUserMessagesFromLocalStore() {
Logger.instance.info(message: "Checking local store with \(localMessageStore.count) messages")
let sortedMessages = localMessageStore.sorted {
switch ($0.value.priority, $1.value.priority) {
case (let priority0?, let priority1?):
// Both messages have a priority, so we compare them.
return priority0 < priority1
case (nil, _):
// The first message has no priority, it should be considered greater so that it ends up at the end of the sorted array.
return false
case (_, nil):
// The second message has no priority, the first message should be ordered first.
return true
}
}
sortedMessages.forEach { message in
handleMessage(message: message.value)
localMessageStore.map(\.value).sortByMessagePriority().forEach { message in
handleMessage(message: message)

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L50 was not covered by tests
}
}

Expand All @@ -59,7 +62,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).sortByMessagePriority()
}

func addMessageToLocalStore(message: Message) {
guard let queueId = message.queueId else {
return
}
Expand All @@ -80,13 +87,8 @@ class MessageQueueManager {
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 91 in Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift

View check run for this annotation

Codecov / codecov/patch

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

Added line #L91 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,9 +101,28 @@ 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) {
if message.isInlineMessage {
// 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)

return
}

// Rest of logic of function is for Modal messages

// Skip showing Modal messages if already shown.
if let queueId = message.queueId, Gist.shared.shownModalMessageQueueIds.contains(queueId) {
Logger.instance.info(message: "Message with queueId: \(queueId) already shown, skipping.")
return
}
Expand All @@ -123,12 +144,6 @@ 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)
return
} else {
_ = Gist.shared.showMessage(message, position: position)
}
_ = gist.showMessage(message, position: position)
}
}
39 changes: 37 additions & 2 deletions Sources/MessagingInApp/Gist/Managers/Models/Message.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ public class GistProperties {

public class Message {
public private(set) var instanceId = UUID().uuidString.lowercased()
public let queueId: String?
public let queueId: String? // used to uniquely identify an in-app message.
public let priority: Int?
public let messageId: String
public let messageId: String // the messageId refers to the template used to render. For non-HTML messages, that messageId is something like "welcome-demo"
public private(set) var gistProperties: GistProperties

var properties = [String: Any]()
Expand Down Expand Up @@ -92,3 +92,38 @@ 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
}
}

// Messages come with a priority used to determine the order of which messages should show in the app.
// Given a list of Messages that could be displayed, sort them by priority (lower values have higher priority).
extension Array where Element == Message {
func sortByMessagePriority() -> [Message] {
sorted {
switch ($0.priority, $1.priority) {
case (let priority0?, let priority1?):
// Both messages have a priority, so we compare them.
return priority0 < priority1
case (nil, _):
// The first message has no priority, it should be considered greater so that it ends up at the end of the sorted array.
return false
case (_, nil):
// The second message has no priority, the first message should be ordered first.
return true

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Gist/Managers/Models/Message.swift#L124-L125

Added lines #L124 - L125 were not covered by tests
}
}
}
}
68 changes: 68 additions & 0 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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() {
// next, configure the View such as setting the position and size. This will come in a future change.
}

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 58 in Sources/MessagingInApp/Views/InAppMessageView.swift

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L58

Added line #L58 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.
}

// next, display the message

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

View check run for this annotation

Codecov / codecov/patch

Sources/MessagingInApp/Views/InAppMessageView.swift#L66

Added line #L66 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 a7c9c1f

Please sign in to comment.