@onekeyfe/react-native-native-list 3.0.157-alpha.254 → 3.0.157-alpha.258

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 (87) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/onekey/nativelist/HybridNativeList.kt +20 -0
  3. package/android/src/main/java/com/onekey/nativelist/NativeListActivityDetailsView.kt +179 -0
  4. package/android/src/main/java/com/onekey/nativelist/NativeListActivityRenderer.kt +23 -2
  5. package/android/src/main/java/com/onekey/nativelist/NativeListContainerSlotAdapter.kt +53 -0
  6. package/android/src/main/java/com/onekey/nativelist/NativeListEndAlignment.kt +5 -0
  7. package/android/src/main/java/com/onekey/nativelist/NativeListMediaPreviewSlot.kt +215 -0
  8. package/android/src/main/java/com/onekey/nativelist/NativeListMediaTileRenderer.kt +15 -6
  9. package/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +3 -1
  10. package/android/src/main/java/com/onekey/nativelist/NativeListScrollPositionTracker.kt +29 -0
  11. package/android/src/main/java/com/onekey/nativelist/NativeListSectionHeaderRenderer.kt +16 -1
  12. package/android/src/main/java/com/onekey/nativelist/NativeListView.kt +165 -26
  13. package/android/src/main/res/values/native_scroll_coordinator_ids.xml +5 -0
  14. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListEndAlignmentTest.kt +34 -0
  15. package/android/src/test/java/com/margelo/nitro/nativelist/NativeListScrollPositionTrackerTest.kt +28 -0
  16. package/docs/DESIGN.md +4 -2
  17. package/docs/ROW_TEMPLATES.md +45 -0
  18. package/docs/SPEC.md +241 -1
  19. package/docs/STYLE_SPEC.md +8 -0
  20. package/ios/HybridNativeList.swift +30 -0
  21. package/ios/NativeListActivityDetailsView.swift +229 -0
  22. package/ios/NativeListActivityRenderer.swift +24 -2
  23. package/ios/NativeListMediaPreviewSlot.swift +199 -0
  24. package/ios/NativeListMediaTileRenderer.swift +10 -4
  25. package/ios/NativeListModels.swift +4 -1
  26. package/ios/NativeListScrollPositionTracker.swift +30 -0
  27. package/ios/NativeListSectionHeaderRenderer.swift +27 -1
  28. package/ios/RNCNativeListView.swift +159 -4
  29. package/ios/tests/NativeListActivityPresentationTests.swift +43 -0
  30. package/ios/tests/NativeListLifecycleTests.swift +123 -0
  31. package/ios/tests/NativeListMediaVisibilityTests.swift +42 -0
  32. package/ios/tests/NativeListScrollPositionTrackerTests.swift +25 -0
  33. package/lib/module/NativeList.js +56 -3
  34. package/lib/module/NativeList.web.js +5 -0
  35. package/lib/module/validation.js +64 -4
  36. package/lib/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  37. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  38. package/lib/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  39. package/lib/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  40. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  41. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  42. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  43. package/lib/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  44. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  45. package/lib/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  46. package/lib/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  47. package/lib/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  48. package/lib/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  49. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  50. package/lib/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  51. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  52. package/lib/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  53. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  54. package/lib/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  55. package/lib/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  56. package/lib/typescript/src/NativeList.nitro.d.ts +3 -0
  57. package/lib/typescript/src/NativeList.types.d.ts +14 -1
  58. package/lib/typescript/src/models.d.ts +33 -5
  59. package/nitrogen/generated/android/c++/JFunc_void_bool.hpp +75 -0
  60. package/nitrogen/generated/android/c++/JHybridNativeListSpec.cpp +39 -3
  61. package/nitrogen/generated/android/c++/JHybridNativeListSpec.hpp +6 -0
  62. package/nitrogen/generated/android/c++/views/JHybridNativeListStateUpdater.cpp +15 -0
  63. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/Func_void_bool.kt +78 -0
  64. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/HybridNativeListSpec.kt +26 -0
  65. package/nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt +24 -9
  66. package/nitrogen/generated/android/nativelistOnLoad.cpp +2 -0
  67. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.cpp +8 -0
  68. package/nitrogen/generated/ios/NativeListModule-Swift-Cxx-Bridge.hpp +37 -0
  69. package/nitrogen/generated/ios/c++/HybridNativeListSpecSwift.hpp +23 -2
  70. package/nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm +32 -0
  71. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  72. package/nitrogen/generated/ios/swift/HybridNativeListSpec.swift +3 -0
  73. package/nitrogen/generated/ios/swift/HybridNativeListSpec_cxx.swift +54 -0
  74. package/nitrogen/generated/shared/c++/HybridNativeListSpec.cpp +6 -0
  75. package/nitrogen/generated/shared/c++/HybridNativeListSpec.hpp +8 -2
  76. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.cpp +6 -0
  77. package/nitrogen/generated/shared/c++/views/HybridNativeListComponent.hpp +11 -2
  78. package/nitrogen/generated/shared/json/NativeListConfig.json +3 -0
  79. package/package.json +6 -5
  80. package/scripts/enable-container-slots.mjs +58 -0
  81. package/scripts/enable-container-slots.test.mjs +58 -0
  82. package/src/NativeList.nitro.ts +3 -0
  83. package/src/NativeList.tsx +84 -2
  84. package/src/NativeList.types.ts +14 -1
  85. package/src/NativeList.web.tsx +5 -0
  86. package/src/models.ts +38 -1
  87. package/src/validation.ts +146 -4
