Skip to content

Commit

Permalink
Merge pull request #766 from DataDog/marcosaia/RUM-7801/ios-newarch-t…
Browse files Browse the repository at this point in the history
…ext-props-resolver

[RUM-7801] iOS: Session Replay Text Recording in New Architecture
  • Loading branch information
marco-saia-datadog authored Jan 17, 2025
2 parents ecea3fe + 1320845 commit 917bdcb
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 60 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,24 @@ public class DdSessionReplayImplementation: NSObject {
private lazy var sessionReplay: SessionReplayProtocol = sessionReplayProvider()
private let sessionReplayProvider: () -> SessionReplayProtocol
private let uiManager: RCTUIManager
private let fabricWrapper: RCTFabricWrapper

internal init(sessionReplayProvider: @escaping () -> SessionReplayProtocol, uiManager: RCTUIManager) {
internal init(
sessionReplayProvider: @escaping () -> SessionReplayProtocol,
uiManager: RCTUIManager,
fabricWrapper: RCTFabricWrapper
) {
self.sessionReplayProvider = sessionReplayProvider
self.uiManager = uiManager
self.fabricWrapper = fabricWrapper
}

@objc
public convenience init(bridge: RCTBridge) {
self.init(
sessionReplayProvider: { NativeSessionReplay() },
uiManager: bridge.uiManager
uiManager: bridge.uiManager,
fabricWrapper: RCTFabricWrapper()
)
}

Expand All @@ -44,6 +51,7 @@ public class DdSessionReplayImplementation: NSObject {
if (customEndpoint != "") {
customEndpointURL = URL(string: "\(customEndpoint)/api/v2/replay" as String)
}

var sessionReplayConfiguration = SessionReplay.Configuration(
replaySampleRate: Float(replaySampleRate),
textAndInputPrivacyLevel: convertTextAndInputPrivacy(textAndInputPrivacyLevel),
Expand All @@ -53,7 +61,9 @@ public class DdSessionReplayImplementation: NSObject {
customEndpoint: customEndpointURL
)

sessionReplayConfiguration.setAdditionalNodeRecorders([RCTTextViewRecorder(uiManager: self.uiManager)])
sessionReplayConfiguration.setAdditionalNodeRecorders([
RCTTextViewRecorder(uiManager: uiManager, fabricWrapper: fabricWrapper)
])

if let core = DatadogSDKWrapper.shared.getCoreInstance() {
sessionReplay.enable(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
#import <Foundation/Foundation.h>
#import "RCTTextPropertiesWrapper.h"

@interface RCTFabricWrapper : NSObject

- (nullable RCTTextPropertiesWrapper*)tryToExtractTextPropertiesFromView:(UIView* _Nonnull)view;

@end
106 changes: 106 additions & 0 deletions packages/react-native-session-replay/ios/Sources/RCTFabricWrapper.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

#import "RCTFabricWrapper.h"

#if RCT_NEW_ARCH_ENABLED
#import <React-RCTFabric/React/RCTParagraphComponentView.h>
#import <React-Fabric/react/renderer/components/text/ParagraphProps.h>
namespace rct = facebook::react;
#endif

@implementation RCTFabricWrapper
/**
* Extracts the text properties from the given UIView when the view is of type RCTParagraphComponentView, returns nil otherwise.
*/
- (nullable RCTTextPropertiesWrapper*)tryToExtractTextPropertiesFromView:(UIView *)view {
#if RCT_NEW_ARCH_ENABLED
if (![view isKindOfClass:[RCTParagraphComponentView class]]) {
return nil;
}

// Cast view to RCTParagraphComponentView
RCTParagraphComponentView* paragraphComponentView = (RCTParagraphComponentView *)view;
if (paragraphComponentView == nil) {
return nil;
}

// Retrieve ParagraphProps from shared pointer
const rct::ParagraphProps* props = (rct::ParagraphProps*)paragraphComponentView.props.get();
if (props == nil) {
return nil;
}

// Extract Attributes
RCTTextPropertiesWrapper* textPropertiesWrapper = [[RCTTextPropertiesWrapper alloc] init];
textPropertiesWrapper.text = [RCTFabricWrapper getTextFromView:paragraphComponentView];
textPropertiesWrapper.contentRect = paragraphComponentView.bounds;

rct::TextAttributes textAttributes = props->textAttributes;
textPropertiesWrapper.alignment = [RCTFabricWrapper getAlignmentFromAttributes:textAttributes];
textPropertiesWrapper.foregroundColor = [RCTFabricWrapper getForegroundColorFromAttributes:textAttributes];
textPropertiesWrapper.fontSize = [RCTFabricWrapper getFontSizeFromAttributes:textAttributes];

return textPropertiesWrapper;
#else
return nil;
#endif
}

#if RCT_NEW_ARCH_ENABLED
+ (NSString* _Nonnull)getTextFromView:(RCTParagraphComponentView*)view {
if (view == nil || view.attributedText == nil) {
return RCTTextPropertiesDefaultText;
}

return view.attributedText.string;
}

+ (NSTextAlignment)getAlignmentFromAttributes:(rct::TextAttributes)textAttributes {
const rct::TextAlignment alignment = textAttributes.alignment.has_value() ?
textAttributes.alignment.value() :
rct::TextAlignment::Natural;

switch (alignment) {
case rct::TextAlignment::Natural:
return NSTextAlignmentNatural;

case rct::TextAlignment::Left:
return NSTextAlignmentLeft;

case rct::TextAlignment::Center:
return NSTextAlignmentCenter;

case rct::TextAlignment::Right:
return NSTextAlignmentRight;

case rct::TextAlignment::Justified:
return NSTextAlignmentJustified;

default:
return RCTTextPropertiesDefaultAlignment;
}
}

+ (UIColor* _Nonnull)getForegroundColorFromAttributes:(rct::TextAttributes)textAttributes {
@try {
rct::Color color = *textAttributes.foregroundColor;
UIColor* uiColor = (__bridge UIColor*)color.getUIColor().get();
if (uiColor != nil) {
return uiColor;
}
} @catch (NSException *exception) {}

return RCTTextPropertiesDefaultForegroundColor;
}

+ (CGFloat)getFontSizeFromAttributes:(rct::TextAttributes)textAttributes {
// Float is just an alias for CGFloat, but this could change in the future.
_Static_assert(sizeof(rct::Float) == sizeof(CGFloat), "Float and CGFloat are expected to have the same size.");
return isnan(textAttributes.fontSize) ? RCTTextPropertiesDefaultFontSize : (CGFloat)textAttributes.fontSize;
}
#endif
@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/

@interface RCTTextPropertiesWrapper : NSObject

extern NSString* const RCTTextPropertiesDefaultText;
extern NSTextAlignment const RCTTextPropertiesDefaultAlignment;
extern UIColor* const RCTTextPropertiesDefaultForegroundColor;
extern CGFloat const RCTTextPropertiesDefaultFontSize;
extern CGRect const RCTTextPropertiesDefaultContentRect;

@property (nonatomic, strong, nonnull) NSString* text;
@property (nonatomic, assign) NSTextAlignment alignment;
@property (nonatomic, strong, nonnull) UIColor* foregroundColor;
@property (nonatomic, assign) CGFloat fontSize;
@property (nonatomic, assign) CGRect contentRect;

- (instancetype _Nonnull) init;

@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
* This product includes software developed at Datadog (https://www.datadoghq.com/).
* Copyright 2016-Present Datadog, Inc.
*/
#import "RCTTextPropertiesWrapper.h"

@implementation RCTTextPropertiesWrapper

NSString* const RCTTextPropertiesDefaultText = @"";
NSTextAlignment const RCTTextPropertiesDefaultAlignment = NSTextAlignmentNatural;
UIColor* const RCTTextPropertiesDefaultForegroundColor = [UIColor blackColor];
CGFloat const RCTTextPropertiesDefaultFontSize = 14.0;
CGRect const RCTTextPropertiesDefaultContentRect = CGRectZero;

- (instancetype)init {
self = [super init];
if (self) {
_text = RCTTextPropertiesDefaultText;
_alignment = RCTTextPropertiesDefaultAlignment;
_foregroundColor = RCTTextPropertiesDefaultForegroundColor;
_fontSize = RCTTextPropertiesDefaultFontSize;
_contentRect = RCTTextPropertiesDefaultContentRect;
}
return self;
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,60 @@ internal class RCTTextViewRecorder: SessionReplayNodeRecorder {
internal var identifier = UUID()

internal let uiManager: RCTUIManager
internal let fabricWrapper: RCTFabricWrapper

internal init(uiManager: RCTUIManager) {
internal init(uiManager: RCTUIManager, fabricWrapper: RCTFabricWrapper) {
self.uiManager = uiManager
}

internal func extractTextFromSubViews(
subviews: [RCTShadowView]?
) -> String? {
if let subviews = subviews {
return subviews.compactMap { subview in
if let sub = subview as? RCTRawTextShadowView {
return sub.text
}
if let sub = subview as? RCTVirtualTextShadowView {
// We recursively get all subviews for nested Text components
return extractTextFromSubViews(subviews: sub.reactSubviews())
}
return nil
}.joined()
}
return nil
self.fabricWrapper = fabricWrapper
}

public func semantics(
of view: UIView,
with attributes: SessionReplayViewAttributes,
in context: SessionReplayViewTreeRecordingContext
) -> SessionReplayNodeSemantics? {
guard
let textProperties = fabricWrapper.tryToExtractTextProperties(from: view) ?? tryToExtractTextProperties(view: view)
else {
return view is RCTTextView ? SessionReplayInvisibleElement.constant : nil
}

let builder = RCTTextViewWireframesBuilder(
wireframeID: context.ids.nodeID(view: view, nodeRecorder: self),
attributes: attributes,
text: textProperties.text,
textAlignment: textProperties.alignment,
textColor: textProperties.foregroundColor,
textObfuscator: textObfuscator(context),
fontSize: textProperties.fontSize,
contentRect: textProperties.contentRect
)

return SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [
SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder)
])
}

internal func tryToExtractTextFromSubViews(
subviews: [RCTShadowView]?
) -> String? {
guard let subviews = subviews else {
return nil
}

return subviews.compactMap { subview in
if let sub = subview as? RCTRawTextShadowView {
return sub.text
}
if let sub = subview as? RCTVirtualTextShadowView {
// We recursively get all subviews for nested Text components
return tryToExtractTextFromSubViews(subviews: sub.reactSubviews())
}
return nil
}.joined()
}

private func tryToExtractTextProperties(view: UIView) -> RCTTextPropertiesWrapper? {
guard let textView = view as? RCTTextView else {
return nil
}
Expand All @@ -56,41 +82,35 @@ internal class RCTTextViewRecorder: SessionReplayNodeRecorder {
shadowView = uiManager.shadowView(forReactTag: tag) as? RCTTextShadowView
}

if let shadow = shadowView {
// TODO: RUM-2173 check performance is ok
let text = extractTextFromSubViews(
subviews: shadow.reactSubviews()
)
guard let shadow = shadowView else {
return nil
}

let builder = RCTTextViewWireframesBuilder(
wireframeID: context.ids.nodeID(view: textView, nodeRecorder: self),
attributes: attributes,
text: text,
textAlignment: shadow.textAttributes.alignment,
textColor: shadow.textAttributes.foregroundColor?.cgColor,
textObfuscator: textObfuscator(context),
fontSize: shadow.textAttributes.fontSize,
contentRect: shadow.contentFrame
)
let node = SessionReplayNode(viewAttributes: attributes, wireframesBuilder: builder)
return SessionReplaySpecificElement(subtreeStrategy: .ignore, nodes: [node])
let textProperties = RCTTextPropertiesWrapper()

// TODO: RUM-2173 check performance is ok
if let text = tryToExtractTextFromSubViews(subviews: shadow.reactSubviews()) {
textProperties.text = text
}
return SessionReplayInvisibleElement.constant
}
}

// Black color. This is the default for RN: https://github.com/facebook/react-native/blob/a5ee029cd02a636136058d82919480eeeb700067/packages/react-native/Libraries/Text/RCTTextAttributes.mm#L250
let DEFAULT_COLOR = UIColor.black.cgColor
if let foregroundColor = shadow.textAttributes.foregroundColor {
textProperties.foregroundColor = foregroundColor
}

// Default font size for RN: https://github.com/facebook/react-native/blob/16dff523b0a16d7fa9b651062c386885c2f48a6b/packages/react-native/React/Views/RCTFont.mm#L396
let DEFAULT_FONT_SIZE = CGFloat(14)
textProperties.alignment = shadow.textAttributes.alignment
textProperties.fontSize = shadow.textAttributes.fontSize
textProperties.contentRect = shadow.contentFrame

return textProperties
}
}

internal struct RCTTextViewWireframesBuilder: SessionReplayNodeWireframesBuilder {
let wireframeID: WireframeID
let attributes: SessionReplayViewAttributes
let text: String?
let text: String
var textAlignment: NSTextAlignment
let textColor: CGColor?
let textColor: UIColor
let textObfuscator: SessionReplayTextObfuscating
let fontSize: CGFloat
let contentRect: CGRect
Expand Down Expand Up @@ -140,12 +160,12 @@ internal struct RCTTextViewWireframesBuilder: SessionReplayNodeWireframesBuilder
id: wireframeID,
frame: relativeIntersectedRect,
clip: attributes.clip,
text: textObfuscator.mask(text: text ?? ""),
text: textObfuscator.mask(text: text),
textFrame: textFrame,
// Text alignment is top for all RCTTextView components.
// Text alignment is top for all RCTTextView and RCTParagraphComponentView components.
textAlignment: .init(systemTextAlignment: textAlignment, vertical: .top),
textColor: textColor ?? DEFAULT_COLOR,
fontOverride: SessionReplayWireframesBuilder.FontOverride(size: fontSize.isNaN ? DEFAULT_FONT_SIZE : fontSize),
textColor: textColor.cgColor,
fontOverride: SessionReplayWireframesBuilder.FontOverride(size: fontSize.isNaN ? RCTTextPropertiesDefaultFontSize : fontSize),
borderColor: attributes.layerBorderColor,
borderWidth: attributes.layerBorderWidth,
backgroundColor: attributes.backgroundColor,
Expand Down
Loading

0 comments on commit 917bdcb

Please sign in to comment.