Skip to content

Commit f92d058

Browse files
BernardGattBernard Gattclaude
authored
feat(in-app): add dark mode support for in-app messages (#1131)
Co-authored-by: Bernard Gatt <bernard@customer.io> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0c5d492 commit f92d058

18 files changed

Lines changed: 294 additions & 21 deletions

Sources/MessagingInApp/Config/MessagingInAppConfigBuilder.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class MessagingInAppConfigBuilder {
1818
// configuration options for MessagingInAppConfigOptions
1919
private let siteId: String
2020
private let region: Region
21+
private var colorScheme: ColorScheme = .auto
2122

2223
/// Initializes new `MessagingInAppConfigBuilder` with required configuration options.
2324
/// - Parameters:
@@ -28,11 +29,18 @@ public class MessagingInAppConfigBuilder {
2829
self.region = region
2930
}
3031

32+
@discardableResult
33+
public func setColorScheme(_ colorScheme: ColorScheme) -> MessagingInAppConfigBuilder {
34+
self.colorScheme = colorScheme
35+
return self
36+
}
37+
3138
/// Builds and returns `MessagingInAppConfigOptions` instance from the configured properties.
3239
public func build() -> MessagingInAppConfigOptions {
3340
MessagingInAppConfigOptions(
3441
siteId: siteId,
35-
region: region
42+
region: region,
43+
colorScheme: colorScheme
3644
)
3745
}
3846
}
@@ -74,6 +82,16 @@ public extension MessagingInAppConfigBuilder {
7482
let regionStr = sdkConfig[Keys.region.rawValue] as? String ?? ""
7583
let region = Region.getRegion(from: regionStr)
7684

77-
return MessagingInAppConfigBuilder(siteId: siteId, region: region).build()
85+
let builder = MessagingInAppConfigBuilder(siteId: siteId, region: region)
86+
87+
if let colorSchemeStr = config["colorScheme"] as? String {
88+
switch colorSchemeStr {
89+
case "light": builder.setColorScheme(.light)
90+
case "dark": builder.setColorScheme(.dark)
91+
default: builder.setColorScheme(.auto)
92+
}
93+
}
94+
95+
return builder.build()
7896
}
7997
}

Sources/MessagingInApp/Config/MessagingInAppConfigOptions.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ import CioInternalCommon
66
public struct MessagingInAppConfigOptions {
77
public let siteId: String
88
public let region: Region
9+
public let colorScheme: ColorScheme
910
}

Sources/MessagingInApp/Gist/EngineWeb/EngineWeb.swift

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ protocol EngineWebInstance: AutoMockable {
1717
var delegate: EngineWebDelegate? { get set }
1818
var view: UIView { get }
1919
func cleanEngineWeb()
20+
func updateColorScheme(_ scheme: String)
2021
}
2122