@@ -0,0 +1,58 @@
1
+ // Nitro 0.37 emits leaf view managers. Container slots need explicit Fabric
2
+ // child ownership, reapplied deterministically after every nitrogen build.
3
+ import { readFileSync, writeFileSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
8
+ const android = join(root, 'nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt');
9
+ const ios = join(root, 'nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm');
10
+ const marker = '// NativeList container slot mounting';
11
+ let kotlin = readFileSync(android, 'utf8');
12
+ if (!kotlin.includes(marker)) {
13
+ if (!kotlin.includes('SimpleViewManager<View>')) throw new Error('Unexpected Nitro Android manager shape');
14
+ kotlin = kotlin.replace('import android.view.View\n', 'import android.view.View\nimport android.view.ViewGroup\n');
15
+ kotlin = kotlin.replace('import com.facebook.react.uimanager.SimpleViewManager', 'import com.facebook.react.uimanager.ViewGroupManager');
16
+ kotlin = kotlin.replace('SimpleViewManager<View>()', 'ViewGroupManager<ViewGroup>()');
17
+ kotlin = kotlin.replaceAll('view: View', 'view: ViewGroup');
18
+ kotlin = kotlin.replace('): View {', '): ViewGroup {').replace('): View? {', '): ViewGroup? {');
19
+ kotlin = kotlin.replace('val view = hybridView.view', 'val view = hybridView.view as ViewGroup');
20
+ kotlin = kotlin.replace('return hybridView.view', 'return hybridView.view as ViewGroup');
21
+ kotlin = kotlin.replace(' override fun getName(): String {', ` ${marker}
22
+ override fun needsCustomLayoutForChildren(): Boolean = true
23
+ override fun addView(parent: ViewGroup, child: View, index: Int) {
24
+ (parent as NativeListView).mountContainerSlot(child, index)
25
+ }
26
+ override fun getChildCount(parent: ViewGroup): Int = (parent as NativeListView).containerSlotCount()
27
+ override fun getChildAt(parent: ViewGroup, index: Int): View = (parent as NativeListView).containerSlotAt(index)
28
+ override fun removeViewAt(parent: ViewGroup, index: Int) {
29
+ (parent as NativeListView).unmountContainerSlot(index)
30
+ }
31
+ override fun removeAllViews(parent: ViewGroup) {
32
+ while (getChildCount(parent) > 0) removeViewAt(parent, getChildCount(parent) - 1)
33
+ }
34
+
35
+ override fun getName(): String {`);
36
+ writeFileSync(android, kotlin);
37
+ }
38
+ let objc = readFileSync(ios, 'utf8');
39
+ if (!objc.includes(marker)) {
40
+ if (!objc.includes('- (void) updateView {')) throw new Error('Unexpected Nitro iOS component shape');
41
+ objc = objc.replace('@implementation HybridNativeListComponent {', `@interface UIView (NativeListContainerSlots)
42
+ - (void)mountContainerSlot:(UIView *)view atIndex:(NSInteger)index;
43
+ - (void)unmountContainerSlot:(UIView *)view;
44
+ @end
45
+
46
+ @implementation HybridNativeListComponent {`);
47
+ objc = objc.replace('- (void) updateView {', `${marker}
48
+ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index {
49
+ [self.contentView mountContainerSlot:child atIndex:index];
50
+ }
51
+
52
+ - (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)child index:(NSInteger)index {
53
+ [self.contentView unmountContainerSlot:child];
54
+ }
55
+
56
+ - (void) updateView {`);
57
+ writeFileSync(ios, objc);
58
+ }
@@ -0,0 +1,58 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync, spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const script = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'enable-container-slots.mjs'), 'utf8');
10
+ const androidPath = 'nitrogen/generated/android/kotlin/com/margelo/nitro/nativelist/views/HybridNativeListManager.kt';
11
+ const iosPath = 'nitrogen/generated/ios/c++/views/HybridNativeListComponent.mm';
12
+
13
+ function fixture(android, ios) {
14
+ const root = mkdtempSync(join(tmpdir(), 'native-list-slots-'));
15
+ for (const [relative, content] of [['scripts/enable-container-slots.mjs', script], [androidPath, android], [iosPath, ios]]) {
16
+ const path = join(root, relative);
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ writeFileSync(path, content);
19
+ }
20
+ return root;
21
+ }
22
+
23
+ test('post-nitrogen child ownership transformation is idempotent', () => {
24
+ const root = fixture(`import android.view.View
25
+ import com.facebook.react.uimanager.SimpleViewManager
26
+ public class HybridNativeListManager: SimpleViewManager<View>() {
27
+ override fun getName(): String { return "NativeList" }
28
+ override fun createViewInstance(reactContext: ThemedReactContext): View {
29
+ val view = hybridView.view
30
+ return view
31
+ }
32
+ }`, `@implementation HybridNativeListComponent {
33
+ }
34
+ - (void) updateView {
35
+ }`);
36
+ try {
37
+ const path = join(root, 'scripts/enable-container-slots.mjs');
38
+ execFileSync(process.execPath, [path]);
39
+ const firstAndroid = readFileSync(join(root, androidPath), 'utf8');
40
+ const firstIOS = readFileSync(join(root, iosPath), 'utf8');
41
+ assert.match(firstAndroid, /ViewGroupManager<ViewGroup>/);
42
+ assert.match(firstAndroid, /unmountContainerSlot\(index\)/);
43
+ assert.match(firstIOS, /mountChildComponentView/);
44
+ assert.match(firstIOS, /unmountContainerSlot:child/);
45
+ execFileSync(process.execPath, [path]);
46
+ assert.equal(readFileSync(join(root, androidPath), 'utf8'), firstAndroid);
47
+ assert.equal(readFileSync(join(root, iosPath), 'utf8'), firstIOS);
48
+ } finally { rmSync(root, { recursive: true, force: true }); }
49
+ });
50
+
51
+ test('unexpected nitrogen output fails instead of silently losing child mounting', () => {
52
+ const root = fixture('unrecognized generator output', '- (void) updateView {');
53
+ try {
54
+ const result = spawnSync(process.execPath, [join(root, 'scripts/enable-container-slots.mjs')]);
55
+ assert.notEqual(result.status, 0);
56
+ assert.match(result.stderr.toString(), /Unexpected Nitro Android manager shape/);
57
+ } finally { rmSync(root, { recursive: true, force: true }); }
58
+ });
@@ -11,6 +11,9 @@ import type {
11
11
  */
12
12
  export interface NativeListNativeProps extends HybridViewProps {
13
13
  snapshotJson: string;
14
+ containerSlotHeightsJson: string;
15
+ scrollPositionThresholdsJson: string;
16
+ onScrollPositionThresholdChange?: (isBeyondThreshold: boolean) => void;
14
17
  keyboardDismissMode: NativeListKeyboardDismissMode;
15
18
  keyboardShouldPersistTaps: NativeListKeyboardShouldPersistTaps;
16
19
  onRowAction?: (payloadJson: string) => void;
@@ -4,6 +4,7 @@ import React, {
4
4
  useImperativeHandle,
5
5
  useMemo,
6
6
  useRef,
7
+ useState,
7
8
  } from 'react';
8
9
  import {
9
10
  OneKeyImageCache,
@@ -13,6 +14,7 @@ import {
13
14
  NativeAvatarPrefetchModel,
14
15
  NativeAvatarPrefetchQueue,
15
16
  } from './avatarPrefetch';
17
+ import { View } from 'react-native';
16
18
  import { callback, getHostComponent } from 'react-native-nitro-modules';
17
19
  import type {
18
20
  NativeListMethods,
@@ -96,6 +98,10 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
96
98
  function NativeList(
97
99
  {
98
100
  snapshot,
101
+ listHeader,
102
+ listFooter,
103
+ listEmpty,
104
+ scrollPositionThresholds,
99
105
  webVirtualizationEnabled: _webVirtualizationEnabled,
100
106
  webSectionIndexContainerRef: _webSectionIndexContainerRef,
101
107
  keyboardDismissMode = 'none',
@@ -106,6 +112,7 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
106
112
  onReorder,
107
113
  onEndReached,
108
114
  onVisibleRangeChanged,
115
+ onScrollPositionThresholdChange,
109
116
  onRefresh,
110
117
  onScrollToIndexFailed,
111
118
  initialScrollIndex,
@@ -116,6 +123,21 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
116
123
  },
117
124
  forwardedRef
118
125
  ) {
126
+ const [slotHeights, setSlotHeights] = useState([0, 0, 0]);
127
+ const containerSlotHeightsJson = JSON.stringify(slotHeights);
128
+ const slotInset =
129
+ snapshot.layout.contentPaddingHorizontal ??
130
+ snapshot.layout.contentPadding ??
131
+ 0;
132
+ if (
133
+ snapshot.layout.orientation === 'horizontal' &&
134
+ (listHeader || listEmpty || listFooter)
135
+ ) {
136
+ throw new Error(
137
+ 'NativeList container slots require vertical orientation'
138
+ );
139
+ }
140
+ const slotContents = [listHeader, listEmpty, listFooter];
119
141
  const normalizedKeyboardShouldPersistTaps =
120
142
  keyboardShouldPersistTaps === true
121
143
  ? 'always'
@@ -130,6 +152,7 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
130
152
  onReorder,
131
153
  onEndReached,
132
154
  onVisibleRangeChanged,
155
+ onScrollPositionThresholdChange,
133
156
  onRefresh,
134
157
  onScrollToIndexFailed,
135
158
  });
@@ -140,9 +163,29 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
140
163
  onReorder,
141
164
  onEndReached,
142
165
  onVisibleRangeChanged,
166
+ onScrollPositionThresholdChange,
143
167
  onRefresh,
144
168
  onScrollToIndexFailed,
145
169
  };
170
+ const scrollPositionThresholdsJson = useMemo(() => {
171
+ if (
172
+ !scrollPositionThresholds ||
173
+ scrollPositionThresholds.enabled === false
174
+ )
175
+ return '';
176
+ const { start, end } = scrollPositionThresholds;
177
+ if (
178
+ !Number.isFinite(start) ||
179
+ !Number.isFinite(end) ||
180
+ start < 0 ||
181
+ end <= start
182
+ ) {
183
+ throw new Error(
184
+ 'scrollPositionThresholds requires 0 <= start < end and finite values'
185
+ );
186
+ }
187
+ return JSON.stringify({ start, end });
188
+ }, [scrollPositionThresholds]);
146
189
  const snapshotJson = useMemo(() => serializeSnapshot(snapshot), [snapshot]);
147
190
  const snapshotRef = useRef(snapshot);
148
191
  const previousSnapshotJsonRef = useRef(snapshotJson);
@@ -391,8 +434,13 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
391
434
  }),
