@shortkitsdk/react-native 0.2.35 → 0.2.37

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.
Files changed (43) hide show
  1. package/android/build.gradle.kts +8 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactCarouselOverlayHost.kt +94 -46
  4. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +46 -7
  5. package/android/src/main/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHost.kt +233 -27
  6. package/android/src/main/java/com/shortkit/reactnative/ShortKitBridge.kt +252 -27
  7. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedView.kt +135 -6
  8. package/android/src/main/java/com/shortkit/reactnative/ShortKitFeedViewManager.kt +15 -0
  9. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +21 -11
  10. package/android/src/test/java/com/shortkit/reactnative/ReactCarouselOverlayHostEmitTest.kt +134 -0
  11. package/android/src/test/java/com/shortkit/reactnative/ReactOverlayHostDragTest.kt +45 -0
  12. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostDragTest.kt +69 -0
  13. package/android/src/test/java/com/shortkit/reactnative/ReactVideoCarouselOverlayHostEmitTest.kt +144 -0
  14. package/android/src/test/java/com/shortkit/reactnative/ShortKitFeedViewActivePropTest.kt +57 -0
  15. package/ios/ReactOverlayHost.swift +10 -8
  16. package/ios/ReactVideoCarouselOverlayHost.swift +14 -11
  17. package/ios/ShortKitBridge.swift +18 -0
  18. package/ios/ShortKitModule.mm +5 -0
  19. package/ios/ShortKitPlayerNativeView.swift +36 -0
  20. package/ios/ShortKitSDK.xcframework/Info.plist +5 -5
  21. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  22. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +932 -84
  23. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +26 -2
  24. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  25. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +26 -2
  26. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  27. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +932 -84
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +26 -2
  31. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  32. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +26 -2
  33. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +932 -84
  34. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +26 -2
  35. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  36. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +26 -2
  37. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  38. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  39. package/package.json +1 -1
  40. package/src/ShortKitCommands.ts +20 -0
  41. package/src/ShortKitFeed.tsx +21 -0
  42. package/src/specs/NativeShortKitModule.ts +10 -0
  43. package/src/types.ts +35 -0
@@ -27,6 +27,10 @@ android {
27
27
  java.srcDirs("src/main/java")
28
28
  }
29
29
  }
30
+
31
+ testOptions {
32
+ unitTests.isIncludeAndroidResources = true
33
+ }
30
34
  }
31
35
 