2223
public class EngineWeb: NSObject, EngineWebInstance {
@@ -34,7 +35,8 @@ public class EngineWeb: NSObject, EngineWebInstance {
3435
webView
3536
}
3637

37-
private let currentConfiguration: EngineWebConfiguration
38+
private var currentConfiguration: EngineWebConfiguration
39+
private let colorSchemeMode: ColorScheme
3840

3941
public private(set) var currentRoute: String {
4042
get { _currentRoute }
@@ -45,6 +47,7 @@ public class EngineWeb: NSObject, EngineWebInstance {
4547
init(configuration: EngineWebConfiguration, state: InAppMessageState, message: Message) {
4648
self.currentMessage = message
4749
self.currentConfiguration = configuration
50+
self.colorSchemeMode = state.colorScheme
4851

4952
super.init()
5053

@@ -92,6 +95,30 @@ public class EngineWeb: NSObject, EngineWebInstance {
9295
}
9396
}
9497

98+
public func updateColorScheme(_ scheme: String) {
99+
applyInterfaceStyle(scheme)
100+
101+
do {
102+
let jsonData = try JSONEncoder().encode(["action": "updateColorScheme", "colorScheme": scheme])
103+
guard let jsonString = String(data: jsonData, encoding: .utf8) else { return }
104+
let js = "window.postMessage(\(jsonString), '*');"
105+
webView.evaluateJavaScript(js, completionHandler: nil)
106+
} catch {
107+
logger.logWithModuleTag("Failed to encode color scheme update: \(error)", level: .error)
108+
}
109+
}
110+
111+
private func applyInterfaceStyle(_ scheme: String) {
112+
switch scheme {
113+
case "dark":
114+
webView.overrideUserInterfaceStyle = .dark
115+
case "light":
116+
webView.overrideUserInterfaceStyle = .light
117+
default:
118+
webView.overrideUserInterfaceStyle = .unspecified
119+
}
120+
}
121+
95122
public func cleanEngineWeb() {
96123
_timeoutTimer?.invalidate()
97124
_timeoutTimer = nil
@@ -159,6 +186,17 @@ extension EngineWeb: WKScriptMessageHandler {
159186
// swiftlint:enable cyclomatic_complexity
160187
extension EngineWeb: WKNavigationDelegate {
161188
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
189+
let resolvedScheme = colorSchemeMode.resolve(with: webView.traitCollection)
190+
applyInterfaceStyle(resolvedScheme)
191+
currentConfiguration = EngineWebConfiguration(
192+
siteId: currentConfiguration.siteId,
193+
dataCenter: currentConfiguration.dataCenter,
194+
instanceId: currentConfiguration.instanceId,
195+
endpoint: currentConfiguration.endpoint,
196+
messageId: currentConfiguration.messageId,
197+
properties: currentConfiguration.properties,
198+
colorScheme: resolvedScheme
199+
)
162200
injectConfiguration(currentConfiguration)
163201
}
164202

Sources/MessagingInApp/Gist/EngineWeb/EngineWebConfiguration.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,23 @@ struct EngineWebConfiguration: Encodable {
88
let messageId: String
99
let livePreview: Bool = false
1010
let properties: [String: AnyEncodable?]?
11+
let colorScheme: String?
1112

1213
init(
1314
siteId: String,
1415
dataCenter: String,
1516
instanceId: String,
1617
endpoint: String,
1718
messageId: String,
18-
properties: [String: AnyEncodable?]?
19+
properties: [String: AnyEncodable?]?,
20+
colorScheme: String? = nil
1921
) {
2022
self.siteId = siteId
2123
self.dataCenter = dataCenter
2224
self.instanceId = instanceId
2325
self.endpoint = endpoint
2426
self.messageId = messageId
2527
self.properties = properties
28+
self.colorScheme = colorScheme
2629
}
2730
}

Sources/MessagingInApp/Gist/Managers/MessageManager.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ open class BaseMessageManager {
2323
var engine: EngineWebInstance!
2424
var gistView: GistView!
2525
var deeplinkUtil: DeepLinkUtil
26+
private var lastResolvedColorScheme: String?
27+
private var colorSchemeSubscriber: InAppMessageStoreSubscriber?
2628

2729
// MARK: - Initializers
2830

@@ -47,13 +49,15 @@ open class BaseMessageManager {
4749
self.deeplinkUtil = diGraph.deepLinkUtil
4850

4951
// Create engine
52+
let resolvedColorScheme = MessagingInAppImplementation.currentColorScheme.resolve(with: UITraitCollection.current)
5053
let engineWebConfiguration = EngineWebConfiguration(
5154
siteId: state.siteId,
5255
dataCenter: state.dataCenter,
5356
instanceId: message.instanceId,
5457
endpoint: state.environment.networkSettings.engineAPI,
5558
messageId: message.messageId,
56-
properties: message.properties.mapValues { AnyEncodable($0) }
59+
properties: message.properties.mapValues { AnyEncodable($0) },
60+
colorScheme: resolvedColorScheme
5761
)
5862
self.engine = engineWebProvider.getEngineWebInstance(
5963
configuration: engineWebConfiguration,
@@ -64,12 +68,51 @@ open class BaseMessageManager {
6468

6569
// Create GistView
6670
self.gistView = GistView(message: currentMessage, engineView: engine.view)
71+
self.lastResolvedColorScheme = resolvedColorScheme
6772

6873
// Set delegate and subscribe
6974
engine.delegate = self
75+
76+
// Subscribe to runtime colorScheme changes
77+
subscribeToColorSchemeChanges()
78+
79+
// Handle system trait collection changes (dark mode toggle)
80+
gistView.onTraitCollectionChange = { [weak self] traitCollection in
81+
self?.handleTraitCollectionChange(traitCollection)
82+
}
83+
}
84+
85+
private func subscribeToColorSchemeChanges() {
86+
colorSchemeSubscriber = {
87+
let subscriber = InAppMessageStoreSubscriber { [weak self] state in
88+
guard let self else { return }
89+
let resolved = state.colorScheme.resolve(with: UITraitCollection.current)
90+
if resolved != self.lastResolvedColorScheme {
91+
self.lastResolvedColorScheme = resolved
92+
self.threadUtil.runMain {
93+
self.engine.updateColorScheme(resolved)
94+
}
95+
}
96+
}
97+
self.inAppMessageManager.subscribe(keyPath: \.colorScheme, subscriber: subscriber)
98+
return subscriber
99+
}()
100+
}
101+
102+
private func handleTraitCollectionChange(_ traitCollection: UITraitCollection) {
103+
let resolved = MessagingInAppImplementation.currentColorScheme.resolve(with: traitCollection)
104+
if resolved != lastResolvedColorScheme {
105+
lastResolvedColorScheme = resolved
106+
threadUtil.runMain { [weak self] in
107+
self?.engine.updateColorScheme(resolved)
108+
}
109+
}
70110
}
71111

72112
deinit {
113+
if let subscriber = colorSchemeSubscriber {
114+
inAppMessageManager.unsubscribe(subscriber: subscriber)
115+
}
73116
removeEngineWebView()
74117
}
75118

Sources/MessagingInApp/Gist/Managers/ModalMessageManager.swift

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import UIKit
55
public class ModalMessageManager: BaseMessageManager {
66
private var modalViewManager: ModalViewManager?
77
var inAppMessageStoreSubscriber: InAppMessageStoreSubscriber?
8+
private var colorSchemeSubscriber: InAppMessageStoreSubscriber?
9+
810
override init(state: InAppMessageState, message: Message) {
911
super.init(state: state, message: message)
1012
subscribeToInAppMessageState()
13+
subscribeToColorSchemeChanges()
1114
}
1215

1316
deinit {
@@ -22,9 +25,10 @@ public class ModalMessageManager: BaseMessageManager {
2225
let messageState = state.modalMessageState
2326
switch messageState {
2427
case .displayed:
28+
let colorScheme = MessagingInAppImplementation.currentColorScheme
2529
threadUtil.runMain {
2630
// Subclasses (Modal or Inline) can show differently
27-
self.onMessageDisplayed()
31+
self.onMessageDisplayed(colorScheme: colorScheme)
2832
}
2933
case .dismissed, .initial:
3034
threadUtil.runMain {
@@ -41,7 +45,7 @@ public class ModalMessageManager: BaseMessageManager {
4145
}
4246

4347
// Show the modal when the message is displayed
44-
func onMessageDisplayed() {
48+
func onMessageDisplayed(colorScheme: ColorScheme = .auto) {
4549
guard isMessageLoaded else {
4650
logger.logWithModuleTag(
4751
"Message not loaded yet. Skipping loadModalMessage for \(currentMessage.describeForLogs).",
@@ -62,7 +66,8 @@ public class ModalMessageManager: BaseMessageManager {
6266
modalViewManager = ModalViewManager(
6367
gistView: gistView,
6468
position: gistProperties.position,
65-
overlayColor: gistProperties.overlayColor
69+
overlayColor: gistProperties.overlayColor,
70+
colorScheme: colorScheme
6671
)
6772
// Show the modal with an optional completion
6873
modalViewManager?.showModalView { [weak self] in
@@ -93,11 +98,30 @@ public class ModalMessageManager: BaseMessageManager {
9398
modalViewManager.dismissModalView(completionHandler: dismissalHandler)
9499
}
95100

101+
private func subscribeToColorSchemeChanges() {
102+
colorSchemeSubscriber = {
103+
let subscriber = InAppMessageStoreSubscriber { [weak self] _ in
104+
guard let self else { return }
105+
let colorScheme = MessagingInAppImplementation.currentColorScheme
106+
self.threadUtil.runMain {
107+
self.modalViewManager?.updateColorScheme(colorScheme)
108+
}
109+
}
110+
self.inAppMessageManager.subscribe(keyPath: \.colorScheme, subscriber: subscriber)
111+
return subscriber
112+
}()
113+
}
114+
96115
open func unsubscribeFromInAppMessageState() {
97-
guard let subscriber = inAppMessageStoreSubscriber else { return }
98-
logger.logWithModuleTag("Unsubscribing BaseMessageManager from InAppMessageState", level: .debug)
99-
inAppMessageManager.unsubscribe(subscriber: subscriber)
100-
inAppMessageStoreSubscriber = nil
116+
if let subscriber = inAppMessageStoreSubscriber {
117+
logger.logWithModuleTag("Unsubscribing BaseMessageManager from InAppMessageState", level: .debug)
118+
inAppMessageManager.unsubscribe(subscriber: subscriber)
119+
inAppMessageStoreSubscriber = nil
120+
}
121+
if let subscriber = colorSchemeSubscriber {
122+
inAppMessageManager.unsubscribe(subscriber: subscriber)
123+
colorSchemeSubscriber = nil
124+
}
101125
}
102126

103127
private func finishDismissal(messageState: ModalMessageState) {

Sources/MessagingInApp/Gist/Managers/ModalViewManager.swift

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@ class ModalViewManager {
1111
var viewController: GistModalViewController!
1212
var position: MessagePosition
1313
var overlayColor: String?
14+
var colorScheme: ColorScheme
1415

15-
init(gistView: GistView, position: MessagePosition, overlayColor: String?) {
16+
init(gistView: GistView, position: MessagePosition, overlayColor: String?, colorScheme: ColorScheme = .auto) {
1617
self.viewController = GistModalViewController()
1718
viewController.gistView = gistView
1819
viewController.setup(position: position)
1920
self.position = position
2021
self.overlayColor = overlayColor
22+
self.colorScheme = colorScheme
2123
}
2224

2325
func showModalView(completionHandler: @escaping () -> Void) {
2426
viewController.view.isHidden = true
2527
window = getUIWindow()
28+
applyColorSchemeToWindow()
2629
window?.rootViewController = viewController
2730
window?.isHidden = false
2831
var finalPosition: CGFloat = 0
@@ -83,6 +86,36 @@ class ModalViewManager {
8386
window = nil
8487
}
8588

89+
func updateColorScheme(_ newColorScheme: ColorScheme) {
90+
colorScheme = newColorScheme
91+
applyColorSchemeToWindow()
92+
}
93+
94+
private func applyColorSchemeToWindow() {
95+
switch colorScheme {
96+
case .light:
97+
window?.overrideUserInterfaceStyle = .light
98+
case .dark:
99+
window?.overrideUserInterfaceStyle = .dark
100+
case .auto:
101+
window?.overrideUserInterfaceStyle = .unspecified
102+
inheritAppInterfaceStyle()
103+
}
104+
}
105+
106+
private func inheritAppInterfaceStyle() {
107+
guard let appWindow = UIApplication.shared.connectedScenes
108+
.compactMap({ $0 as? UIWindowScene })
109+
.flatMap(\.windows)
110+
.first(where: { $0 !== self.window }) else { return }
111+
112+
if appWindow.overrideUserInterfaceStyle != .unspecified {
113+
window?.overrideUserInterfaceStyle = appWindow.overrideUserInterfaceStyle
114+
} else if let rootVC = appWindow.rootViewController, rootVC.overrideUserInterfaceStyle != .unspecified {
115+
window?.overrideUserInterfaceStyle = rootVC.overrideUserInterfaceStyle
116+
}
117+
}
118+
86119
private func getUIWindow() -> UIWindow {
87120
var modalWindow = UIWindow(frame: UIScreen.main.bounds)
88121
if #available(iOS 13.0, *) {

Sources/MessagingInApp/Gist/Views/GistView.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ public protocol GistViewLifecycleDelegate: AnyObject {
1616
public class GistView: UIView {
1717
public weak var delegate: GistViewDelegate?
1818
public weak var lifecycleDelegate: GistViewLifecycleDelegate?
19+
var onTraitCollectionChange: ((UITraitCollection) -> Void)?
1920
var message: Message?
2021

2122
convenience init(message: Message, engineView: UIView) {
@@ -25,6 +26,13 @@ public class GistView: UIView {
2526
engineView.autoresizingMask = [.flexibleWidth, .flexibleHeight, .flexibleBottomMargin, .flexibleRightMargin]
2627
}
2728

29+
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
30+
super.traitCollectionDidChange(previousTraitCollection)
31+
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
32+
onTraitCollectionChange?(traitCollection)
33+
}
34+
}
35+
2836
override public func removeFromSuperview() {
2937
super.removeFromSuperview()
3038

0 commit comments

Comments
 (0)