392
435
  onRowAction: callback((payloadJson: string) => {
393
436
  const payload = parsePayload<RowActionEvent>(payloadJson);
394
- if (payload.actionKey === 'nativeList.refresh')
437
+ if (
438
+ payload.actionKey === 'nativeList.refresh' &&
439
+ payload.rowKey === undefined
440
+ ) {
395
441
  callbacksRef.current.onRefresh?.();
442
+ return;
443
+ }
396
444
  callbacksRef.current.onRowAction?.(payload);
397
445
  }),
398
446
  onActionAnchorInvalidated: callback((payloadJson: string) => {
@@ -427,6 +475,13 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
427
475
  parsePayload<EndReachedEvent>(payloadJson)
428
476
  );
429
477
  }),
478
+ onScrollPositionThresholdChange: callback(
479
+ (isBeyondThreshold: boolean) => {
480
+ callbacksRef.current.onScrollPositionThresholdChange?.({
481
+ isBeyondThreshold,
482
+ });
483
+ }
484
+ ),
430
485
  onVisibleRangeChanged: callback((payloadJson: string) => {
431
486
  const payload = parsePayload<VisibleRangeChangedEvent>(payloadJson);
432
487
  const previous = avatarRangeRef.current;
@@ -450,9 +505,36 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
450
505
  {...viewProps}
451
506
  {...nativeCallbacks}
452
507
  snapshotJson={snapshotJson}
508
+ containerSlotHeightsJson={containerSlotHeightsJson}
509
+ scrollPositionThresholdsJson={scrollPositionThresholdsJson}
453
510
  keyboardDismissMode={keyboardDismissMode}
454
511
  keyboardShouldPersistTaps={normalizedKeyboardShouldPersistTaps}
455
- />
512
+ >
513
+ {slotContents.map((content, index) => (
514
+ <View
515
+ key={index}
516
+ collapsable={false}
517
+ style={{
518
+ position: 'absolute',
519
+ left: slotInset,
520
+ right: slotInset,
521
+ top: 0,
522
+ }}
523
+ onLayout={(event) => {
524
+ const height = Math.ceil(event.nativeEvent.layout.height);
525
+ setSlotHeights((previous) =>
526
+ previous[index] === height
527
+ ? previous
528
+ : previous.map((value, slot) =>
529
+ slot === index ? height : value
530
+ )
531
+ );
532
+ }}
533
+ >
534
+ {content}
535
+ </View>
536
+ ))}
537
+ </NativeListHost>
456
538
  );
457
539
  }
