clintplay-react-native 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -126,6 +126,73 @@ static NSString *HexColorStringFromUIColor(UIColor *color) {
126
126
  return nil;
127
127
  }
128
128
 
129
+ // The React Native <Text> is not a UILabel: it is RCTParagraphComponentView on the New
130
+ // Architecture and RCTTextView on the legacy one. Both keep the rendered string with its
131
+ // attributes; reading it here gives the replay the visible text plus size, weight, colour
132
+ // and alignment (accessibilityLabel carries only the string, and an app-provided label
133
+ // replaces it).
134
+ static NSAttributedString *ReactNativeAttributedText(UIView *view) {
135
+ NSString *className = NSStringFromClass([view class]);
136
+ id value = nil;
137
+ @try {
138
+ if ([className isEqualToString:@"RCTParagraphComponentView"] &&
139
+ [view respondsToSelector:@selector(attributedText)]) {
140
+ value = [(id)view attributedText];
141
+ } else if ([className isEqualToString:@"RCTTextView"]) {
142
+ // Legacy architecture: no public getter, the storage lives in the `_textStorage` ivar.
143
+ value = [view valueForKey:@"textStorage"];
144
+ }
145
+ } @catch (NSException *e) {
146
+ value = nil;
147
+ }
148
+ return [value isKindOfClass:[NSAttributedString class]] ? (NSAttributedString *)value : nil;
149
+ }
150
+
151
+ // CSS-style weight (100-900) from the font's UIFontWeightTrait (-1.0 to 1.0).
152
+ static NSInteger CSSFontWeight(UIFont *font) {
153
+ NSDictionary *traits = [font.fontDescriptor objectForKey:UIFontDescriptorTraitsAttribute];
154
+ NSNumber *weight = [traits isKindOfClass:[NSDictionary class]] ? traits[UIFontWeightTrait] : nil;
155
+ if (![weight isKindOfClass:[NSNumber class]]) {
156
+ return (font.fontDescriptor.symbolicTraits & UIFontDescriptorTraitBold) ? 700 : 400;
157
+ }
158
+ CGFloat w = weight.doubleValue;
159
+ if (w <= UIFontWeightLight) return 300;
160
+ if (w < UIFontWeightMedium) return 400;
161
+ if (w < UIFontWeightSemibold) return 500;
162
+ if (w < UIFontWeightBold) return 600;
163
+ return 700;
164
+ }
165
+
166
+ static void PutTextStyle(NSMutableDictionary *nodeDict, UIFont *font, UIColor *color, NSTextAlignment alignment) {
167
+ @try {
168
+ if ([font isKindOfClass:[UIFont class]] && font.pointSize > 0) {
169
+ nodeDict[@"fontSize"] = @(round(font.pointSize * 10.0) / 10.0);
170
+ nodeDict[@"fontWeight"] = @(CSSFontWeight(font));
171
+ }
172
+ NSString *hex = [color isKindOfClass:[UIColor class]] ? HexColorStringFromUIColor(color) : nil;
173
+ if (hex) {
174
+ nodeDict[@"color"] = hex;
175
+ }
176
+ if (alignment == NSTextAlignmentCenter) {
177
+ nodeDict[@"textAlign"] = @"center";
178
+ } else if (alignment == NSTextAlignmentRight) {
179
+ nodeDict[@"textAlign"] = @"right";
180
+ } else {
181
+ nodeDict[@"textAlign"] = @"left";
182
+ }
183
+ } @catch (NSException *e) {
184
+ // Style is best effort: the text alone still makes the replay readable.
185
+ }
186
+ }
187
+
188
+ static void PutAttributedTextStyle(NSMutableDictionary *nodeDict, NSAttributedString *text) {
189
+ if (text.length == 0) return;
190
+ NSDictionary *attrs = [text attributesAtIndex:0 effectiveRange:NULL];
191
+ NSParagraphStyle *paragraph = attrs[NSParagraphStyleAttributeName];
192
+ NSTextAlignment alignment = [paragraph isKindOfClass:[NSParagraphStyle class]] ? paragraph.alignment : NSTextAlignmentNatural;
193
+ PutTextStyle(nodeDict, attrs[NSFontAttributeName], attrs[NSForegroundColorAttributeName], alignment);
194
+ }
195
+
129
196
  static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteger depth) {
130
197
  if (depth > 64) {
131
198
  return nil;
@@ -223,21 +290,42 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
223
290
  if (identifier.length > 0) {
224
291
  nodeDict[@"accessibilityIdentifier"] = identifier;
225
292
  }
226
- if (view.accessibilityLabel.length > 0) {
227
- nodeDict[@"accessibilityLabel"] = isMasked ? @"***" : view.accessibilityLabel;
293
+ // Reuse the label read above: on RCTParagraphComponentView every read rebuilds the
294
+ // string from the shadow tree state.
295
+ if (accLabel.length > 0) {
296
+ nodeDict[@"accessibilityLabel"] = isMasked ? @"***" : accLabel;
228
297
  }
229
298
 
230
- // Text extraction from UILabel, UITextField, UITextView, UIButton
299
+ // Text extraction from the React Native <Text>, UILabel, UITextField, UITextView, UIButton.
300
+ // Style is read later, only for unmasked nodes with text: reading a UIButton's titleLabel
301
+ // creates one on image-only buttons, and a passive recorder must not change the host UI.
231
302
  NSString *extractedText = nil;
232
- if ([view isKindOfClass:[UILabel class]]) {
303
+ void (^putStyle)(void) = nil;
304
+ NSAttributedString *reactNativeText = ReactNativeAttributedText(view);
305
+ if (reactNativeText) {
306
+ // Copy: on the legacy architecture this is the live backing store of the NSTextStorage.
307
+ extractedText = [reactNativeText.string copy];
308
+ putStyle = ^{
309
+ PutAttributedTextStyle(nodeDict, reactNativeText);
310
+ };
311
+ } else if ([view isKindOfClass:[UILabel class]]) {
233
312
  UILabel *label = (UILabel *)view;
234
313
  extractedText = label.text ?: (label.attributedText ? label.attributedText.string : nil);
314
+ putStyle = ^{
315
+ PutTextStyle(nodeDict, label.font, label.textColor, label.textAlignment);
316
+ };
235
317
  } else if ([view isKindOfClass:[UITextField class]]) {
236
318
  UITextField *textField = (UITextField *)view;
237
319
  extractedText = textField.text ?: (textField.attributedText ? textField.attributedText.string : nil);
320
+ putStyle = ^{
321
+ PutTextStyle(nodeDict, textField.font, textField.textColor, textField.textAlignment);
322
+ };
238
323
  } else if ([view isKindOfClass:[UITextView class]]) {
239
324
  UITextView *textView = (UITextView *)view;
240
325
  extractedText = textView.text ?: (textView.attributedText ? textView.attributedText.string : nil);
326
+ putStyle = ^{
327
+ PutTextStyle(nodeDict, textView.font, textView.textColor, textView.textAlignment);
328
+ };
241
329
  } else if ([view isKindOfClass:[UIButton class]]) {
242
330
  UIButton *button = (UIButton *)view;
243
331
  if (@available(iOS 15.0, *)) {
@@ -248,10 +336,21 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
248
336
  if (!extractedText) {
249
337
  extractedText = [button titleForState:UIControlStateNormal] ?: (button.currentTitle ?: (button.currentAttributedTitle ? button.currentAttributedTitle.string : nil));
250
338
  }
339
+ putStyle = ^{
340
+ PutTextStyle(nodeDict, button.titleLabel.font, button.currentTitleColor, NSTextAlignmentCenter);
341
+ };
251
342
  }
252
343
 
253
344
  if (extractedText) {
254
345
  nodeDict[@"text"] = isMasked ? @"***" : extractedText;
346
+ if (!isMasked && extractedText.length > 0 && putStyle) {
347
+ putStyle();
348
+ }
349
+ // The text view's accessibilityLabel defaults to its own string: sending both doubles
350
+ // the payload of every <Text> for nothing.
351
+ if (!isMasked && [nodeDict[@"accessibilityLabel"] isEqual:extractedText]) {
352
+ [nodeDict removeObjectForKey:@"accessibilityLabel"];
353
+ }
255
354
  }
256
355
 
257
356
  if (isMasked) {
@@ -283,7 +382,7 @@ static NSDictionary *SerializeView(UIView *view, NSInteger *nodeCounter, NSInteg
283
382
  RCT_EXPORT_MODULE(ClintplayRN)
284
383
 
285
384
  RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
286
- return @"0.2.0";
385
+ return @"0.2.2";
287
386
  }
288
387
 
289
388
  - (NSDictionary *)extractHierarchyDictionaryOnMainThread {
@@ -319,7 +418,9 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVersion) {
319
418
  return @"{\"version\":1,\"nodes\":[]}";
320
419
  }
321
420
  NSError *error = nil;
322
- NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
421
+ // Sorted keys keep an unchanged screen byte-identical between captures, which is what
422
+ // lets the JS recorder skip re-sending it.
423
+ NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:NSJSONWritingSortedKeys error:&error];
323
424
  if (error || !jsonData) {
324
425
  return @"{\"version\":1,\"nodes\":[]}";
325
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clintplay-react-native",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Clintplay React Native SDK — session capture for iOS and Android apps. Companion to clintplay-sdk (web).",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",