@shortkitsdk/react-native 0.2.24 → 0.2.26

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/README.md +151 -0
  2. package/android/libs/shortkit-release.aar +0 -0
  3. package/android/src/main/java/com/shortkit/reactnative/ReactOverlayHost.kt +19 -1
  4. package/android/src/main/java/com/shortkit/reactnative/ShortKitModule.kt +43 -0
  5. package/ios/ReactCarouselOverlayHost.swift +51 -3
  6. package/ios/ReactOverlayHost.swift +67 -7
  7. package/ios/ReactVideoCarouselOverlayHost.swift +181 -19
  8. package/ios/SKFabricSurfaceWrapper.mm +7 -1
  9. package/ios/ShortKitBridge.swift +85 -5
  10. package/ios/ShortKitFeedView.swift +70 -4
  11. package/ios/ShortKitFeedViewManager.mm +3 -0
  12. package/ios/ShortKitModule.mm +46 -3
  13. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  14. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +5273 -337
  15. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +151 -7
  16. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  17. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +151 -7
  18. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  19. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  20. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  21. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +5273 -337
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +151 -7
  23. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  24. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +151 -7
  25. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +5273 -337
  26. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +151 -7
  27. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  28. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +151 -7
  29. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  30. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  31. package/package.json +1 -1
  32. package/src/ShortKitCarouselOverlaySurface.tsx +38 -10
  33. package/src/ShortKitCommands.ts +4 -0
  34. package/src/ShortKitFeed.tsx +65 -10
  35. package/src/ShortKitOverlaySurface.tsx +59 -23
  36. package/src/ShortKitProvider.tsx +2 -1
  37. package/src/ShortKitVideoCarouselOverlaySurface.tsx +51 -5
  38. package/src/index.ts +2 -0
  39. package/src/serialization.ts +37 -1
  40. package/src/specs/NativeShortKitModule.ts +68 -3
  41. package/src/specs/ShortKitFeedViewNativeComponent.ts +11 -0
  42. package/src/types.ts +85 -3
  43. package/src/useShortKitCarousel.ts +80 -0
@@ -13,7 +13,9 @@ import ShortKitSDK
13
13
  // MARK: - Configuration
14
14
 
15
15
  var surfacePresenter: AnyObject?
16
- weak var bridge: ShortKitBridge?
16
+ weak var bridge: ShortKitBridge? {
17
+ didSet { subscribeToCarouselCompletion() }
18
+ }
17
19
 
18
20
  /// Module name for the RCTFabricSurface. Set by the overlay factory
19
21
  /// based on the feed config's video carousel overlay name.
@@ -37,16 +39,36 @@ import ShortKitSDK
37
39
  /// Cached carouselItem JSON — setProperties replaces all props, doesn't merge.
38
40
  private var carouselItemJSON: String?
39
41
 
42
+ /// Currently configured carousel item — used to detect item transitions
43
+ /// so we can emit an event instead of triggering a full Fabric remount.
44
+ private var currentCarouselItem: VideoCarouselItem?
45
+
46
+ /// Whether initial props have been pushed to the surface at least once.
47
+ /// First mount must go through setProperties (for item + surfaceId + initial
48
+ /// state). Subsequent item changes use the event path.
49
+ private var hasPushedInitialProps: Bool = false
50
+
40
51
  // Player state
41
52
  private var player: ShortKitPlayer?
42
53
  private var cancellables = Set<AnyCancellable>()
54
+ /// Separate cancellable set for the carousel-completion subscription.
55
+ /// Kept out of `cancellables` so `subscribeToPlayer`'s removeAll() doesn't
56
+ /// wipe it out — the completion subscription is wired once at bridge didSet
57
+ /// time and must survive player re-attachments.
58
+ private var carouselCompletionCancellables = Set<AnyCancellable>()
43
59
  private var isActive = false
60
+ private var isDragging: Bool = false // FIX: track drag phase
44
61
  private var cachedPlayerState: String = "idle"
45
62
  private var cachedIsMuted: Bool = true
