Skip to content
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
6 changes: 6 additions & 0 deletions Sources/PayPalMessages/Analytics/AnalyticsLogger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ class AnalyticsLogger: Encodable {

var instanceId: String

// Language returned by the server in the message response
var languageRendered: String?

// Includes things like fdata, experience IDs, debug IDs, and the like
var dynamicData: [String: AnyCodable] = [:]

Expand Down Expand Up @@ -48,6 +51,7 @@ class AnalyticsLogger: Encodable {
case buyerCountryCode = "buyer_country_code"
case channel = "presentment_channel"
case languageRequested = "language_requested"
case languageRendered = "language_rendered"
// Message Only
case styleLogoType = "style_logo_type"
case styleColor = "style_color"
Expand Down Expand Up @@ -79,10 +83,12 @@ class AnalyticsLogger: Encodable {
try container.encodeIfPresent(message.logoType.rawValue, forKey: .styleLogoType)
try container.encodeIfPresent(message.color.rawValue, forKey: .styleColor)
try container.encodeIfPresent(message.textAlign.rawValue, forKey: .styleTextAlign)
// Language requested by the merchant via locale/language config
let languageRequested = message.locale?.replacingOccurrences(of: "_", with: "-")
?? message.language
?? "undefined"
try container.encodeIfPresent(languageRequested, forKey: .languageRequested)
try container.encodeIfPresent(languageRendered ?? "undefined", forKey: .languageRendered)

case .modal(let weakModal):
guard let modal = weakModal.value else { return }
Expand Down
10 changes: 10 additions & 0 deletions Sources/PayPalMessages/IO/MessageResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ struct MessageResponse: Decodable {

let logoPlaceholder: String

let language: String?

let trackingData: [String: AnyCodable]

var requestDuration: TimeInterval?
Expand All @@ -48,6 +50,7 @@ struct MessageResponse: Decodable {
modalCloseButtonColor: String,
modalCloseButtonColorType: String,
modalCloseButtonAlternativeText: String,
language: String? = nil,
trackingData: [String: AnyCodable] = [:]
) {
self.offerType = offerType
Expand All @@ -66,6 +69,7 @@ struct MessageResponse: Decodable {
self.modalCloseButtonColor = modalCloseButtonColor
self.modalCloseButtonColorType = modalCloseButtonColorType
self.modalCloseButtonAlternativeText = modalCloseButtonAlternativeText
self.language = language
self.trackingData = trackingData
}

Expand Down Expand Up @@ -135,6 +139,11 @@ struct MessageResponse: Decodable {
forKey: .creditProductGroup
)

language = try metaContainer.decodeIfPresent(
String.self,
forKey: .language
)

// MARK: - Variable Container

let variableContainer = try metaContainer.nestedContainer(
Expand Down Expand Up @@ -219,6 +228,7 @@ struct MessageResponse: Decodable {
enum MetaKeys: String, CodingKey {
case offerType = "offer_type"
case creditProductGroup = "credit_product_group"
case language
case modalCloseButton = "modal_close_button"
case variables
case trackingKeys = "tracking_keys"
Expand Down
5 changes: 3 additions & 2 deletions Sources/PayPalMessages/PayPalMessageViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ class PayPalMessageViewModel: PayPalMessageModalEventDelegate {

// MARK: - Private Properties
/// used to avoid property update related requests from being executed when there's a config requesting a fetch
private var fetchMessageContentPending = false
var fetchMessageContentPending = false

/// Config update queue debounce time interval
private let queueTimeInterval: TimeInterval = 0.001
Expand Down Expand Up @@ -199,7 +199,7 @@ class PayPalMessageViewModel: PayPalMessageModalEventDelegate {
}

/// When the message is being fetch from a Property update, it considers whether an update is not being currently executed or requested
private func queueMessageContentUpdate(requiresFetch: Bool = true, fireImmediately: Bool = false) {
func queueMessageContentUpdate(requiresFetch: Bool = true, fireImmediately: Bool = false) {
renderStart = Date()

if requiresFetch {
Expand Down Expand Up @@ -284,6 +284,7 @@ class PayPalMessageViewModel: PayPalMessageModalEventDelegate {
private func onMessageRequestReceived(response: MessageResponse) {
messageResponse = response
logger.dynamicData = response.trackingData
logger.languageRendered = response.language

if let stateDelegate, let messageView {
stateDelegate.onSuccess(messageView)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ final class PayPalMessageAttributedStringBuilder {
attributedText.append(makeSpaceAttributedString(2))
}

// main message
attributedText.append(makeMainMessageAttributedString(parameters))
// main message with bold formatting
attributedText.append(makeMainMessageAttributedStringWithBold(parameters))

// if logo has a custom location, search for placeholder and replace
if !parameters.shouldDisplayLeadingLogo {
Expand All @@ -62,6 +62,58 @@ final class PayPalMessageAttributedStringBuilder {

return attributedText
}
/// Returns the main message attributed string, applying bold formatting to substrings wrapped in %bold%...%bold%.
func makeMainMessageAttributedStringWithBold(_ parameters: PayPalMessageViewParameters) -> NSAttributedString {
let message = parameters.message
let baseFont = getDynamicTypeFont(for: .systemFont(ofSize: Constants.fontSize))
let boldFont = getDynamicTypeFont(for: .boldSystemFont(ofSize: Constants.fontSize))
let color = parameters.messageColor

let result = NSMutableAttributedString()
var currentIndex = message.startIndex

while let boldStart = message.range(of: "%bold%", range: currentIndex..<message.endIndex) {
// Add text before bold marker
let beforeBold = String(message[currentIndex..<boldStart.lowerBound])
if !beforeBold.isEmpty {
let normalAttr = NSAttributedString(string: beforeBold, attributes: [
.font: baseFont,
.foregroundColor: color
])
result.append(normalAttr)
}
// Find end marker
let afterBoldStart = boldStart.upperBound
if let boldEnd = message.range(of: "%bold%", range: afterBoldStart..<message.endIndex) {
let boldText = String(message[afterBoldStart..<boldEnd.lowerBound])
let boldAttr = NSAttributedString(string: boldText, attributes: [
.font: boldFont,
.foregroundColor: color
])
result.append(boldAttr)
currentIndex = boldEnd.upperBound
} else {
// No end marker, treat rest as normal
let rest = String(message[afterBoldStart..<message.endIndex])
let normalAttr = NSAttributedString(string: rest, attributes: [
.font: baseFont,
.foregroundColor: color
])
result.append(normalAttr)
break
}
}
// Add any remaining text after last bold marker
if currentIndex < message.endIndex {
let remaining = String(message[currentIndex..<message.endIndex])
let normalAttr = NSAttributedString(string: remaining, attributes: [
.font: baseFont,
.foregroundColor: color
])
result.append(normalAttr)
}
return result
}

// MARK: - Private Helpers

Expand Down
27 changes: 27 additions & 0 deletions Tests/PayPalMessagesTests/PayPalMessageAttributedStringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,31 @@ final class PayPalMessageAttributedStringTests: XCTestCase {
let length = attributedString.string.count
XCTAssertFalse(attributedString.containsAttachments(in: NSRange(location: 0, length: length)))
}

func testBoldFormattingInMessage() {
let message = "Normal %bold%BoldText%bold% NormalEnd"
let params = buildParameters()
let paramsWithBold = PayPalMessageViewParameters(
message: message,
messageColor: .black,
shouldDisplayLeadingLogo: params.shouldDisplayLeadingLogo,
logoPlaceholder: params.logoPlaceholder,
logoImage: params.logoImage,
productName: params.productName,
linkDescription: params.linkDescription,
linkColor: params.linkColor,
linkUnderlineColor: params.linkUnderlineColor,
textAlign: params.textAlign,
accessibilityLabel: params.accessibilityLabel,
accessibilityTraits: params.accessibilityTraits,
isAccessibilityElement: params.isAccessibilityElement
)
let attributed = stringBuilder.makeMainMessageAttributedStringWithBold(paramsWithBold)
let string = attributed.string
XCTAssertTrue(string.contains("BoldText"))
let range = (string as NSString).range(of: "BoldText")
let font = attributed.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont
XCTAssertNotNil(font)
XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.traitBold) ?? false)
}
}
39 changes: 32 additions & 7 deletions Tests/PayPalMessagesTests/PayPalMessageLoggerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
"style_color": "black",
"style_text_align": "left",
"language_requested": "undefined",
"language_rendered": "undefined",
"component_events": [
[
"event_type": "message_rendered",
Expand Down Expand Up @@ -248,6 +249,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
"style_color": "black",
"style_text_align": "left",
"language_requested": "undefined",
"language_rendered": "undefined",
"component_events": [
[
"event_type": "message_rendered",
Expand Down Expand Up @@ -317,6 +319,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
"style_color": "black",
"style_text_align": "left",
"language_requested": "undefined",
"language_rendered": "undefined",
"component_events": [
[
"event_type": "message_rendered",
Expand Down Expand Up @@ -375,6 +378,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
"style_color": "black",
"style_text_align": "left",
"language_requested": "undefined",
"language_rendered": "undefined",
"component_events": [
[
"event_type": "message_clicked",
Expand Down Expand Up @@ -442,6 +446,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
"style_color": "black",
"style_text_align": "left",
"language_requested": "undefined",
"language_rendered": "undefined",
"component_events": [
[
"event_type": "message_rendered",
Expand Down Expand Up @@ -490,33 +495,53 @@ final class PayPalMessageLoggerTests: XCTestCase {
XCTAssert(clientID1 == "testloggerclientid3" || clientID2 == "testloggerclientid3")
}

// Language Requested Tests
// Language Tests

func testLanguageRequestedWithLocale() {
message.locale = "en_US"
XCTAssertEqual(getLanguageRequested(), "en-US")
XCTAssertEqual(getLanguageField(key: "language_requested"), "en-US")
}

func testLanguageRequestedWithLanguageOnly() {
message.language = "fr-CA"
XCTAssertEqual(getLanguageRequested(), "fr-CA")
XCTAssertEqual(getLanguageField(key: "language_requested"), "fr-CA")
}

func testLanguageRequestedPreferesLocaleOverLanguage() {
message.language = "en-US"
message.locale = "fr_CA"
XCTAssertEqual(getLanguageRequested(), "fr-CA")
XCTAssertEqual(getLanguageField(key: "language_requested"), "fr-CA")
}

func testLanguageRequestedDefaultsToUndefined() {
XCTAssertEqual(getLanguageRequested(), "undefined")
XCTAssertEqual(getLanguageField(key: "language_requested"), "undefined")
}

private func getLanguageRequested() -> String? {
func testLanguageRenderedFromResponse() {
let messageLogger = AnalyticsLogger(.message(Weak(message)))
messageLogger.languageRendered = "en-CA"
messageLogger.addEvent(.messageRender(renderDuration: 10, requestDuration: 15))
AnalyticsService.shared.flushEvents()

XCTAssertEqual(getLanguageField(key: "language_rendered", logger: messageLogger), "en-CA")
}

func testLanguageRenderedWhenNil() {
let messageLogger = AnalyticsLogger(.message(Weak(message)))
messageLogger.languageRendered = nil
messageLogger.addEvent(.messageRender(renderDuration: 10, requestDuration: 15))
AnalyticsService.shared.flushEvents()

XCTAssertEqual(getLanguageField(key: "language_rendered", logger: messageLogger), "undefined")
}

private func getLanguageField(key: String, logger: AnalyticsLogger? = nil) -> String? {
let messageLogger = logger ?? AnalyticsLogger(.message(Weak(message)))
if logger == nil {
messageLogger.addEvent(.messageRender(renderDuration: 10, requestDuration: 15))
AnalyticsService.shared.flushEvents()
}

guard let data = mockSender.calls.last,
let jsonData = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let loggerData = jsonData["data"] as? [String: Any],
Expand All @@ -526,7 +551,7 @@ final class PayPalMessageLoggerTests: XCTestCase {
return nil
}

return firstComponent["language_requested"] as? String
return firstComponent[key] as? String
}

// MARK: - Helper assert functions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,14 @@ final class PayPalMessageModalViewModelTests: XCTestCase {
XCTAssertEqual(eventDelegate.onClickData?.linkSrc, "Apply Now Src")
}

func testFlushUpdatesUpdatesWebViewProps() {
let (viewModel, webView, _, _) = makePayPalMessageModalViewModel()
// Simulate update
viewModel.flushUpdates()
// Check that evaluateJavaScript was called
XCTAssertTrue(webView.evaluateJavaScriptCalled)
}

private func makePayPalMessageModalViewModel(
config: PayPalMessageModalConfig = PayPalMessageModalConfig(data: .init(clientID: "testclientid", environment: .live))
) -> ( // swiftlint:disable:this large_tuple
Expand Down
Loading
Loading