Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add AutoLayout constraints to make inline messages visible in UIKit app #718

Merged
merged 3 commits into from
Jun 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 187 additions & 142 deletions Apps/APN-UIKit/APN UIKit/Base.lproj/Main.storyboard

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions Apps/APN-UIKit/APN UIKit/View/DashboardViewController.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CioDataPipelines
import CioMessagingInApp
import UIKit

class DashboardViewController: BaseViewController {
Expand All @@ -15,6 +16,9 @@ class DashboardViewController: BaseViewController {
@IBOutlet var versionsLabel: UILabel!
@IBOutlet var userInfoLabel: UILabel!
@IBOutlet var settings: UIImageView!
@IBOutlet var inlineInAppViewCreatedInStoryboard: InAppMessageView!
@IBOutlet var buttonStackView: UIStackView!

var dashboardRouter: DashboardRouting?
var notificationUtil = DIGraphShared.shared.notificationUtil
var storage = DIGraphShared.shared.storage
Expand All @@ -28,6 +32,23 @@ class DashboardViewController: BaseViewController {

override func viewDidLoad() {
super.viewDidLoad()

// 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
inlineInAppViewCreatedInStoryboard.elementId = "dashboard-announcement"

// We want to test that Inline Views can be used by customers who prefer to use code to make the UI.
// Construct a new instance of the View, add it to the ViewController, then set constraints to make it visible.
let newInlineViewUsingUIAsCode = InAppMessageView(elementId: "dashboard-announcement-code")
// Because the Dashboard screen contains a lot of Views and it's designed using Storyboard, we are
// adding this inline View into the UI by adding to a StackView. This allows us to dyanamically add to the Dashboard screen without complexity or breaking any of the constraints set in Storyboard.
buttonStackView.addArrangedSubview(newInlineViewUsingUIAsCode)

// Customers are responsible for setting the width of the View.
newInlineViewUsingUIAsCode.translatesAutoresizingMaskIntoConstraints = false
newInlineViewUsingUIAsCode.widthAnchor.constraint(equalTo: buttonStackView.widthAnchor).isActive = true
}

configureDashboardRouter()
addNotifierObserver()
addUserInteractionToImageViews()
Expand Down
5 changes: 4 additions & 1 deletion Sources/MessagingInApp/Gist/Gist.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ protocol GistInstance: AutoMockable {
}

public class Gist: GistInstance, GistDelegate {
var messageQueueManager: MessageQueueManager = DIGraphShared.shared.messageQueueManager
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed because MessageQueueManager is a singleton now. Use a property getter to get the shared singleton instance.

var shownModalMessageQueueIds: Set<String> = [] // all modal messages that have been shown in the app already.
var messageQueueManager: MessageQueueManager {
DIGraphShared.shared.messageQueueManager
}

private var messageManagers: [ModalMessageManager] = []
public var siteId: String = ""
public var dataCenter: String = ""
Expand Down
14 changes: 12 additions & 2 deletions Sources/MessagingInApp/Gist/Managers/MessageQueueManager.swift
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because queue manager stores data in-memory, I had to make it a singleton. All changes in this commit are to make it a singleton.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import Foundation
import UIKit

protocol MessageQueueManager: AutoMockable {
var interval: Double { get set }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I do prefer to use property getter/setters for an API, I found this API to be safer with having the class be a singleton.

Here is some sample code with what I mean:

// If you use a property getter/setter API:
let queueManager: MessageQueueManager = ... get singleton instance 
queueManager.interval = 500
// 🤔 Did we update the shared singleton `inventory` property? Or did we update the `queueManager` local variable, only? 

// if we change to getter/setter functions:
MessageQueueManager.singletonInstance.setInventory(500)
// This is much more clear that we are editing the singleton, not a locally scoped instance. 

func getInterval() -> Double
func setInterval(_ newInterval: Double)
func setup(skipQueueCheck: Bool)
func fetchUserMessagesFromLocalStore()
func removeMessageFromLocalStore(message: Message)
Expand All @@ -12,8 +13,9 @@ protocol MessageQueueManager: AutoMockable {
}

// sourcery: InjectRegisterShared = "MessageQueueManager"
// sourcery: InjectSingleton
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class has always been used as a singleton. The whole SDK would use Gist.messageQueue. To make the code testable, I switched to instead make the class a singleton in the digraph as we do for all other code in the SDK.

class MessageQueueManagerImpl: MessageQueueManager {
@Atomic var interval: Double = 600
@Atomic private 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 and inline messages.
Expand All @@ -23,6 +25,14 @@ class MessageQueueManagerImpl: MessageQueueManager {
DIGraphShared.shared.gist
}

func getInterval() -> Double {
interval
}

func setInterval(_ newInterval: Double) {
interval = newInterval
}

func setup(skipQueueCheck: Bool) {
queueTimer?.invalidate()
queueTimer = nil
Expand Down
7 changes: 4 additions & 3 deletions Sources/MessagingInApp/Gist/Managers/QueueManager.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CioInternalCommon
import Foundation

class QueueManager {
Expand Down Expand Up @@ -55,10 +56,10 @@ class QueueManager {
private func updatePollingInterval(headers: [AnyHashable: Any]) {
if let newPollingIntervalString = headers["x-gist-queue-polling-interval"] as? String,
let newPollingInterval = Double(newPollingIntervalString),
newPollingInterval != Gist.shared.messageQueueManager.interval {
newPollingInterval != DIGraphShared.shared.messageQueueManager.getInterval() {
DispatchQueue.main.async {
Gist.shared.messageQueueManager.interval = newPollingInterval
Gist.shared.messageQueueManager.setup(skipQueueCheck: true)
DIGraphShared.shared.messageQueueManager.setInterval(newPollingInterval)
DIGraphShared.shared.messageQueueManager.setup(skipQueueCheck: true)
Logger.instance.info(message: "Polling interval changed to: \(newPollingInterval) seconds")
}
}
Expand Down
44 changes: 40 additions & 4 deletions Sources/MessagingInApp/Views/InAppMessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ public class InAppMessageView: UIView {
}
}

var heightConstraint: NSLayoutConstraint!

// Get the View's current height or change the height by setting a new value.
private var viewHeight: CGFloat {
get {
heightConstraint.constant
}
set {
heightConstraint.constant = newValue
layoutIfNeeded()
}
}

private var inlineMessageManager: InlineMessageManager?

public init(elementId: String) {
Expand All @@ -54,12 +67,24 @@ public class InAppMessageView: UIView {
}

private func setupView() {
// next, configure the View such as setting the position and size. This will come in a future change.
// Remove any existing height constraints added by customer.
// This is required as only 1 height constraint can be active at a time. Our height constraint will be ignored
// if we do not do this.
for existingViewConstraint in constraints where existingViewConstraint.firstAnchor == heightAnchor {
existingViewConstraint.isActive = false
}

// Create a view constraint for the height of the View.
// This allows us to dynamically update the height at a later time.
//
// Set the initial height of the view to 0 so it's not visible.
heightConstraint = heightAnchor.constraint(equalToConstant: 0)
heightConstraint.priority = .required
heightConstraint.isActive = true
layoutIfNeeded()
}

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
}
Expand Down Expand Up @@ -88,13 +113,24 @@ public class InAppMessageView: UIView {
}
addSubview(inlineView)

// Setup the WebView to be the same size as this View. When this View changes size, the WebView will change, too.
inlineView.translatesAutoresizingMaskIntoConstraints = false // Required in order for this inline View to have full control over the AutoLayout constraints for the WebView.
NSLayoutConstraint.activate([
inlineView.topAnchor.constraint(equalTo: topAnchor),
inlineView.leadingAnchor.constraint(equalTo: leadingAnchor),
inlineView.trailingAnchor.constraint(equalTo: trailingAnchor),
inlineView.bottomAnchor.constraint(equalTo: bottomAnchor)
])

inlineMessageManager = newInlineMessageManager
}
}

extension InAppMessageView: InlineMessageManagerDelegate {
// This function is called by WebView when the content's size changes.
public func sizeChanged(width: CGFloat, height: CGFloat) {
// In a future commit, we will change the height of the View to display the web content.
// We keep the width the same to what the customer set it as.
// Update the height to match the aspect ratio of the web content.
viewHeight = height
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,27 @@ extension DIGraphShared {
GistInAppProvider()
}

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

private var newMessageQueueManager: MessageQueueManager {
var sharedMessageQueueManager: MessageQueueManager {
// Use a DispatchQueue to make singleton thread safe. You must create unique dispatchqueues instead of using 1 shared one or you will get a crash when trying
// to call DispatchQueue.sync{} while already inside another DispatchQueue.sync{} call.
DispatchQueue(label: "DIGraphShared_MessageQueueManager_singleton_access").sync {
if let overridenDep: MessageQueueManager = getOverriddenInstance() {
return overridenDep
}
let existingSingletonInstance = self.singletons[String(describing: MessageQueueManager.self)] as? MessageQueueManager
let instance = existingSingletonInstance ?? _get_messageQueueManager()
self.singletons[String(describing: MessageQueueManager.self)] = instance
return instance
}
}

private func _get_messageQueueManager() -> MessageQueueManager {
MessageQueueManagerImpl()
}
}
Expand Down
96 changes: 59 additions & 37 deletions Sources/MessagingInApp/autogenerated/AutoMockable.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -498,45 +498,15 @@ class MessageQueueManagerMock: MessageQueueManager, 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 underlyingInterval: Double!
/// `true` if the getter or setter of property is called at least once.
var intervalCalled: Bool {
intervalGetCalled || intervalSetCalled
}

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

var intervalGetCallsCount = 0
/// `true` if the setter called on the property at least once.
var intervalSetCalled: Bool {
intervalSetCallsCount > 0
}
public func resetMock() {
getIntervalCallsCount = 0

var intervalSetCallsCount = 0
/// The mocked property with a getter and setter.
var interval: Double {
get {
mockCalled = true
intervalGetCallsCount += 1
return underlyingInterval
}
set(value) {
mockCalled = true
intervalSetCallsCount += 1
underlyingInterval = value
}
}
mockCalled = false // do last as resetting properties above can make this true
setIntervalCallsCount = 0
setIntervalReceivedArguments = nil
setIntervalReceivedInvocations = []

public func resetMock() {
intervalGetCallsCount = 0
intervalSetCallsCount = 0
mockCalled = false // do last as resetting properties above can make this true
setupCallsCount = 0
setupReceivedArguments = nil
setupReceivedInvocations = []
Expand All @@ -560,6 +530,58 @@ class MessageQueueManagerMock: MessageQueueManager, Mock {
mockCalled = false // do last as resetting properties above can make this true
}

// MARK: - getInterval

/// Number of times the function was called.
@Atomic private(set) var getIntervalCallsCount = 0
/// `true` if the function was ever called.
var getIntervalCalled: Bool {
getIntervalCallsCount > 0
}

/// Value to return from the mocked function.
var getIntervalReturnValue: Double!
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
The closure has first priority to return a value for the mocked function. If the closure returns `nil`,
then the mock will attempt to return the value for `getIntervalReturnValue`
*/
var getIntervalClosure: (() -> Double)?

/// Mocked function for `getInterval()`. Your opportunity to return a mocked value and check result of mock in test code.
func getInterval() -> Double {
mockCalled = true
getIntervalCallsCount += 1
return getIntervalClosure.map { $0() } ?? getIntervalReturnValue
}

// MARK: - setInterval

/// Number of times the function was called.
@Atomic private(set) var setIntervalCallsCount = 0
/// `true` if the function was ever called.
var setIntervalCalled: Bool {
setIntervalCallsCount > 0
}

/// The arguments from the *last* time the function was called.
@Atomic private(set) var setIntervalReceivedArguments: Double?
/// Arguments from *all* of the times that the function was called.
@Atomic private(set) var setIntervalReceivedInvocations: [Double] = []
/**
Set closure to get called when function gets called. Great way to test logic or return a value for the function.
*/
var setIntervalClosure: ((Double) -> Void)?

/// Mocked function for `setInterval(_ newInterval: Double)`. Your opportunity to return a mocked value and check result of mock in test code.
func setInterval(_ newInterval: Double) {
mockCalled = true
setIntervalCallsCount += 1
setIntervalReceivedArguments = newInterval
setIntervalReceivedInvocations.append(newInterval)
setIntervalClosure?(newInterval)
}

// MARK: - setup

/// Number of times the function was called.
Expand Down
Loading