458
540
  );
@@ -1,4 +1,4 @@
1
- import type { RefObject } from 'react';
1
+ import type { ReactNode, RefObject } from 'react';
2
2
  import type { View, ViewProps } from 'react-native';
3
3
  import type {
4
4
  ActionAnchorInvalidatedEvent,
@@ -119,6 +119,10 @@ export type NativeListRef = Readonly<{
119
119
  export type NativeListProps = Omit<ViewProps, 'children'> &
120
120
  Readonly<{
121
121
  snapshot: NativeListSnapshot;
122
+ /** Native-only, container-owned scroll content. Never rendered as a data row. */
123
+ listHeader?: ReactNode;
124
+ listFooter?: ReactNode;
125
+ listEmpty?: ReactNode;
122
126
  /** Web only. Defaults to true and is ignored by the native host. */
123
127
  webVirtualizationEnabled?: boolean;
124
128
  /** Web only. Hosts the section index inside this container. */
@@ -136,6 +140,15 @@ export type NativeListProps = Omit<ViewProps, 'children'> &
136
140
  onReorder?: (event: ReorderEvent) => void;
137
141
  onEndReached?: (event: EndReachedEvent) => void;
138
142
  onVisibleRangeChanged?: (event: VisibleRangeChangedEvent) => void;
143
+ /** Native only. Emits initial state and hysteresis transitions, not scroll frames. */
144
+ scrollPositionThresholds?: Readonly<{
145
+ start: number;
146
+ end: number;
147
+ enabled?: boolean;
148
+ }>;
149
+ onScrollPositionThresholdChange?: (
150
+ event: Readonly<{ isBeyondThreshold: boolean }>
151
+ ) => void;
139
152
  onRefresh?: () => void;
140
153
  onScrollToIndexFailed?: (info: ScrollToIndexFailedInfo) => void;
141
154
  }> &
@@ -82,6 +82,11 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
82
82
  function NativeList(
83
83
  {
84
84
  snapshot,
85
+ listHeader: _listHeader,
86
+ listEmpty: _listEmpty,
87
+ listFooter: _listFooter,
88
+ scrollPositionThresholds: _scrollPositionThresholds,
89
+ onScrollPositionThresholdChange: _onScrollPositionThresholdChange,
85
90
  webVirtualizationEnabled = true,
86
91
  webSectionIndexContainerRef,
87
92
  keyboardDismissMode: _keyboardDismissMode,
package/src/models.ts CHANGED
@@ -517,6 +517,16 @@ export type RailRow = RowBase &
517
517
  style?: RailRowStyle;
518
518
  }>;
519
519
 
520
+ export type ActivityAmount = Readonly<{
521
+ key: string;
522
+ text: string;
523
+ textSegments?: readonly ValueTextSegment[];
524
+ leading?: LeadingVisual;
525
+ tone?: TextTone;
526
+ secondaryText?: string;
527
+ secondaryTextSegments?: readonly ValueTextSegment[];
528
+ }>;
529
+
520
530
  export type ActivityRow = RowBase &
521
531
  Readonly<{
522
532
  type: 'activity';
@@ -527,6 +537,18 @@ export type ActivityRow = RowBase &
527
537
  status?: string;
528
538
  primaryAmount?: string;
529
539
  secondaryAmount?: string;
540
+ amounts?: readonly ActivityAmount[];
541
+ badges?: readonly BadgeModel[];
542
+ presentation?: 'stacked' | 'table';
543
+ fee?: Readonly<{
544
+ label?: string;
545
+ primary: string;
546
+ primaryTextSegments?: readonly ValueTextSegment[];
547
+ secondary?: string;
548
+ secondaryTextSegments?: readonly ValueTextSegment[];
549
+ hidden?: boolean;
550
+ }>;
551
+ descriptionActionKey?: string;
530
552
  footerActions?: readonly FooterAction[];
531
553
  style?: ActivityRowStyle;
532
554
  }>;
@@ -601,6 +623,11 @@ export type MediaTileRow = RowBase &
601
623
  Readonly<{
602
624
  type: 'mediaTile';
603
625
  variant: 'gallery' | 'browserPreview';
626
+ /** Native preview; video candidates display a silent first frame, never autoplay. */
627
+ media?: Readonly<{
628
+ source: ImageSource;
629
+ probeOrder: readonly ('image' | 'video')[];
630
+ }>;
604
631
  image?: ImageSource;
605
632
  imageState?: 'empty' | 'error';
606
633
  networkImage?: ImageSource;
@@ -650,6 +677,7 @@ export type SectionHeaderRow = RowBase &
650
677
  indexTitle?: string;
651
678
  variant?: 'summary' | 'gallery' | 'history';
652
679
  title: string;
680
+ titleLoading?: boolean;
653
681
  subtitle?: string;
654
682
  value?: string;
655
683
  valueActionKey?: string;
@@ -795,7 +823,7 @@ export type NativeListSnapshot = Readonly<{
795
823
  layout: Readonly<{
796
824
  kind: NativeListLayout;
797
825
  orientation?: NativeListOrientation;
798
- gridColumns?: 2 | 3 | 4;
826
+ gridColumns?: 2 | 3 | 4 | 5 | 6 | 7;
799
827
  stickyHeaders?: boolean;
800
828
  contentPadding?: number;
801
829
  contentPaddingHorizontal?: number;
@@ -813,6 +841,7 @@ export type NativeListSnapshot = Readonly<{
813
841
  reorderable?: boolean;
814
842
  pullToRefresh?: boolean;
815
843
  refreshing?: boolean;
844
+ refreshTriggerDistance?: number;
816
845
  loadMore?: boolean;
817
846
  endReachedThreshold?: number;
818
847
  sectionIndex?: SectionIndexConfig;
@@ -882,6 +911,11 @@ export type RowPatch =
882
911
  | 'title'
883
912
  | 'description'
884
913
  | 'status'
914
+ | 'amounts'
915
+ | 'badges'
916
+ | 'presentation'
917
+ | 'fee'
918
+ | 'descriptionActionKey'
885
919
  | 'primaryAmount'
886
920
  | 'secondaryAmount'
887
921
  | 'leading'
@@ -957,6 +991,7 @@ export type RowPatch =
957
991
  | CommonPatchFields
958
992
  | 'image'
959
993
  | 'imageState'
994
+ | 'media'
960
995
  | 'networkImage'
961
996
  | 'title'
962
997
  | 'subtitle'
@@ -993,6 +1028,7 @@ export type RowPatch =
993
1028
  SectionHeaderRow,
994
1029
  | CommonPatchFields
995
1030
  | 'variant'
1031
+ | 'titleLoading'
996
1032
  | 'title'
997
1033
  | 'subtitle'
998
1034
  | 'value'
@@ -1036,6 +1072,7 @@ export type NativeListActionSource =
1036
1072
  | 'leadingAction'
1037
1073
  | 'trailingAccessory'
1038
1074
  | 'footerAction'
1075
+ | 'description'
1039
1076
  | 'mediaClose';
1040
1077
 
1041
1078
  export type NativeListWindowRect = Readonly<{
package/src/validation.ts CHANGED
@@ -175,6 +175,11 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly<
175
175
  'title',
176
176
  'description',
177
177
  'status',
178
+ 'amounts',
179
+ 'badges',
180
+ 'presentation',
181
+ 'fee',
182
+ 'descriptionActionKey',
178
183
  'primaryAmount',
179
184
  'secondaryAmount',
180
185
  ],
@@ -183,7 +188,7 @@ const TEXT_STYLE_KEYS_BY_ROW_TYPE: Readonly<
183
188
  market: ['title', 'subtitle', 'price', 'change'],
184
189
  mediaTile: ['title', 'subtitle', 'badge'],
185
190
  metricCard: ['title', 'value', 'subtitle', 'trend'],
186
- sectionHeader: ['title', 'subtitle', 'value'],
191
+ sectionHeader: ['titleLoading', 'title', 'subtitle', 'value'],
187
192
  action: ['title', 'value'],
188
193
  system: ['title', 'message', 'actionText'],
189
194
  };
@@ -917,7 +922,84 @@ function assertVisual(row: RowModel, path: string): void {
917
922
  });
918
923
  }
919
924
 
920
- function assertActivity(row: ActivityRow, path: string): void {
925
+ function assertActivity(row: Partial<ActivityRow>, path: string): void {
926
+ assertText(row.title, `${path}.title`);
927
+ assertText(row.description, `${path}.description`);
928
+ if (
929
+ row.presentation !== undefined &&
930
+ !['stacked', 'table'].includes(row.presentation)
931
+ )
932
+ fail(`${path}.presentation`, 'must be stacked or table');
933
+ if (row.descriptionActionKey !== undefined)
934
+ assertKey(row.descriptionActionKey, `${path}.descriptionActionKey`);
935
+ if ((row.amounts?.length ?? 0) > 32)
936
+ fail(`${path}.amounts`, 'supports at most 32 amount lines');
937
+ const amountKeys = new Set<string>();
938
+ row.amounts?.forEach((amount, index) => {
939
+ const amountPath = `${path}.amounts[${index}]`;
940
+ assertKey(amount.key, `${amountPath}.key`);
941
+ if (amountKeys.has(amount.key)) fail(amountPath, 'duplicate amount key');
942
+ amountKeys.add(amount.key);
943
+ assertText(amount.text, `${amountPath}.text`);
944
+ assertLeadingVisual(amount.leading, `${amountPath}.leading`);
945
+ assertText(amount.secondaryText, `${amountPath}.secondaryText`);
946
+ if (
947
+ amount.tone !== undefined &&
948
+ !['primary', 'secondary', 'positive', 'negative'].includes(amount.tone)
949
+ )
950
+ fail(`${amountPath}.tone`, 'invalid text tone');
951
+ amount.textSegments?.forEach((segment, segmentIndex) => {
952
+ assertText(
953
+ segment.text,
954
+ `${amountPath}.textSegments[${segmentIndex}].text`
955
+ );
956
+ if (segment.style !== undefined && segment.style !== 'subscript')
957
+ fail(amountPath, 'invalid text segment style');
958
+ });
959
+ });
960
+ const assertActivitySegments = (
961
+ segments: readonly { text: string; style?: string }[] | undefined,
962
+ segmentPath: string
963
+ ) =>
964
+ segments?.forEach((segment, index) => {
965
+ assertText(segment.text, `${segmentPath}[${index}].text`);
966
+ if (segment.style !== undefined && segment.style !== 'subscript')
967
+ fail(segmentPath, 'invalid text segment style');
968
+ });
969
+ row.amounts?.forEach((amount, index) =>
970
+ assertActivitySegments(
971
+ amount.secondaryTextSegments,
972
+ `${path}.amounts[${index}].secondaryTextSegments`
973
+ )
974
+ );
975
+ assertActivitySegments(
976
+ row.fee?.primaryTextSegments,
977
+ `${path}.fee.primaryTextSegments`
978
+ );
979
+ assertActivitySegments(
980
+ row.fee?.secondaryTextSegments,
981
+ `${path}.fee.secondaryTextSegments`
982
+ );
983
+ assertText(row.fee?.label, `${path}.fee.label`);
984
+ assertText(row.fee?.primary, `${path}.fee.primary`);
985
+ assertText(row.fee?.secondary, `${path}.fee.secondary`);
986
+ if ((row.badges?.length ?? 0) > 4)
987
+ fail(`${path}.badges`, 'supports at most 4 activity badges');
988
+ const badgeKeys = new Set<string>();
989
+ row.badges?.forEach((badge, index) => {
990
+ assertKey(badge.key, `${path}.badges[${index}].key`);
991
+ if (badgeKeys.has(badge.key)) fail(`${path}.badges`, 'duplicate badge key');
992
+ badgeKeys.add(badge.key);
993
+ assertText(badge.text, `${path}.badges[${index}].text`);
994
+ if (
995
+ badge.tone !== undefined &&
996
+ !['neutral', 'info', 'success', 'warning', 'danger'].includes(badge.tone)
997
+ )
998
+ fail(`${path}.badges`, 'invalid badge tone');
999
+ });
1000
+ if (row.fee?.hidden !== undefined && typeof row.fee.hidden !== 'boolean')
1001
+ fail(`${path}.fee.hidden`, 'must be boolean');
1002
+
921
1003
  if ((row.footerActions?.length ?? 0) > MAX_FOOTER_ACTIONS) {
922
1004
  fail(
923
1005
  `${path}.footerActions`,
@@ -1119,6 +1201,7 @@ function assertRow(
1119
1201
  assertMarketRow(row, path);
1120
1202
  break;
1121
1203
  case 'mediaTile':
1204
+ assertMediaPreview(row.media, `${path}.media`);
1122
1205
  assertImage(row.image, `${path}.image`);
1123
1206
  if (
1124
1207
  row.imageState !== undefined &&
@@ -1126,10 +1209,14 @@ function assertRow(
1126
1209
  ) {
1127
1210
  fail(`${path}.imageState`, 'must be empty or error when provided');
1128
1211
  }
1129
- if (row.imageState === undefined && row.image === undefined) {
1212
+ if (
1213
+ row.imageState === undefined &&
1214
+ row.image === undefined &&
1215
+ row.media === undefined
1216
+ ) {
1130
1217
  fail(
1131
1218
  `${path}.image`,
1132
- 'is required unless imageState is empty or error'
1219
+ 'is required unless media is provided or imageState is empty or error'
1133
1220
  );
1134
1221
  }
1135
1222
  assertImage(row.networkImage, `${path}.networkImage`);
@@ -1168,6 +1255,11 @@ function assertRow(
1168
1255
  }
1169
1256
  break;
1170
1257
  case 'sectionHeader':
1258
+ if (
1259
+ row.titleLoading !== undefined &&
1260
+ typeof row.titleLoading !== 'boolean'
1261
+ )
1262
+ fail(`${path}.titleLoading`, 'must be boolean');
1171
1263
  assertKey(row.sectionKey, `${path}.sectionKey`);
1172
1264
  if (
1173
1265
  row.presentation !== undefined &&
@@ -1292,14 +1384,57 @@ function assertGroups(rows: readonly RowModel[]): void {
1292
1384
  }
1293
1385
  }
1294
1386
 
1387
+ function assertMediaPreview(value: unknown, path: string) {
1388
+ if (value === undefined) return;
1389
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1390
+ fail(path, 'must be a media descriptor');
1391
+ const media = value as {
1392
+ source?: import('./models').ImageSource;
1393
+ probeOrder?: unknown;
1394
+ };
1395
+ assertImage(media.source, `${path}.source`);
1396
+ if (!media.source) fail(`${path}.source`, 'is required');
1397
+ const order = media.probeOrder;
1398
+ if (
1399
+ !Array.isArray(order) ||
1400
+ order.length < 1 ||
1401
+ order.length > 2 ||
1402
+ order.some((kind) => kind !== 'image' && kind !== 'video') ||
1403
+ new Set(order).size !== order.length
1404
+ ) {
1405
+ fail(
1406
+ `${path}.probeOrder`,
1407
+ 'must contain one or two distinct image/video candidates'
1408
+ );
1409
+ }
1410
+ }
1411
+
1295
1412
  export function validateSnapshot(
1296
1413
  snapshot: NativeListSnapshot
1297
1414
  ): NativeListSnapshot {
1415
+ if (
1416
+ snapshot.capabilities?.refreshTriggerDistance !== undefined &&
1417
+ (!Number.isFinite(snapshot.capabilities.refreshTriggerDistance) ||
1418
+ snapshot.capabilities.refreshTriggerDistance <= 0)
1419
+ )
1420
+ fail(
1421
+ 'capabilities.refreshTriggerDistance',
1422
+ 'must be a positive finite number'
1423
+ );
1424
+
1298
1425
  assertPlainSerializable(snapshot, 'snapshot', new Set<object>());
1299
1426
  if (snapshot.schemaVersion !== 1) fail('snapshot.schemaVersion', 'must be 1');
1300
1427
  if (!Number.isSafeInteger(snapshot.generation) || snapshot.generation < 0) {
1301
1428
  fail('snapshot.generation', 'must be a non-negative safe integer');
1302
1429
  }
1430
+ if (
1431
+ snapshot.layout.gridColumns !== undefined &&
1432
+ (!Number.isInteger(snapshot.layout.gridColumns) ||
1433
+ snapshot.layout.gridColumns < 2 ||
1434
+ snapshot.layout.gridColumns > 7)
1435
+ ) {
1436
+ fail('snapshot.layout.gridColumns', 'must be an integer from 2 to 7');
1437
+ }
1303
1438
  if (snapshot.layout.kind === 'grid' && !snapshot.layout.gridColumns) {
1304
1439
  fail('snapshot.layout.gridColumns', 'is required for grid layout');
1305
1440
  }
@@ -1508,6 +1643,7 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
1508
1643
  assertLeadingVisual(patch.changes.visual, `${path}.visual`);
1509
1644
  break;
1510
1645
  case 'activity':
1646
+ assertActivity(patch.changes, path);
1511
1647
  assertText(patch.changes.title, `${path}.title`);
1512
1648
  assertText(patch.changes.description, `${path}.description`);
1513
1649
  assertText(patch.changes.status, `${path}.status`);
@@ -1576,6 +1712,7 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
1576
1712
  );
1577
1713
  break;
1578
1714
  case 'mediaTile':
1715
+ assertMediaPreview(patch.changes.media, `${path}.media`);
1579
1716
  assertImage(patch.changes.image, `${path}.image`);
1580
1717
  if (
1581
1718
  patch.changes.imageState !== undefined &&
@@ -1623,6 +1760,11 @@ function assertPatchChanges(patch: RowPatch, index: number): void {
1623
1760
  }
1624
1761
  break;
1625
1762
  case 'sectionHeader':
1763
+ if (
1764
+ patch.changes.titleLoading !== undefined &&
1765
+ typeof patch.changes.titleLoading !== 'boolean'
1766
+ )
1767
+ fail(`${path}.titleLoading`, 'must be boolean');
1626
1768
  assertSectionHeaderVariant(patch.changes.variant, `${path}.variant`);
1627
1769
  assertText(patch.changes.title, `${path}.title`);
1628
1770
  assertText(patch.changes.subtitle, `${path}.subtitle`);