46
63
  private var cachedTime: (current: Double, duration: Double, buffered: Double) = (0, 0, 0)
47
64
  private var timeCoalesceTimer: Timer?
48
65
  private var timeDirty = false
49
66
 
67
+ // Tracks the last bounds.size pushed to the surface. Used in layoutSubviews
68
+ // to skip redundant setSize calls that would otherwise trigger a Fabric
69
+ // layout recalc on every frame during scroll.
70
+ private var lastLayoutSize: CGSize = .zero
71
+
50
72
  // MARK: - Init
51
73
 
52
74
  override init(frame: CGRect) {
@@ -67,12 +89,21 @@ import ShortKitSDK
67
89
  // MARK: - VideoCarouselOverlay
68
90
 
69
91
  public func configure(with item: VideoCarouselItem) {
92
+ let isSameItem = item.id == currentCarouselItem?.id
93
+ currentCarouselItem = item
94
+
70
95
  isActive = false
96
+ isDragging = false
71
97
  timeDirty = false
72
98
  timeCoalesceTimer?.invalidate()
73
99
  timeCoalesceTimer = nil
100
+ // Reset cached state so recycled cells don't flash stale values from
101
+ // the previous item's player. The new player's publishers will re-emit
102
+ // current values after attach(), so the defaults are only visible for
103
+ // the single frame between configure() and the first publisher tick.
74
104
  cachedTime = (0, 0, 0)
75
105
  cachedPlayerState = "idle"
106
+ cachedIsMuted = true
76
107
 
77
108
  createSurfaceIfNeeded()
78
109
 
@@ -80,6 +111,35 @@ import ShortKitSDK
80
111
  let json = String(data: data, encoding: .utf8) else { return }
81
112
  carouselItemJSON = json
82
113
 
114
+ // Surface lifecycle on item change:
115
+ // - First mount: pushInitialProperties() via setProperties (unavoidable
116
+ // — React needs props at mount time). ALSO emit
117
+ // onVideoCarouselItemChanged so external subscribers that live
118
+ // outside the overlay's React tree (e.g. useShortKitCarousel() hook
119
+ // consumers) see the initial carousel too. The in-surface subscriber
120
+ // at ShortKitVideoCarouselOverlaySurface.tsx:153 is sid-filtered
121
+ // and sets the same JSON-parsed item, so it's a redundant second
122
+ // set with identical value — safe.
123
+ // - Subsequent item change with surface ready: emit event for React
124
+ // diff (no remount).
125
+ // - Same item: no-op.
126
+ if !hasPushedInitialProps {
127
+ let props = buildInitialProps(item: item, json: json)
128
+ if let surface {
129
+ surface.setProperties(props)
130
+ hasPushedInitialProps = true
131
+ } else {
132
+ pendingProps = props
133
+ }
134
+ emitItemChanged(item: item, json: json)
135
+ } else if !isSameItem {
136
+ emitItemChanged(item: item, json: json)
137
+ }
138
+ }
139
+
140
+ /// Build the initial-properties dictionary for an item. Used on first mount
141
+ /// (via setProperties) and shadowed by emitItemChanged() on subsequent changes.
142
+ private func buildInitialProps(item: VideoCarouselItem, json: String) -> [String: Any] {
83
143
  var props: [String: Any] = [
84
144
  "surfaceId": surfaceId,
85
145
  "carouselItem": json,
@@ -93,11 +153,32 @@ import ShortKitSDK
93
153
  props["activeVideo"] = videoJSON
94
154
  props["activeVideoIndex"] = 0
95
155
  }
96
- if let surface {
97
- surface.setProperties(props)
156
+ return props
157
+ }
158
+
159
+ /// Emit onVideoCarouselItemChanged with full initial state for the new item.
160
+ /// Replaces setProperties() on cell reuse, avoiding a full Fabric root remount.
161
+ private func emitItemChanged(item: VideoCarouselItem, json: String) {
162
+ var body: [String: Any] = [
163
+ "surfaceId": surfaceId,
164
+ "carouselItem": json,
165
+ "isActive": false,
166
+ "playerState": "idle",
167
+ "isMuted": cachedIsMuted,
168
+ "activeVideoIndex": 0,
169
+ ]
170
+ if let firstVideo = item.videos.first,
171
+ let videoData = try? JSONEncoder().encode(firstVideo),
172
+ let videoJSON = String(data: videoData, encoding: .utf8) {
173
+ body["activeVideo"] = videoJSON
98
174
  } else {
99
- pendingProps = props
175
+ // Match buildInitialProps: omit the key rather than emitting an
176
+ // empty-string sentinel that JS treats as falsy (overlay would
177
+ // disappear on every cell reuse with a zero-video carousel).
178
+ body["activeVideo"] = NSNull()
100
179
  }
180
+
181
+ bridge?.emit("onVideoCarouselItemChanged", body: body)
101
182
  }
102
183
 
103
184
  public func updateActiveVideo(index: Int, item: ContentItem) {
@@ -147,17 +228,20 @@ import ShortKitSDK
147
228
 
148
229
  private func subscribeToPlayer(_ player: ShortKitPlayer) {
149
230
  cancellables.removeAll()
231
+ // FIX: playerState, isMuted, and time are suppressed during .dragging to
232
+ // avoid unnecessary bridge traffic while scrolling. emitFullState() on
233
+ // drag→settled re-syncs cached values.
234
+
150
235
  player.playerState
151
236
  .receive(on: DispatchQueue.main)
152
237
  .sink { [weak self] state in
153
238
  guard let self else { return }
154
239
  self.cachedPlayerState = Self.playerStateString(state)
155
- if self.isActive {
156
- self.bridge?.emit("onOverlayPlayerStateChanged", body: [
157
- "surfaceId": self.surfaceId,
158
- "playerState": self.cachedPlayerState
159
- ])
160
- }
240
+ guard self.isActive, !self.isDragging else { return }
241
+ self.bridge?.emit("onOverlayPlayerStateChanged", body: [
242
+ "surfaceId": self.surfaceId,
243
+ "playerState": self.cachedPlayerState
244
+ ])
161
245
  }
162
246
  .store(in: &cancellables)
163
247
 
@@ -166,23 +250,90 @@ import ShortKitSDK
166
250
  .sink { [weak self] muted in
167
251
  guard let self else { return }
168
252
  self.cachedIsMuted = muted
169
- if self.isActive {
170
- self.bridge?.emit("onOverlayMutedChanged", body: [
171
- "surfaceId": self.surfaceId,
172
- "isMuted": self.cachedIsMuted
173
- ])
174
- }
253
+ guard self.isActive, !self.isDragging else { return }
254
+ self.bridge?.emit("onOverlayMutedChanged", body: [
255
+ "surfaceId": self.surfaceId,
256
+ "isMuted": self.cachedIsMuted
257
+ ])
175
258
  }
176
259
  .store(in: &cancellables)
177
260
 
178
261
  player.time
179
262
  .receive(on: DispatchQueue.main)
180
263
  .sink { [weak self] time in
181
- guard let self, self.isActive else { return }
264
+ guard let self, self.isActive, !self.isDragging else { return }
182
265
  self.cachedTime = (time.current, time.duration, time.buffered)
183
266
  self.timeDirty = true
184
267
  }
185
268
  .store(in: &cancellables)
269
+
270
+ // FIX: subscribe to feedScrollPhase so we know when a drag starts/settles.
271
+ // On drag start: invalidate the time coalescing timer.
272
+ // On settle: restart timer and re-sync state via emitFullState().
273
+ player.feedScrollPhase
274
+ .receive(on: DispatchQueue.main)
275
+ .sink { [weak self] phase in
276
+ guard let self else { return }
277
+ switch phase {
278
+ case .dragging:
279
+ self.isDragging = true
280
+ // Stop the time coalescing timer during drags. Time updates
281
+ // are suppressed while isDragging is true, so the timer
282
+ // would just wake the main thread to check timeDirty.
283
+ self.timeCoalesceTimer?.invalidate()
284
+ self.timeCoalesceTimer = nil
285
+ case .settled:
286
+ let wasDragging = self.isDragging
287
+ self.isDragging = false
288
+ if self.isActive {
289
+ self.startTimeCoalescing()
290
+ if wasDragging {
291
+ // Re-sync state that was suppressed during the drag.
292
+ self.emitFullState()
293
+ }
294
+ }
295
+ }
296
+ }
297
+ .store(in: &cancellables)
298
+ }
299
+
300
+ // MARK: - Carousel Completion
301
+
302
+ /// Subscribe to the carousel's activeVideoCompleted publisher and emit
303
+ /// `onCarouselActiveVideoCompleted` with this host's surfaceId.
304
+ /// Called via `bridge` didSet so the subscription is wired once per host
305
+ /// instance, mirroring how `onVideoCarouselActiveVideoChanged` is emitted.
306
+ private func subscribeToCarouselCompletion() {
307
+ guard let carousel = bridge?.sdk?.carousel else { return }
308
+ carousel.activeVideoCompleted
309
+ .receive(on: DispatchQueue.main)
310
+ .sink { [weak self] event in
311
+ guard let self, self.isActive,
312
+ event.carouselItem.id == self.currentCarouselItem?.id else { return }
313
+ let contentItemJSON: String
314
+ if let data = try? JSONEncoder().encode(event.contentItem),
315
+ let json = String(data: data, encoding: .utf8) {
316
+ contentItemJSON = json
317
+ } else {
318
+ contentItemJSON = "{}"
319
+ }
320
+ let carouselItemJSON: String
321
+ if let data = try? JSONEncoder().encode(event.carouselItem),
322
+ let json = String(data: data, encoding: .utf8) {
323
+ carouselItemJSON = json
324
+ } else {
325
+ carouselItemJSON = "{}"
326
+ }
327
+ self.bridge?.emit("onCarouselActiveVideoCompleted", body: [
328
+ "surfaceId": self.surfaceId,
329
+ "contentItem": contentItemJSON,
330
+ "indexInCarousel": event.indexInCarousel,
331
+ "carouselItem": carouselItemJSON,
332
+ "wasLast": event.wasLast,
333
+ "willAutoAdvance": event.willAutoAdvance,
334
+ ])
335
+ }
336
+ .store(in: &carouselCompletionCancellables)
186
337
  }
187
338
 
188
339
  // MARK: - Time Coalescing
@@ -190,7 +341,10 @@ import ShortKitSDK
190
341
  private func startTimeCoalescing() {
191
342
  timeCoalesceTimer?.invalidate()
192
343
  timeCoalesceTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
193
- guard let self, self.timeDirty else { return }
344
+ guard let self else { return }
345
+ // Timer is invalidated on drag start; this guard is belt-and-suspenders.
346
+ if self.isDragging { return }
347
+ guard self.timeDirty else { return }
194
348
  self.timeDirty = false
195
349
  self.bridge?.emit("onOverlayTimeUpdate", body: [
196
350
  "surfaceId": self.surfaceId,
@@ -251,6 +405,7 @@ import ShortKitSDK
251
405
  if let pending = pendingProps {
252
406
  surf.setProperties(pending)
253
407
  pendingProps = nil
408
+ hasPushedInitialProps = true
254
409
  }
255
410
  }
256
411
 
@@ -261,8 +416,15 @@ import ShortKitSDK
261
416
  guard let surface else { return }
262
417
  let size = bounds.size
263
418
  guard size.width > 0, size.height > 0 else { return }
419
+
420
+ // Skip setSize when bounds haven't changed. UICollectionView calls
421
+ // layoutSubviews every frame during scroll; without this guard we'd
422
+ // trigger a Fabric layout recalc each time.
423
+ guard size != lastLayoutSize else { return }
424
+ lastLayoutSize = size
425
+
264
426
  surface.setMinimumSize(size)
265
- surface.setMaximumSize(size)
427
+ // setMaximumSize is a no-op in SKFabricSurfaceWrapper — not called.
266
428
  }
267
429
 
268
430
  // MARK: - Helpers
@@ -5,6 +5,7 @@
5
5
  @implementation SKFabricSurfaceWrapper {
6
6
  RCTFabricSurface *_surface;
7
7
  NSDictionary *_lastProperties;
8
+ CGSize _lastSize;
8
9
  }
9
10
 
10
11
  - (nullable instancetype)initWithPresenter:(nonnull id)surfacePresenter
@@ -46,7 +47,12 @@
46
47
  }
47
48
 
48
49
  - (void)setMinimumSize:(CGSize)size {
49
- // RCTFabricSurface min/max are readonly; use setSize: to set both.
50
+ // Skip when size hasn't changed belt-and-suspenders guard in case a
51
+ // caller other than the overlay hosts invokes this during scroll.
52
+ if (CGSizeEqualToSize(_lastSize, size)) {
53
+ return;
54
+ }
55
+ _lastSize = size;
50
56
  [_surface setSize:size];
51
57
  }
52
58
 
@@ -70,6 +70,37 @@ import ShortKitSDK
70
70
  self?.emitOnMain("onFeedReady", body: ["feedId": id])
71
71
  }
72
72
 
73
+ // Wire per-feed refresh state callback (scoped by feedId)
74
+ vc.onRefreshStateChanged = { [weak self] state in
75
+ NSLog("[ShortKit Bridge] onRefreshStateChangedPerFeed feedId=%@ status=%@", id, "\(state)")
76
+ var body: [String: Any] = ["feedId": id]
77
+ switch state {
78
+ case .idle:
79
+ body["status"] = "idle"
80
+ body["progress"] = 0.0
81
+ case .pulling(let progress):
82
+ body["status"] = "pulling"
83
+ body["progress"] = progress
84
+ case .triggered:
85
+ body["status"] = "triggered"
86
+ body["progress"] = 0.0
87
+ case .refreshing:
88
+ body["status"] = "refreshing"
89
+ body["progress"] = 0.0
90
+ }
91
+ self?.emitOnMain("onRefreshStateChangedPerFeed", body: body)
92
+ }
93
+
94
+ // Wire per-feed video-carousel cell tap event
95
+ vc.onVideoCarouselCellTap = { [weak self] payload in
96
+ self?.emitOnMain("onVideoCarouselCellTap", body: [
97
+ "feedId": id,
98
+ "id": payload.id,
99
+ "index": payload.index,
100
+ "pageIndex": payload.pageIndex,
101
+ ])
102
+ }
103
+
73
104
  // Replay buffered operations on the next run-loop tick so the VC's
74
105
  // view hierarchy is fully set up after didMoveToWindow returns.
75
106
  if let ops = pendingOps.removeValue(forKey: id) {
@@ -207,6 +238,44 @@ import ShortKitSDK
207
238
  shortKit?.player.skipToPrevious()
208
239
  }
209
240
 
241
+ // MARK: - Carousel Commands
242
+
243
+ /// Advance the active carousel to the next video.
244
+ /// Returns NSNumber wrapping Bool: @(YES) on success, @(NO) when no carousel active.
245
+ @objc public func carouselNext() -> NSNumber {
246
+ return NSNumber(value: shortKit?.carousel.next() ?? false)
247
+ }
248
+
249
+ /// Move the active carousel to the previous video.
250
+ @objc public func carouselPrevious() -> NSNumber {
251
+ return NSNumber(value: shortKit?.carousel.previous() ?? false)
252
+ }
253
+
254
+ /// Jump the active carousel to a specific zero-based index.
255
+ ///
256
+ /// Accepts `Int` (bridged to ObjC `NSInteger` / encoding `q`), matching
257
+ /// what RN TurboModule codegen emits for a TS `Int32` parameter. Declaring
258
+ /// this with `NSNumber *` crashed at the Swift @objc thunk's ARC retain
259
+ /// because the codegen wrote a raw int into the NSInvocation slot, which
260
+ /// the thunk interpreted as an object pointer.
261
+ @objc public func carouselSetActiveIndex(_ index: Int) -> NSNumber {
262
+ return NSNumber(value: shortKit?.carousel.setActiveIndex(index) ?? false)
263
+ }
264
+
265
+ // MARK: - Carousel Accessors
266
+
267
+ /// Zero-based index of the currently playing carousel video, or -1 when no carousel is active.
268
+ @objc public func getCarouselActiveIndex() -> NSNumber {
269
+ guard let idx = shortKit?.carousel.activeIndexValue else { return NSNumber(value: -1) }
270
+ return NSNumber(value: idx)
271
+ }
272
+
273
+ /// Total number of videos in the active carousel, or 0 when no carousel is active.
274
+ @objc public func getCarouselVideoCount() -> NSNumber {
275
+ guard let count = shortKit?.carousel.videoCountValue else { return NSNumber(value: 0) }
276
+ return NSNumber(value: count)
277
+ }
278
+
210
279
  @objc public func setMuted(_ muted: Bool) {
211
280
  shortKit?.player.setMuted(muted)
212
281
  }
@@ -234,20 +303,27 @@ import ShortKitSDK
234
303
 
235
304
  // MARK: - Custom Feed
236
305
 
237
- @objc public func setFeedItems(_ feedId: String, items json: String) {
306
+ @objc public func setFeedItems(_ feedId: String, items json: String, startAtId: String?) {
238
307
  guard let items = Self.parseFeedInputs(json) else { return }
239
308
  DispatchQueue.main.async { [weak self] in
240
309
  guard let self else { return }
241
310
  if let vc = self.feedViewController(for: feedId) {
242
- vc.setFeedItems(items)
311
+ vc.setFeedItems(items, startAtId: startAtId)
243
312
  } else {
244
313
  self.pendingOps[feedId, default: []].append { vc in
245
- vc.setFeedItems(items)
314
+ vc.setFeedItems(items, startAtId: startAtId)
246
315
  }
247
316
  }
248
317
  }
249
318
  }
250
319
 
320
+ @objc public func scrollFeedToItem(_ feedId: String, id: String, animated: Bool) {
321
+ DispatchQueue.main.async { [weak self] in
322
+ guard let self else { return }
323
+ self.feedViewController(for: feedId)?.scrollToItem(id: id, animated: animated)
324
+ }
325
+ }
326
+
251
327
  @objc public func appendFeedItems(_ feedId: String, items json: String) {
252
328
  guard let items = Self.parseFeedInputs(json) else { return }
253
329
  DispatchQueue.main.async { [weak self] in
@@ -552,6 +628,9 @@ import ShortKitSDK
552
628
  .store(in: &cancellables)
553
629
 
554
630
  // Remaining content count — now handled per-feed via onRemainingContentCountChange callback
631
+
632
+ // Carousel video completion is emitted from ReactVideoCarouselOverlayHost
633
+ // using that host's surfaceId, so useOverlayEvent subscribers can match it.
555
634
  }
556
635
 
557
636
  // MARK: - Event Emission Helpers
@@ -869,7 +948,7 @@ import ShortKitSDK
869
948
  }
870
949
 
871
950
  /// Parse a JSON string of FeedInput[] from the JS bridge.
872
- private static func parseFeedInputs(_ json: String) -> [FeedInput]? {
951
+ static func parseFeedInputs(_ json: String) -> [FeedInput]? {
873
952
  guard let data = json.data(using: .utf8),
874
953
  let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
875
954
  return nil
@@ -881,8 +960,9 @@ import ShortKitSDK
881
960
  switch type {
882
961
  case "video":
883
962
  guard let playbackId = obj["playbackId"] as? String else { continue }
963
+ let origin = (obj["origin"] as? String).flatMap { ContentOrigin(rawValue: $0) } ?? .other
884
964
  let fallbackUrl = obj["fallbackUrl"] as? String
885
- result.append(.video(playbackId: playbackId, fallbackUrl: fallbackUrl))
965
+ result.append(.video(playbackId: playbackId, origin: origin, fallbackUrl: fallbackUrl))
886
966
  case "imageCarousel":
887
967
  guard let itemData = obj["item"],
888
968
  let itemJSON = try? JSONSerialization.data(withJSONObject: itemData),
@@ -20,6 +20,40 @@ import ShortKitSDK
20
20
  didSet { /* used at init time only */ }
21
21
  }
22
22
 
23
+ /// URL of the thumbnail the host app is already rendering for the item that
24
+ /// will be the first active cell. Looked up synchronously via SDWebImage's
25
+ /// memory cache (supports expo-image / FastImage clients) and seeded onto
26
+ /// the feed VC before viewDidLoad fires. Falls back to patchMissingThumbnail
27
+ /// (network fetch) on cache miss.
28
+ @objc public var seedThumbnailUrl: String? {
29
+ didSet { /* used at embed time only */ }
30
+ }
31
+
32
+ /// JSON-serialized FeedInput[] for initial custom-feed items at mount time.
33
+ /// Parsed and wrapped in FeedPreload(immediateItems:) at embed time so the
34
+ /// SDK takes the sync render path — no async preload wait, no ref-attach
35
+ /// race, first cell dequeued on the first run-loop tick.
36
+ /// When preloadId is also set, preloadId takes precedence.
37
+ @objc public var feedItemsJSON: String? {
38
+ didSet { /* used at embed time only */ }
39
+ }
40
+
41
+ /// When the consumer explicitly manages this prop, it becomes the
42
+ /// authoritative suspension signal — overriding willMove(toWindow:).
43
+ private var isActiveManagedByProp = false
44
+
45
+ @objc public var active: Bool = true {
46
+ didSet {
47
+ guard active != oldValue, let feedVC = feedViewController else { return }
48
+ if !active { isActiveManagedByProp = true }
49
+ if active {
50
+ feedVC.activate()
51
+ } else {
52
+ feedVC.deactivate()
53
+ }
54
+ }
55
+ }
56
+
23
57
  @objc var feedId: String?
24
58
 
25
59
  // MARK: - Child VC
@@ -39,7 +73,7 @@ import ShortKitSDK
39
73
  public override func willMove(toWindow newWindow: UIWindow?) {
40
74
  super.willMove(toWindow: newWindow)
41
75
 
42
- if newWindow == nil {
76
+ if newWindow == nil && !isActiveManagedByProp {
43
77
  suspendFeedViewController()
44
78
  }
45
79
  }
@@ -62,14 +96,18 @@ import ShortKitSDK
62
96
 
63
97
  // Re-attach an existing suspended VC (e.g. after native stack pop)
64
98
  if let feedVC = feedViewController {
99
+ NSLog("[ShortKit FeedView] re-attaching suspended VC feedId=%@", feedId ?? "nil")
65
100
  parentVC.addChild(feedVC)
66
101
  feedVC.view.frame = bounds
67
102
  addSubview(feedVC.view)
68
103
  feedVC.didMove(toParent: parentVC)
69
- feedVC.activate()
104
+ if !isActiveManagedByProp {
105
+ feedVC.activate()
106
+ }
70
107
  if let feedId = self.feedId {
71
108
  ShortKitBridge.shared?.registerFeed(id: feedId, viewController: feedVC)
72
109
  }
110
+ NSLog("[ShortKit FeedView] re-attach complete — activate() called")
73
111
  return
74
112
  }
75
113
 
@@ -90,8 +128,14 @@ import ShortKitSDK
90
128
  NSLog("[ShortKit FeedView] ❌ No preload handle found for feedId: %@", preloadId)
91
129
  NSLog("[ShortKit FeedView] Available preload handles: %@", ShortKitBridge.shared?.preloadHandles.keys.joined(separator: ", ") ?? "none")
92
130
  }
131
+ } else if let json = self.feedItemsJSON,
132
+ let items = ShortKitBridge.parseFeedInputs(json) {
133
+ // feedItems prop: wrap in an immediate preload (no async prefetch
134
+ // work — items are available synchronously at viewDidLoad).
135
+ feedConfig.preload = FeedPreload(immediateItems: items)
136
+ NSLog("[ShortKit FeedView] feedItems prop: created immediate preload with %d items", items.count)
93
137
  } else {
94
- NSLog("[ShortKit FeedView] No preloadId prop set")
138
+ NSLog("[ShortKit FeedView] No preloadId or feedItems prop set")
95
139
  }
96
140
 
97
141
  NSLog("[ShortKit FeedView] feedConfig.preload is %@", feedConfig.preload != nil ? "SET" : "NIL")
@@ -104,6 +148,17 @@ import ShortKitSDK
104
148
  let feedVC = ShortKitFeedViewController(shortKit: sdk, config: feedConfig, startAtItemId: startAtItemId)
105
149
  feedVC.setBridgeManaged()
106
150
 
151
+ // Seed a thumbnail from the host app's image cache so the first cell
152
+ // renders with a visible thumbnail from frame zero. Synchronous
153
+ // SDWebImage lookup (memory then disk, via reflection) — no network.
154
+ // If nil (cache miss or SDWebImage not linked), the existing
155
+ // patchMissingThumbnailIfNeeded helper handles the fallback at
156
+ // feed-open time.
157
+ if let url = self.seedThumbnailUrl,
158
+ let image = SeedThumbnailResolver.resolveFromMemory(url: url) {
159
+ feedVC.seedThumbnail = image
160
+ }
161
+
107
162
  if sdk.debugPanelEnabled {
108
163
  feedVC.debugPanelFactory = { active, prev, next in
109
164
  let panel = DebugPanelView(frame: CGRect(
@@ -135,6 +190,12 @@ import ShortKitSDK
135
190
  feedVC.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
136
191
  addSubview(feedVC.view)
137
192
  feedVC.didMove(toParent: parentVC)
193
+
194
+ // Fabric sets props BEFORE didMoveToWindow, so active=false may have
195
+ // fired its didSet while feedViewController was still nil. Apply now.
196
+ if isActiveManagedByProp && !active, let feedVC = feedViewController {
197
+ feedVC.deactivate()
198
+ }
138
199
  }
139
200
 
140
201
  /// Detach the feedVC from the parent VC hierarchy without destroying it.
@@ -142,15 +203,20 @@ import ShortKitSDK
142
203
  /// (e.g. pushing a new screen on top). The feedVC and its state are preserved
143
204
  /// so they can be re-attached when the view returns to the window.
144
205
  private func suspendFeedViewController() {
206
+ NSLog("[ShortKit FeedView] suspendFeedViewController ENTRY feedId=%@", feedId ?? "nil")
145
207
  if let feedId = self.feedId {
146
208
  ShortKitBridge.shared?.unregisterFeed(id: feedId)
147
209
  }
148
- guard let feedVC = feedViewController else { return }
210
+ guard let feedVC = feedViewController else {
211
+ NSLog("[ShortKit FeedView] suspendFeedViewController NOOP — no feedVC")
212
+ return
213
+ }
149
214
 
150
215
  feedVC.deactivate()
151
216
  feedVC.willMove(toParent: nil)
152
217
  feedVC.view.removeFromSuperview()
153
218
  feedVC.removeFromParent()
219
+ NSLog("[ShortKit FeedView] suspendFeedViewController EXIT — VC retained for re-attach")
154
220
  // Keep feedViewController reference — re-attached in embedFeedViewControllerIfNeeded
155
221
  }
156
222
 
@@ -26,5 +26,8 @@ RCT_EXPORT_VIEW_PROPERTY(config, NSString)
26
26
  RCT_EXPORT_VIEW_PROPERTY(startAtItemId, NSString)
27
27
  RCT_EXPORT_VIEW_PROPERTY(preloadId, NSString)
28
28
  RCT_EXPORT_VIEW_PROPERTY(feedId, NSString)
29
+ RCT_EXPORT_VIEW_PROPERTY(seedThumbnailUrl, NSString)
30
+ RCT_EXPORT_VIEW_PROPERTY(feedItemsJSON, NSString)
31
+ RCT_EXPORT_VIEW_PROPERTY(active, BOOL)
29
32
 
30
33
  @end