32
36
  dependencies {
@@ -47,4 +51,8 @@ dependencies {
47
51
  implementation("com.squareup.okhttp3:okhttp:4.12.0")
48
52
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
49
53
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
54
+
55
+ testImplementation("junit:junit:4.13.2")
56
+ testImplementation("org.robolectric:robolectric:4.14.1")
57
+ testImplementation("androidx.test:core:1.6.1")
50
58
  }
Binary file
@@ -59,6 +59,52 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
59
59
  /** Unique identifier for this overlay instance, used for event routing. */
60
60
  val surfaceId: String = java.util.UUID.randomUUID().toString()
61
61
 
62
+ /**
63
+ * Currently configured item id — used to detect item transitions so we
64
+ * can emit an event instead of triggering a full Fabric remount.
65
+ * Marked `internal` to allow unit-test verification.
66
+ */
67
+ internal var currentItemId: String? = null
68
+ private set
69
+
70
+ /**
71
+ * Whether initial props have been pushed at least once. First configure
72
+ * must go through pushProps()/setProperties; subsequent same-item
73
+ * rebinds are no-op; subsequent different-item rebinds emit
74
+ * onCarouselItemChanged for a React diff (no Fabric remount).
75
+ */
76
+ internal var hasPushedInitialProps: Boolean = false
77
+ private set
78
+
79
+ /**
80
+ * Test seam for emit-event interception. Production uses the default
81
+ * (forward to ShortKitBridge.shared); tests can overwrite to capture.
82
+ * Mirrors how iOS tests wrap bridge.emit().
83
+ */
84
+ internal var emitEvent: (String, com.facebook.react.bridge.WritableMap) -> Unit =
85
+ { name, params -> ShortKitBridge.shared?.emitEvent(name, params) }
86
+
87
+ /**
88
+ * Test seam for WritableMap creation. Defaults to Arguments.createMap()
89
+ * (WritableNativeMap — requires JNI). Tests override with JavaOnlyMap()
90
+ * to avoid the native-library initialization requirement in Robolectric.
91
+ */
92
+ internal var createMap: () -> com.facebook.react.bridge.WritableMap =
93
+ { com.facebook.react.bridge.Arguments.createMap() }
94
+
95
+ /**
96
+ * Test seam for ReactSurface creation. Production implementation resolves
97
+ * the surface from the ReactHost obtained from the application context.
98
+ * Tests override with a stub/mock so the surface is non-null after
99
+ * pushProps(), allowing hasPushedInitialProps to flip as expected.
100
+ * Returns null when surface creation fails (e.g. reactHost unavailable).
101
+ */
102
+ internal var createSurface: (moduleName: String, initialProps: Bundle?) -> ReactSurface? =
103
+ { moduleName, initialProps ->
104
+ (context.applicationContext as? ReactApplication)?.reactHost
105
+ ?.createSurface(context, moduleName, initialProps)
106
+ }
107
+
62
108
  private var cachedItemJSON: String? = null
63
109
  private var isActive: Boolean = false
64
110
  private var activeImageIndex: Int = 0
@@ -133,16 +179,12 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
133
179
  isActive = false
134
180
  activeImageIndex = 0
135
181
 
136
- // Increment generation any in-flight coroutine from a previous
137
- // configure() call will see a stale generation and bail out.
138
- val gen = ++configureGeneration
182
+ val isSameItem = item.id == currentItemId
183
+ currentItemId = item.id
139
184
 
140
- // Cancel any in-flight write job from a previous configure() call.
185
+ val gen = ++configureGeneration
141
186
  pendingWriteJob?.cancel()
142
187
 
143
- // Fast path: check for pre-existing temp files synchronously (just
144
- // file.exists(), no I/O). This avoids the flicker of remote→local
145
- // URL swap for images that were cached in a previous session.
146
188
  val lookup = cachedImage
147
189
  val fastItem = if (lookup != null) {
148
190
  val fastImages = item.images.map { image ->
@@ -166,9 +208,23 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
166
208
 
167
209
  val json = Json.encodeToString(fastItem)
168
210
  cachedItemJSON = json
169
- pushProps()
170
211
 
171
- // Pre-size the surface view NOW — before the overlay is attached to a cell.
212
+ if (!hasPushedInitialProps) {
213
+ pushProps()
214
+ // Only flip the flag if the surface was actually created. If reactHost
215
+ // was null (createSurfaceIfNeeded silently returned), surface remains
216
+ // null and pendingProps is parked — stay in "first mount" mode so the
217
+ // next configure() retries pushProps() with the new item. Mirrors iOS
218
+ // ReactCarouselOverlayHost.swift:131-141.
219
+ if (surface != null) {
220
+ hasPushedInitialProps = true
221
+ }
222
+ } else if (!isSameItem) {
223
+ emitItemChanged(json)
224
+ }
225
+ // else: same item rebind — no emit, no Fabric prop push.
226
+
227
+ // Pre-size the surface view (existing logic, unchanged).
172
228
  val parentView = parent as? android.view.View
173
229
  val w = if (width > 0) width
174
230
  else if (parentView != null && parentView.width > 0) parentView.width
@@ -178,43 +234,19 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
178
234
  else context.resources.displayMetrics.heightPixels
179
235
  measureAndLayoutSurfaceView(w, h)
180
236
 
181
- // Background: write any newly-cached images to temp files.
182
- // Only runs if there are remote URLs remaining (not all were fast-pathed).
237
+ // Background: write any newly-cached images to temp files for next-time
238
+ // fast-path availability. iOS-aligned: write-only, no second pushProps()
239
+ // (next configure() picks up the local URL via the synchronous fast-path).
183
240
  if (lookup == null) return
184
241
  val hasRemoteUrls = fastItem.images.any { !it.url.startsWith("file://") }
185
242
  if (!hasRemoteUrls) return
186
243
 
187
244
  pendingWriteJob = ioScope.launch {
188
- val modifiedItem = withContext(Dispatchers.IO) {
189
- val localImages = item.images.map { image ->
190
- val bitmap = lookup(image.url)
191
- if (bitmap != null) {
192
- val localUrl = writeTempImage(bitmap, image.url)
193
- if (localUrl != null) CarouselImage(url = localUrl, alt = image.alt)
194
- else image
195
- } else {
196
- image
197
- }
245
+ withContext(Dispatchers.IO) {
246
+ item.images.forEach { image ->
247
+ if (gen != configureGeneration) return@forEach
248
+ lookup(image.url)?.let { writeTempImage(it, image.url) }
198
249
  }
199
- ImageCarouselItem(
200
- id = item.id,
201
- images = localImages,
202
- caption = item.caption,
203
- title = item.title,
204
- description = item.description,
205
- author = item.author,
206
- section = item.section,
207
- articleUrl = item.articleUrl,
208
- )
209
- }
210
-
211
- // Back on Main — only update if this configure() is still current
212
- if (gen != configureGeneration) return@launch
213
- val hasLocalImages = modifiedItem.images.any { it.url.startsWith("file://") }
214
- if (hasLocalImages) {
215
- val localJson = Json.encodeToString(modifiedItem)
216
- cachedItemJSON = localJson
217
- pushProps()
218
250
  }
219
251
  }
220
252
  }
@@ -257,6 +289,21 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
257
289
  applySurfaceProps(bundle)
258
290
  }
259
291
 
292
+ /**
293
+ * Emit onCarouselItemChanged for a React diff update on cell reuse.
294
+ * Replaces pushProps() on subsequent item changes to avoid Fabric remount.
295
+ * Payload shape mirrors iOS ReactCarouselOverlayHost.swift:151-158.
296
+ */
297
+ private fun emitItemChanged(json: String) {
298
+ val params = createMap().apply {
299
+ putString("surfaceId", surfaceId)
300
+ putString("item", json)
301
+ putBoolean("isActive", false)
302
+ putInt("activeImageIndex", 0)
303
+ }
304
+ emitEvent("onCarouselItemChanged", params)
305
+ }
306
+
260
307
  /** Apply props to the surface, handling all lifecycle states. */
261
308
  private fun applySurfaceProps(bundle: Bundle) {
262
309
  val s = surface
@@ -374,11 +421,6 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
374
421
  private fun createSurfaceIfNeeded() {
375
422
  if (surface != null) return
376
423
 
377
- val reactHost = (context.applicationContext as? ReactApplication)?.reactHost
378
- if (reactHost == null) {
379
- android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
380
- return
381
- }
382
424
  val moduleName = "ShortKitCarouselOverlay_$carouselOverlayName"
383
425
 
384
426
  // Pass pending props as initial props so the JS component has data on first render.
@@ -387,7 +429,13 @@ class ReactCarouselOverlayHost(context: Context) : FrameLayout(context), Carouse
387
429
  val initialProps = pendingProps
388
430
  pendingProps = null
389
431
 
390
- val newSurface = reactHost.createSurface(context, moduleName, initialProps)
432
+ val newSurface = createSurface(moduleName, initialProps)
433
+ if (newSurface == null) {
434
+ android.util.Log.e(TAG, "createSurface FAILED: reactHost is null")
435
+ // Restore pending props so a future createSurfaceIfNeeded() can retry.
436
+ if (initialProps != null) pendingProps = initialProps
437
+ return
438
+ }
391
439
  surface = newSurface
392
440
 
393
441
  newSurface.view?.let { surfaceView ->
@@ -99,6 +99,21 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
99
99
  private var cachedActiveCue: JSONObject? = null
100
100
  private var cachedFeedScrollPhase: String? = null
101
101
 
102
+ /**
103
+ * Tracks active drag phase. While true, per-frame player-state emissions
104
+ * (playerState, isMuted, playbackRate, captionsEnabled, activeCue, time)
105
+ * are suppressed; emitFullState() re-syncs cached values on settle.
106
+ *
107
+ * Mirrors iOS ReactOverlayHost.swift:51 isDragging field.
108
+ */
109
+ internal var isDragging: Boolean = false
110
+ private set
111
+
112
+ /** Test-only setter for [isDragging]. */
113
+ internal var isDraggingForTest: Boolean
114
+ get() = isDragging
115
+ set(value) { isDragging = value }
116
+
102
117
  // Time coalescing (250ms)
103
118
  private var cachedCurrentTime: Double = 0.0
104
119
  private var cachedDuration: Double = 0.0
@@ -184,10 +199,17 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
184
199
  cachedCaptionsEnabled = false
185
200
  cachedActiveCue = null
186
201
  cachedFeedScrollPhase = null
202
+ isDragging = false
187
203
 
188
204
  if (surface == null) {
189
205
  createSurfaceIfNeeded()
190
- surfaceHasStarted = true
206
+ // Only flip the flag if surface was actually created. If reactHost was
207
+ // null (createSurfaceIfNeeded silently returned), surface remains null
208
+ // and the next configure() will retry. Mirrors iOS pattern and matches
209
+ // the carousel host fix from 6e5e1dce.
210
+ if (surface != null) {
211
+ surfaceHasStarted = true
212
+ }
191
213
  } else if (!surfaceHasStarted) {
192
214
  (surface as? ReactSurfaceImpl)?.updateInitProps(buildInitialPropsBundle())
193
215
  surfaceHasStarted = true
@@ -410,7 +432,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
410
432
  scope.launch {
411
433
  player.playerState.collect { state ->
412
434
  cachedPlayerState = ShortKitBridge.playerStateString(state)
413
- if (isActive) {
435
+ if (isActive && !isDragging) {
414
436
  val params = Arguments.createMap().apply {
415
437
  putString("surfaceId", surfaceId)
416
438
  putString("playerState", cachedPlayerState)
@@ -424,7 +446,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
424
446
  scope.launch {
425
447
  player.isMuted.collect { muted ->
426
448
  cachedIsMuted = muted
427
- if (isActive) {
449
+ if (isActive && !isDragging) {
428
450
  val params = Arguments.createMap().apply {
429
451
  putString("surfaceId", surfaceId)
430
452
  putBoolean("isMuted", cachedIsMuted)
@@ -438,7 +460,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
438
460
  scope.launch {
439
461
  player.playbackRate.collect { rate ->
440
462
  cachedPlaybackRate = rate.toDouble()
441
- if (isActive) {
463
+ if (isActive && !isDragging) {
442
464
  val params = Arguments.createMap().apply {
443
465
  putString("surfaceId", surfaceId)
444
466
  putDouble("playbackRate", cachedPlaybackRate)
@@ -452,7 +474,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
452
474
  scope.launch {
453
475
  player.captionsEnabled.collect { enabled ->
454
476
  cachedCaptionsEnabled = enabled
455
- if (isActive) {
477
+ if (isActive && !isDragging) {
456
478
  val params = Arguments.createMap().apply {
457
479
  putString("surfaceId", surfaceId)
458
480
  putBoolean("captionsEnabled", cachedCaptionsEnabled)
@@ -474,7 +496,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
474
496
  } else {
475
497
  null
476
498
  }
477
- if (isActive) {
499
+ if (isActive && !isDragging) {
478
500
  val params = Arguments.createMap().apply {
479
501
  putString("surfaceId", surfaceId)
480
502
  val cueJson = cachedActiveCue?.toString()
@@ -495,7 +517,7 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
495
517
  cachedCurrentTime = time.currentMs / 1000.0
496
518
  cachedDuration = time.durationMs / 1000.0
497
519
  cachedBuffered = time.bufferedMs / 1000.0
498
- if (isActive) {
520
+ if (isActive && !isDragging) {
499
521
  timeDirty = true
500
522
  }
501
523
  }
@@ -511,6 +533,23 @@ class ReactOverlayHost(context: Context) : FrameLayout(context), FeedOverlay {
511
533
  }.toString()
512
534
  is FeedScrollPhase.Settled -> """{"phase":"settled"}"""
513
535
  }
536
+ when (phase) {
537
+ is FeedScrollPhase.Dragging -> {
538
+ isDragging = true
539
+ stopTimeCoalescing()
540
+ }
541
+ is FeedScrollPhase.Settled -> {
542
+ val wasDragging = isDragging
543
+ isDragging = false
544
+ if (isActive) {
545
+ startTimeCoalescing()
546
+ if (wasDragging) {
547
+ emitFullState()
548
+ return@collect // emitFullState includes feedScrollPhase
549
+ }
550
+ }
551
+ }
552
+ }
514
553
  if (isActive) {
515
554
  val params = Arguments.createMap().apply {
516
555
  putString("surfaceId", surfaceId)