react-native-userleap 4.0.0 → 4.1.1

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.
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Platform, NativeModules } from "react-native";
1
+ import { Platform, NativeModules, TurboModuleRegistry } from "react-native";
2
2
 
3
3
  import { version } from "./package.json";
4
4
 
@@ -15,6 +15,100 @@ const SprigUserInterfaceMode = {
15
15
  dark: 2,
16
16
  };
17
17
 
18
+ const SprigEventName = {
19
+ sdkReady: "sdkReady",
20
+ visitorIdUpdated: "visitorIdUpdated",
21
+ surveyHeight: "surveyHeight",
22
+ surveyStateReturned: "surveyStateReturned",
23
+ surveyWillPresent: "surveyWillPresent",
24
+ surveyPresented: "surveyPresented",
25
+ surveyAppeared: "surveyAppeared",
26
+ questionAnswered: "questionAnswered",
27
+ surveyCloseRequested: "surveyCloseRequested",
28
+ surveyWillClose: "surveyWillClose",
29
+ surveyClosed: "surveyClosed",
30
+ surveyCompleted: "surveyCompleted",
31
+ replayCapture: "replayCapture",
32
+ replayCaptureStarted: "replayCaptureStarted",
33
+ replayCaptureStopped: "replayCaptureStopped",
34
+ replayCaptureCompleted: "replayCaptureCompleted",
35
+ replayRenderingCompleted: "replayRenderingCompleted",
36
+ replayUploadCompleted: "replayUploadCompleted",
37
+ replayEventsUploadCompleted: "replayEventsUploadCompleted",
38
+ loggingEvent: "loggingEvent",
39
+ };
40
+
41
+ // Resolved lazily; needed for the onSprigEvent channel (a JSI EventEmitter the legacy NativeModules interop doesn't expose).
42
+ let _module = null;
43
+ const getModule = () => {
44
+ if (_module) return _module;
45
+ _module =
46
+ (TurboModuleRegistry && TurboModuleRegistry.get
47
+ ? TurboModuleRegistry.get("UserLeapBindings")
48
+ : null) ||
49
+ NativeModules.UserLeapBindings ||
50
+ null;
51
+ return _module;
52
+ };
53
+
54
+ // All native events arrive on one onSprigEvent channel as a {name,json} JSON-string envelope
55
+ // (payloads are heterogeneous); we subscribe once and fan out to per-event listeners.
56
+ const _listenersByEvent = new Map();
57
+ let _channelSub = null;
58
+
59
+ const ensureChannelSubscribed = () => {
60
+ if (_channelSub) return;
61
+ const mod = getModule();
62
+ if (!mod || typeof mod.onSprigEvent !== "function") return;
63
+ _channelSub = mod.onSprigEvent((envelope) => {
64
+ if (!envelope || !envelope.name) return;
65
+ const set = _listenersByEvent.get(envelope.name);
66
+ if (!set || set.size === 0) return;
67
+
68
+ let payload;
69
+ try {
70
+ payload = envelope.json ? JSON.parse(envelope.json) : {};
71
+ } catch (e) {
72
+ payload = {};
73
+ }
74
+ if (payload && typeof payload === "object") {
75
+ payload.type = envelope.name;
76
+ }
77
+
78
+ // Copy so a listener (un)subscribing during dispatch can't mutate the set we're walking.
79
+ for (const cb of Array.from(set)) {
80
+ try {
81
+ cb(payload);
82
+ } catch (err) {
83
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
84
+ console.warn(
85
+ '[Sprig] listener for "' + envelope.name + '" threw:',
86
+ err
87
+ );
88
+ }
89
+ }
90
+ }
91
+ });
92
+ };
93
+
94
+ const NOOP_SUBSCRIPTION = { remove: () => {} };
95
+
96
+ let _supportedEventNamesCache = null;
97
+ const getSupportedEventNames = () => {
98
+ if (_supportedEventNamesCache !== null) return _supportedEventNamesCache;
99
+ if (!isValidPlatform()) {
100
+ _supportedEventNamesCache = [];
101
+ return _supportedEventNamesCache;
102
+ }
103
+ try {
104
+ const result = NativeModules.UserLeapBindings.getSupportedEventNames();
105
+ _supportedEventNamesCache = Array.isArray(result) ? result : [];
106
+ } catch (err) {
107
+ _supportedEventNamesCache = [];
108
+ }
109
+ return _supportedEventNamesCache;
110
+ };
111
+
18
112
 
19
113
  const stringifyAttributes = (attributes) => {
20
114
  if (!(attributes instanceof Object && attributes.constructor === Object))
@@ -42,11 +136,7 @@ const visitorIdentifier = () => {
42
136
  };
43
137
 
44
138
  const sdkVersion = () => {
45
- if (Platform.OS === "ios") {
46
- if (String(Platform.Version) >= "15.0") {
47
- return NativeModules.UserLeapBindings.sdkVersion();
48
- }
49
- } else {
139
+ if (isValidPlatform()) {
50
140
  return NativeModules.UserLeapBindings.getSdkVersion();
51
141
  }
52
142
  return "0.0.0";
@@ -271,6 +361,63 @@ const overrideUserInterfaceMode = (mode) => {
271
361
  }
272
362
  }
273
363
 
364
+ /**
365
+ * Subscribe to a native SDK lifecycle event. Returns a subscription whose
366
+ * `.remove()` unsubscribes only this listener.
367
+ * @param {string} eventName
368
+ * @param {(data: object) => void} callback
369
+ * @returns {{ remove: () => void }} subscription
370
+ */
371
+ const addEventListener = (eventName, callback) => {
372
+ if (!isValidPlatform()) return NOOP_SUBSCRIPTION;
373
+
374
+ const supported = getSupportedEventNames();
375
+ if (supported.length > 0 && !supported.includes(eventName)) {
376
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
377
+ console.warn(
378
+ '[Sprig] "' + eventName +
379
+ '" is not emitted on this platform (' +
380
+ Platform.OS + '); subscription is a no-op.'
381
+ );
382
+ }
383
+ return NOOP_SUBSCRIPTION;
384
+ }
385
+
386
+ ensureChannelSubscribed();
387
+
388
+ let set = _listenersByEvent.get(eventName);
389
+ if (!set) {
390
+ set = new Set();
391
+ _listenersByEvent.set(eventName, set);
392
+ }
393
+ set.add(callback);
394
+
395
+ let removed = false;
396
+ return {
397
+ remove: () => {
398
+ if (removed) return;
399
+ removed = true;
400
+ const s = _listenersByEvent.get(eventName);
401
+ if (s) {
402
+ s.delete(callback);
403
+ if (s.size === 0) _listenersByEvent.delete(eventName);
404
+ }
405
+ },
406
+ };
407
+ };
408
+
409
+ /**
410
+ * Remove every listener for the given event name, or all listeners if no name is given.
411
+ * @param {string} [eventName]
412
+ */
413
+ const removeAllEventListeners = (eventName) => {
414
+ if (eventName) {
415
+ _listenersByEvent.delete(eventName);
416
+ return;
417
+ }
418
+ _listenersByEvent.clear();
419
+ };
420
+
274
421
 
275
422
  const UserLeap = {
276
423
  visitorIdentifier,
@@ -298,9 +445,13 @@ const UserLeap = {
298
445
  pauseDisplayingSurveys,
299
446
  unpauseDisplayingSurveys,
300
447
  overrideUserInterfaceMode,
448
+ addEventListener,
449
+ removeAllEventListeners,
450
+ getSupportedEventNames,
301
451
  };
302
452
 
303
453
  UserLeap.SurveyState = SurveyState;
304
454
  UserLeap.SprigUserInterfaceMode = SprigUserInterfaceMode;
455
+ UserLeap.SprigEventName = SprigEventName;
305
456
 
306
457
  export default UserLeap;
@@ -1,6 +1,8 @@
1
- #import <React/RCTBridgeModule.h>
2
- @import UserLeapKit;
1
+ #import <UserLeapKit/UserLeapKit.h>
2
+ #import <UserLeapKit/UserLeapKit-Swift.h>
3
3
 
4
- @interface UserLeapBindings : NSObject <RCTBridgeModule, _SGRNExtractor>
5
- @property (nonatomic, weak) RCTBridge *bridge;
4
+ #import <React/RCTInvalidating.h>
5
+ #import <RNUserLeapBindingsSpec/RNUserLeapBindingsSpec.h>
6
+
7
+ @interface UserLeapBindings : NativeUserLeapBindingsSpecBase <NativeUserLeapBindingsSpec, _SGRNExtractor, RCTInvalidating>
6
8
  @end
@@ -0,0 +1,491 @@
1
+ #import "UserLeapBindings.h"
2
+ #import <React/RCTUtils.h>
3
+ #import <ReactCommon/CallInvoker.h>
4
+ #import <os/lock.h>
5
+ // Legacy Paper includes gated on RCT_REMOVE_LEGACY_ARCH: these classes are removed on RN 0.85+.
6
+ #ifndef RCT_REMOVE_LEGACY_ARCH
7
+ #import <React/RCTTextView.h>
8
+ #import <React/RCTUIManager.h>
9
+ #import <React/RCTTextShadowView.h>
10
+ #import <React/RCTShadowView.h>
11
+ #import <React/RCTRawTextShadowView.h>
12
+ #import <React/RCTVirtualTextShadowView.h>
13
+ #import <React/RCTUIManagerUtils.h>
14
+ #endif
15
+ #import <React/RCTView.h>
16
+
17
+ // The codegen spec header is Obj-C++ only, which is why this file is `.mm`.
18
+ #import <RNUserLeapBindingsSpec/RNUserLeapBindingsSpec.h>
19
+
20
+ @implementation UserLeapBindings {
21
+ os_unfair_lock _lock; // guards _alive and _jsInvoker (shared_ptr copy isn't atomic); never held across an emit
22
+ BOOL _alive;
23
+ std::shared_ptr<facebook::react::CallInvoker> _jsInvoker;
24
+ }
25
+
26
+ + (BOOL)requiresMainQueueSetup
27
+ {
28
+ return YES;
29
+ }
30
+
31
+ - (instancetype)init
32
+ {
33
+ if (self = [super init]) {
34
+ _lock = OS_UNFAIR_LOCK_INIT;
35
+ _alive = YES;
36
+ [self subscribeToSdkEvents];
37
+ }
38
+ return self;
39
+ }
40
+
41
+ // Deterministic teardown hook: stop delivering before the runtime is destroyed.
42
+ - (void)invalidate
43
+ {
44
+ os_unfair_lock_lock(&_lock);
45
+ _alive = NO;
46
+ os_unfair_lock_unlock(&_lock);
47
+ [self unsubscribeFromSdkEvents];
48
+ }
49
+
50
+ - (void)dealloc
51
+ {
52
+ // Backstop; -invalidate is the real teardown path.
53
+ [self unsubscribeFromSdkEvents];
54
+ }
55
+
56
+ - (dispatch_queue_t)methodQueue
57
+ {
58
+ return dispatch_get_main_queue();
59
+ }
60
+
61
+ - (NSDictionary *)parseSurveyResult:(SprigSurveyResult *)result {
62
+ SurveyState state = [result surveyState];
63
+ NSString *surveyStateString = [self getSurveyState:state];
64
+ NSInteger surveyId = [result surveyId];
65
+ return @{@"surveyState": surveyStateString, @"surveyId": @(surveyId)};
66
+ }
67
+
68
+ - (NSString *)getSurveyState:(SurveyState)surveyState
69
+ {
70
+ NSString *surveyStateBinding = @"NO_SURVEY";
71
+ switch(surveyState) {
72
+ case SurveyStateDisabled:
73
+ surveyStateBinding = @"DISABLED";
74
+ break;
75
+ case SurveyStateReady:
76
+ surveyStateBinding = @"READY";
77
+ break;
78
+ case SurveyStateNoSurvey:
79
+ surveyStateBinding = @"NO_SURVEY";
80
+ break;
81
+ case SurveyStatePreviousSurveyReady:
82
+ surveyStateBinding = @"PREVIOUS_SURVEY_READY";
83
+ break;
84
+ }
85
+ return surveyStateBinding;
86
+ }
87
+
88
+ RCT_EXPORT_MODULE()
89
+
90
+ - (NSNumber *)visitorIdentifier
91
+ {
92
+ // No @() boxing: @() expects a primitive C type, and the SDK already returns NSNumber *.
93
+ return [[UserLeap shared] visitorIdentifier];
94
+ }
95
+
96
+ - (NSString *)visitorIdentifierString
97
+ {
98
+ return [[UserLeap shared] visitorIdentifierString];
99
+ }
100
+
101
+ - (NSString *)getSdkVersion
102
+ {
103
+ // Renamed from `sdkVersion` to `getSdkVersion` so iOS and Android share one spec method name.
104
+ return [[UserLeap shared] sdkVersion];
105
+ }
106
+
107
+ - (void)configure:(NSString *)environmentId configuration:(NSDictionary *)configuration
108
+ {
109
+ [[UserLeap shared] configureWithEnvironment:environmentId configuration:configuration];
110
+ [[UserLeap shared] _passWithRnExtractor:self];
111
+ }
112
+
113
+ - (void)setPreviewKey:(NSString *)previewKey
114
+ {
115
+ [[UserLeap shared] setPreviewKey:previewKey];
116
+ }
117
+
118
+ - (void)setUserIdentifier:(NSString *)identifier
119
+ {
120
+ [[UserLeap shared] setUserIdentifier:identifier];
121
+ }
122
+
123
+ - (void)setEmailAddress:(NSString *)emailAddress
124
+ {
125
+ [[UserLeap shared] setEmailAddress:emailAddress];
126
+ }
127
+
128
+ - (void)logout
129
+ {
130
+ [[UserLeap shared] logout];
131
+ }
132
+
133
+ - (void)setVisitorAttribute:(NSString *)key value:(NSString *)value
134
+ {
135
+ [[UserLeap shared] setVisitorAttributeWithKey:key value:value];
136
+ }
137
+
138
+ - (void)setVisitorAttributes:(NSDictionary *)attributes
139
+ {
140
+ [[UserLeap shared] setVisitorAttributes:attributes];
141
+ }
142
+
143
+ - (void)removeVisitorAttributes:(NSArray *)keys
144
+ {
145
+ [[UserLeap shared] removeVisitorAttributes:keys];
146
+ }
147
+
148
+ - (void)setVisitorAttributesAndIdentify:(NSDictionary *)attributes
149
+ userId:(NSString *)userId
150
+ partnerAnonymousId:(NSString *)partnerAnonymousId
151
+ {
152
+ [[UserLeap shared] setVisitorAttributes:attributes userId:userId partnerAnonymousId:partnerAnonymousId];
153
+ }
154
+
155
+ - (void)presentSurvey
156
+ {
157
+ [[UserLeap shared] presentSurveyFrom:RCTPresentedViewController()];
158
+ }
159
+
160
+ - (void)dismissActiveSurvey
161
+ {
162
+ [[UserLeap shared] dismissActiveSurvey];
163
+ }
164
+
165
+ - (void)pauseDisplayingSurveys
166
+ {
167
+ [[UserLeap shared] pauseDisplayingSurveys];
168
+ }
169
+
170
+ - (void)unpauseDisplayingSurveys
171
+ {
172
+ [[UserLeap shared] unpauseDisplayingSurveys];
173
+ }
174
+
175
+ - (void)trackAndPresent:(NSString *)eventName
176
+ {
177
+ [[UserLeap shared] trackAndPresentWithEventName:eventName from:RCTPresentedViewController()];
178
+ }
179
+
180
+ - (void)trackIdentifyAndPresent:(NSString *)eventName
181
+ userId:(NSString *)userId
182
+ partnerAnonymousId:(NSString *)partnerAnonymousId
183
+ {
184
+ [[UserLeap shared] trackAndPresentWithEventName:eventName userId:userId partnerAnonymousId:partnerAnonymousId from:RCTPresentedViewController()];
185
+ }
186
+
187
+ - (void)overrideUserInterfaceMode:(double)mode
188
+ {
189
+ // Explicit enum cast required: Obj-C++ won't implicitly convert numeric → enum.
190
+ [[UserLeap shared] overrideUserInterfaceModeWithMode:(enum SprigUserInterfaceMode)(NSInteger)mode];
191
+ }
192
+
193
+ - (void)trackEvent:(NSString *)eventName
194
+ surveyResultCallback:(RCTResponseSenderBlock)surveyResultCallback
195
+ {
196
+ EventPayload *payload = [[EventPayload alloc] initWithEventName:eventName
197
+ userId:nil
198
+ partnerAnonymousId:nil
199
+ properties:nil
200
+ handler:nil
201
+ resultHandler:^(SprigSurveyResult * result) {
202
+ if (surveyResultCallback != nil) {
203
+ surveyResultCallback(@[[self parseSurveyResult:result]]);
204
+ }
205
+ }];
206
+ [[UserLeap shared] trackWithPayload:payload];
207
+ }
208
+
209
+ - (void)trackEventWithProperties:(NSString *)eventName
210
+ userId:(NSString *)userId
211
+ partnerAnonymousId:(NSString *)partnerAnonymousId
212
+ properties:(NSDictionary *)properties
213
+ surveyResultCallback:(RCTResponseSenderBlock)surveyResultCallback
214
+ {
215
+ EventPayload *payload = [[EventPayload alloc] initWithEventName:eventName
216
+ userId:userId
217
+ partnerAnonymousId:partnerAnonymousId
218
+ properties:properties
219
+ handler:nil
220
+ resultHandler:^(SprigSurveyResult * result) {
221
+ if (surveyResultCallback != nil) {
222
+ surveyResultCallback(@[[self parseSurveyResult:result]]);
223
+ }
224
+ }];
225
+ [[UserLeap shared] trackWithPayload:payload];
226
+ }
227
+
228
+ - (void)trackEventAndIdentify:(NSString *)eventName
229
+ userId:(NSString *)userId
230
+ partnerAnonymousId:(NSString *)partnerAnonymousId
231
+ surveyResultCallback:(RCTResponseSenderBlock)surveyResultCallback
232
+ {
233
+ EventPayload *payload = [[EventPayload alloc] initWithEventName:eventName
234
+ userId:userId
235
+ partnerAnonymousId:partnerAnonymousId
236
+ properties:nil
237
+ handler:nil
238
+ resultHandler:^(SprigSurveyResult * result) {
239
+ if (surveyResultCallback != nil) {
240
+ surveyResultCallback(@[[self parseSurveyResult:result]]);
241
+ }
242
+ }];
243
+ [[UserLeap shared] trackWithPayload:payload];
244
+ }
245
+
246
+ - (void)track:(NSString *)eventName
247
+ surveyStateCallback:(RCTResponseSenderBlock)surveyStateCallback
248
+ {
249
+ if (surveyStateCallback != nil) {
250
+ [[UserLeap shared] trackWithEventName:eventName handler:^(enum SurveyState surveyState) {
251
+ surveyStateCallback(@[[self getSurveyState:surveyState]]);
252
+ }];
253
+ } else {
254
+ [[UserLeap shared] trackWithEventName:eventName handler:nil];
255
+ }
256
+ }
257
+
258
+ - (void)trackWithProperties:(NSString *)eventName
259
+ userId:(NSString *)userId
260
+ partnerAnonymousId:(NSString *)partnerAnonymousId
261
+ properties:(NSDictionary *)properties
262
+ surveyStateCallback:(RCTResponseSenderBlock)surveyStateCallback
263
+ {
264
+ [[UserLeap shared] trackWithEventName:eventName
265
+ userId:userId
266
+ partnerAnonymousId:partnerAnonymousId
267
+ properties:properties
268
+ handler:^(enum SurveyState surveyState) {
269
+ if (surveyStateCallback != nil) {
270
+ surveyStateCallback(@[[self getSurveyState:surveyState]]);
271
+ }
272
+ }];
273
+ }
274
+
275
+ - (void)trackAndIdentify:(NSString *)eventName
276
+ userId:(NSString *)userId
277
+ partnerAnonymousId:(NSString *)partnerAnonymousId
278
+ surveyStateCallback:(RCTResponseSenderBlock)surveyStateCallback
279
+ {
280
+ if (surveyStateCallback != nil) {
281
+ [[UserLeap shared] trackWithEventName:eventName
282
+ userId:userId
283
+ partnerAnonymousId:partnerAnonymousId
284
+ handler:^(enum SurveyState surveyState) {
285
+ surveyStateCallback(@[[self getSurveyState:surveyState]]);
286
+ }];
287
+ } else {
288
+ [[UserLeap shared] trackWithEventName:eventName
289
+ userId:userId
290
+ partnerAnonymousId:partnerAnonymousId
291
+ handler:nil];
292
+ }
293
+ }
294
+
295
+ - (_SGRNTextProperties *)textPropertiesFromView:(UIView * _Nonnull)passedView {
296
+
297
+ #ifndef RCT_REMOVE_LEGACY_ARCH
298
+ if ([passedView isKindOfClass:[RCTTextView class]]) {
299
+ RCTUIManager* uiManager = [self.bridge moduleForClass:[RCTUIManager class]];
300
+ RCTTextView *textView = (RCTTextView *)passedView;
301
+ __block _SGRNTextProperties *textProperties = [[_SGRNTextProperties alloc] init];
302
+ dispatch_sync(RCTGetUIManagerQueue(), ^{
303
+ RCTShadowView *shadowView = [uiManager shadowViewForReactTag:textView.reactTag];
304
+ if ([shadowView isKindOfClass:[RCTTextShadowView class]]) {
305
+ RCTTextShadowView *textShadowView = (RCTTextShadowView *)shadowView;
306
+ NSString *text = [self extractText: textShadowView.reactSubviews];
307
+ if (text.length == 0) return;
308
+ textProperties.text = text;
309
+ textProperties.color = textShadowView.textAttributes.foregroundColor;
310
+ textProperties.alignment = textShadowView.textAttributes.alignment;
311
+ textProperties.font = textShadowView.textAttributes.effectiveFont;
312
+ }
313
+ });
314
+ if (textProperties.text.length == 0) return nil;
315
+ return textProperties;
316
+ }
317
+ #endif
318
+
319
+ Class ParagraphComponentView = NSClassFromString(@"RCTParagraphComponentView");
320
+ if (ParagraphComponentView && [passedView isKindOfClass:ParagraphComponentView]) {
321
+ NSString *text = [self extractTextFromParagraphComponentView:passedView];
322
+ if (text.length == 0) return nil;
323
+ _SGRNTextProperties *textProperties = [[_SGRNTextProperties alloc] init];
324
+ textProperties.text = text;
325
+ if ([passedView respondsToSelector:@selector(attributedText)]) {
326
+ NSAttributedString *attrText = [passedView performSelector:@selector(attributedText)];
327
+ if (attrText.length > 0) {
328
+ NSDictionary *attrs = [attrText attributesAtIndex:0 effectiveRange:nil];
329
+ if (!textProperties.color && attrs[NSForegroundColorAttributeName]) {
330
+ textProperties.color = attrs[NSForegroundColorAttributeName];
331
+ }
332
+ if (!textProperties.font && attrs[NSFontAttributeName]) {
333
+ textProperties.font = attrs[NSFontAttributeName];
334
+ }
335
+ NSParagraphStyle *style = attrs[NSParagraphStyleAttributeName];
336
+ if (style) {
337
+ textProperties.alignment = style.alignment;
338
+ }
339
+ }
340
+ }
341
+ return textProperties;
342
+ }
343
+ return nil;
344
+ }
345
+
346
+ - (_SGRNViewProperties * _Nullable)propertiesFromView:(UIView * _Nonnull)passedView {
347
+ if ([passedView isKindOfClass:[RCTView class]]) {
348
+ RCTView *rView = (RCTView *) passedView;
349
+ if (rView.borderWidth > 0) {
350
+ _SGRNViewProperties *props = [[_SGRNViewProperties alloc] init];
351
+ props.borderColor = rView.borderColor;
352
+ props.borderWidth = rView.borderWidth;
353
+ return props;
354
+ }
355
+ }
356
+ return nil;
357
+ }
358
+
359
+ #ifndef RCT_REMOVE_LEGACY_ARCH
360
+ - (NSString * _Nullable)extractText:(NSArray<RCTShadowView *> * _Nonnull) subviews {
361
+ NSMutableString *text = [[NSMutableString alloc] init];
362
+ for (RCTShadowView *subview in subviews) {
363
+ if ([subview isKindOfClass:[RCTRawTextShadowView class]]) {
364
+ [text appendString:((RCTRawTextShadowView *)subview).text];
365
+ }
366
+ if ([subview isKindOfClass:[RCTVirtualTextShadowView class]]) {
367
+ // Append, don't early-return: returning here would drop text already collected.
368
+ NSString *nested = [self extractText: ((RCTVirtualTextShadowView *)subview).reactSubviews];
369
+ if (nested) [text appendString:nested];
370
+ }
371
+ }
372
+ return text;
373
+ }
374
+ #endif
375
+
376
+ - (NSString *)extractTextFromParagraphComponentView:(UIView *)view {
377
+ if ([view respondsToSelector:@selector(attributedText)]) {
378
+ NSAttributedString *attrText = [view performSelector:@selector(attributedText)];
379
+ return attrText.string;
380
+ }
381
+ if ([view respondsToSelector:@selector(text)]) {
382
+ NSString *text = [view performSelector:@selector(text)];
383
+ return text;
384
+ }
385
+ NSMutableString *result = [NSMutableString string];
386
+ for (UIView *subview in view.subviews) {
387
+ NSString *subText = [self extractTextFromParagraphComponentView:subview];
388
+ if (subText) [result appendString:subText];
389
+ }
390
+ return result;
391
+ }
392
+
393
+ - (NSArray<NSString *> *)getSupportedEventNames
394
+ {
395
+ NSMutableArray<NSString *> *events = [NSMutableArray array];
396
+ for (NSString *name in [LifecycleEventUtil allNameStrings]) {
397
+ if ([name isEqualToString:@"unknown"]) continue;
398
+ [events addObject:name];
399
+ }
400
+ return events;
401
+ }
402
+
403
+ - (NSDictionary *)normalizePayloadForEvent:(NSString *)eventName
404
+ data:(NSDictionary<NSString *, id> *)data
405
+ {
406
+ if (![eventName isEqualToString:@"loggingEvent"]) {
407
+ return data ?: @{};
408
+ }
409
+
410
+ NSString *raw = data[@"message"];
411
+ NSString *message = @"";
412
+ if ([raw isKindOfClass:[NSString class]]) {
413
+ NSString *prefix = @"Sprig: ";
414
+ message = [raw hasPrefix:prefix] ? [raw substringFromIndex:prefix.length] : raw;
415
+ }
416
+
417
+ return @{
418
+ @"type": @"loggingEvent",
419
+ @"message": message,
420
+ @"level": @"info",
421
+ };
422
+ }
423
+
424
+ - (NSString *)jsonStringFromDictionary:(NSDictionary *)dict
425
+ {
426
+ if (![NSJSONSerialization isValidJSONObject:dict]) {
427
+ return @"{}";
428
+ }
429
+ NSError *error = nil;
430
+ NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&error];
431
+ if (!data || error) {
432
+ return @"{}";
433
+ }
434
+ return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}";
435
+ }
436
+
437
+ - (void)subscribeToSdkEvents
438
+ {
439
+ __weak __typeof(self) weakSelf = self;
440
+ UserLeap *sdk = [UserLeap shared];
441
+
442
+ for (NSNumber *raw in [LifecycleEventUtil all]) {
443
+ LifecycleEvent eventType = (LifecycleEvent)raw.integerValue;
444
+ NSString *name = [LifecycleEventUtil stringName:eventType];
445
+ if ([name isEqualToString:@"unknown"]) continue;
446
+
447
+ [sdk registerEventListenerFor:eventType listener:^(NSDictionary<NSString *,id> * _Nonnull data) {
448
+ __strong __typeof(weakSelf) strongSelf = weakSelf;
449
+ if (!strongSelf) return;
450
+
451
+ // Snapshot liveness + invoker under the lock; bail if torn down.
452
+ std::shared_ptr<facebook::react::CallInvoker> invoker;
453
+ os_unfair_lock_lock(&strongSelf->_lock);
454
+ if (strongSelf->_alive) { invoker = strongSelf->_jsInvoker; }
455
+ os_unfair_lock_unlock(&strongSelf->_lock);
456
+ if (!invoker) return;
457
+
458
+ NSDictionary *payload = [strongSelf normalizePayloadForEvent:name data:data];
459
+ NSString *json = [strongSelf jsonStringFromDictionary:payload];
460
+
461
+ // Emit on the JS thread so it can't race runtime teardown; dropped if already invalidated.
462
+ invoker->invokeAsync([strongSelf, name, json]() {
463
+ os_unfair_lock_lock(&strongSelf->_lock);
464
+ BOOL alive = strongSelf->_alive;
465
+ os_unfair_lock_unlock(&strongSelf->_lock);
466
+ if (!alive) return;
467
+ [strongSelf emitOnSprigEvent:@{@"name": name, @"json": json}];
468
+ });
469
+ }];
470
+ }
471
+ }
472
+
473
+ - (void)unsubscribeFromSdkEvents
474
+ {
475
+ UserLeap *sdk = [UserLeap shared];
476
+ for (NSNumber *raw in [LifecycleEventUtil all]) {
477
+ LifecycleEvent eventType = (LifecycleEvent)raw.integerValue;
478
+ [sdk unregisterAllEventListenersFor:eventType];
479
+ }
480
+ }
481
+
482
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params
483
+ {
484
+ // Capture the JS CallInvoker so event callbacks can marshal emits onto the JS thread.
485
+ os_unfair_lock_lock(&_lock);
486
+ _jsInvoker = params.jsInvoker;
487
+ os_unfair_lock_unlock(&_lock);
488
+ return std::make_shared<facebook::react::NativeUserLeapBindingsSpecJSI>(params);
489
+ }
490
+
491
+ @end