@onekeyfe/react-native-native-list 3.0.116 → 3.0.118

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.
@@ -506,6 +506,7 @@ internal class NativeListRowView(
506
506
  private var checkboxUsesSelectorStyle = false
507
507
  private var iconSubduedColor = Color.rgb(141, 141, 141)
508
508
  private var visualBackdropColor = Color.WHITE
509
+ private var imagePlaceholderColor = "#0000000F"
509
510
  private val circleOutlineProvider = object : ViewOutlineProvider() {
510
511
  override fun getOutline(view: View, outline: Outline) {
511
512
  outline.setOval(0, 0, view.width, view.height)
@@ -914,6 +915,8 @@ internal class NativeListRowView(
914
915
  }
915
916
  iconSubduedColor = color(theme, "iconSubdued", "#00000072")
916
917
  visualBackdropColor = color(theme, "rowBackground", "#FFFFFF")
918
+ imagePlaceholderColor = theme?.optString("strongBackground", "#0000000F")
919
+ ?.takeIf(String::isNotEmpty) ?: "#0000000F"
917
920
  unreadDot.background = roundedFill(
918
921
  parseNativeListColor("#E5484D"),
919
922
  4f,
@@ -1367,6 +1370,8 @@ internal class NativeListRowView(
1367
1370
  leadingActionIcon.iconName = ""
1368
1371
  leadingActionIcon.glyphSizeDp = 24
1369
1372
  leadingActionIcon.setOnClickListener(null)
1373
+ leadingActionIcon.setTag(com.facebook.react.R.id.react_test_id, null)
1374
+ leadingActionIcon.contentDescription = null
1370
1375
  mainColumn.visibility = VISIBLE
1371
1376
  dataContainer.visibility = GONE
1372
1377
  dataColumns.forEach { it.visibility = GONE }
@@ -2106,6 +2111,33 @@ internal class NativeListRowView(
2106
2111
  ?: if (variant == "perp") 8 else 14
2107
2112
  setPadding(dp(horizontalPadding), dp(verticalPadding), dp(horizontalPadding), dp(verticalPadding))
2108
2113
 
2114
+ item.json.optJSONObject("leadingAction")?.let { action ->
2115
+ leadingActionIcon.visibility = VISIBLE
2116
+ leadingActionIcon.iconName = action.optString("name")
2117
+ leadingActionIcon.glyphSizeDp = 24
2118
+ leadingActionIcon.tintColor = safeColor(
2119
+ action.optString("tintColor"),
2120
+ color(theme, "icon", "#0000009B"),
2121
+ )
2122
+ leadingActionIcon.isEnabled = !action.optBoolean("disabled", false)
2123
+ leadingActionIcon.alpha = if (leadingActionIcon.isEnabled) 1f else 0.4f
2124
+ leadingActionIcon.setTag(
2125
+ com.facebook.react.R.id.react_test_id,
2126
+ action.optString("testID").takeIf(String::isNotEmpty),
2127
+ )
2128
+ leadingActionIcon.contentDescription = action.optString("accessibilityLabel")
2129
+ leadingActionIcon.setOnClickListener {
2130
+ emitAction(
2131
+ item,
2132
+ action.optString("actionKey"),
2133
+ null,
2134
+ leadingActionIcon,
2135
+ "leadingAction",
2136
+ )
2137
+ }
2138
+ addView(leadingActionIcon, LayoutParams(dp(36), dp(36)).apply { marginEnd = dp(5) })
2139
+ }
2140
+
2109
2141
  val leading = JSONObject(item.json.getJSONObject("leading").toString())
2110
2142
  imageStyle?.optString("shape")?.takeIf(String::isNotEmpty)?.let { leading.put("shape", it) }
2111
2143
  imageStyle?.optString("contentFit")?.takeIf(String::isNotEmpty)?.let { contentFit ->
@@ -2463,11 +2495,12 @@ internal class NativeListRowView(
2463
2495
  mediaMetadataRow.gravity = Gravity.CENTER_VERTICAL
2464
2496
  mediaMetadataRow.addView(subtitle, weighted().apply { marginEnd = dp(8) })
2465
2497
  item.json.optJSONObject("networkImage")?.let { networkImage ->
2466
- mediaNetworkImage.visibility = VISIBLE
2498
+ // OneKey patch: Preserve badge layout without exposing a loading/error tile.
2499
+ // mediaNetworkImage.visibility = VISIBLE
2467
2500
  mediaNetworkImage.outlineProvider = circleOutlineProvider
2468
2501
  mediaNetworkImage.clipToOutline = true
2469
2502
  mediaMetadataRow.addView(mediaNetworkImage, LayoutParams(dp(14), dp(14)))
2470
- bindImage(networkImage, mediaNetworkImage, item.key, 2, "network")
2503
+ bindImage(networkImage, mediaNetworkImage, item.key, 2, "network", hideUntilLoaded = true)
2471
2504
  }
2472
2505
  mainColumn.addView(mediaMetadataRow, 0)
2473
2506
  mainColumn.addView(titleLine, 1)
@@ -3155,11 +3188,17 @@ internal class NativeListRowView(
3155
3188
  val isIcon = kind == "icon"
3156
3189
  val cornerIconData = visual.optJSONObject("cornerIcon")
3157
3190
  val fallback = visual.optString("fallbackText").take(2)
3158
- leadingFallback.text = fallback
3191
+ val fallbackIconData = visual.optJSONObject("fallbackIcon")
3192
+ val handlesSourceFallback =
3193
+ !isIcon && sources.isNotEmpty() &&
3194
+ (visual.has("fallbackText") || fallbackIconData != null)
3195
+ leadingFallback.text = if (handlesSourceFallback) "" else fallback
3159
3196
  leadingFallback.setTextColor(parseNativeListColor("#00000072"))
3160
3197
  val visualBackground = safeColor(
3161
3198
  visual.optString("backgroundColor"),
3162
- parseNativeListColor(if (isIcon) "#0000000F" else "#E0E0E0"),
3199
+ // OneKey patch: Visual loading and fallback use the active list theme.
3200
+ // parseNativeListColor(if (isIcon) "#0000000F" else "#E0E0E0"),
3201
+ parseNativeListColor(imagePlaceholderColor),
3163
3202
  )
3164
3203
  if (!isIcon && visual.optString("backgroundColor").isNotEmpty()) {
3165
3204
  leadingFrame.background = roundedFill(
@@ -3168,10 +3207,11 @@ internal class NativeListRowView(
3168
3207
  )
3169
3208
  }
3170
3209
  leadingFallback.background = roundedFill(
3171
- visualBackground,
3210
+ if (handlesSourceFallback) parseNativeListColor(imagePlaceholderColor) else visualBackground,
3172
3211
  cornerRadiusDp ?: leadingCornerRadius(shape, minOf(sizeDp, heightDp)),
3173
3212
  )
3174
- leadingFallback.visibility = if (!isIcon && sources.isEmpty()) VISIBLE else GONE
3213
+ leadingFallback.visibility =
3214
+ if (!isIcon && (sources.isEmpty() || handlesSourceFallback)) VISIBLE else GONE
3175
3215
  if (isIcon) {
3176
3216
  leadingFrame.background = GradientDrawable().apply {
3177
3217
  setColor(visualBackground)
@@ -3184,6 +3224,14 @@ internal class NativeListRowView(
3184
3224
  parseNativeListColor("#0000009B"),
3185
3225
  )
3186
3226
  leadingIcon.visibility = VISIBLE
3227
+ } else if (sources.isEmpty() && fallbackIconData != null) {
3228
+ leadingFallback.visibility = GONE
3229
+ leadingIcon.iconName = fallbackIconData.optString("name")
3230
+ leadingIcon.tintColor = safeColor(
3231
+ fallbackIconData.optString("tintColor"),
3232
+ parseNativeListColor("#0000009B"),
3233
+ )
3234
+ leadingIcon.visibility = VISIBLE
3187
3235
  }
3188
3236
  val visibleSources = sources.take(leadingImages.size)
3189
3237
  val tokenPair = kind == "token" && visibleSources.size > 1
@@ -3236,7 +3284,10 @@ internal class NativeListRowView(
3236
3284
  }
3237
3285
  visibleSources.forEachIndexed { index, (source, variant) ->
3238
3286
  val image = leadingImages[index]
3239
- image.visibility = VISIBLE
3287
+ // OneKey patch: Decorative badges and source-owned fallbacks stay hidden until loaded.
3288
+ // image.visibility = VISIBLE
3289
+ val ownsSourceFallback = index == 0 && handlesSourceFallback
3290
+ image.visibility = if (index > 0 || ownsSourceFallback) INVISIBLE else VISIBLE
3240
3291
  image.layoutParams = leadingImageLayout(
3241
3292
  index = index,
3242
3293
  count = visibleSources.size,
@@ -3273,21 +3324,32 @@ internal class NativeListRowView(
3273
3324
  marketLeadingUsesSourceClip = true
3274
3325
  image.clipToOutline = false
3275
3326
  }
3276
- val fallbackIcon = if (index == 0) visual.optJSONObject("fallbackIcon") else null
3327
+ val fallbackIcon = if (index == 0) fallbackIconData else null
3277
3328
  val expectedEpoch = bindingEpoch
3278
3329
  bindImage(source, image, boundKey ?: "", index, variant,
3279
- onLoad = if (fallbackIcon == null) null else ({
3280
- if (bindingEpoch == expectedEpoch) { image.visibility = VISIBLE; leadingIcon.visibility = GONE }
3330
+ onLoad = if (!ownsSourceFallback) null else ({
3331
+ if (bindingEpoch == expectedEpoch) {
3332
+ image.visibility = VISIBLE
3333
+ leadingFallback.visibility = GONE
3334
+ leadingIcon.visibility = GONE
3335
+ }
3281
3336
  }),
3282
- onError = if (fallbackIcon == null) null else ({
3337
+ onError = if (!ownsSourceFallback) null else ({
3283
3338
  if (bindingEpoch == expectedEpoch) {
3284
3339
  image.visibility = GONE
3285
- leadingFallback.visibility = GONE
3286
- leadingIcon.iconName = fallbackIcon.optString("name")
3287
- leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B"))
3288
- leadingIcon.visibility = VISIBLE
3340
+ if (fallbackIcon != null) {
3341
+ leadingFallback.visibility = GONE
3342
+ leadingIcon.iconName = fallbackIcon.optString("name")
3343
+ leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B"))
3344
+ leadingIcon.visibility = VISIBLE
3345
+ } else {
3346
+ leadingIcon.visibility = GONE
3347
+ leadingFallback.text = fallback
3348
+ leadingFallback.visibility = VISIBLE
3349
+ }
3289
3350
  }
3290
3351
  }),
3352
+ hideUntilLoaded = index > 0 || ownsSourceFallback,
3291
3353
  )
3292
3354
  }
3293
3355
  // OneKey patch: wallet overlays retain source images, provider colors and QR text.
@@ -3310,7 +3372,7 @@ internal class NativeListRowView(
3310
3372
  val image = overlay.optJSONObject("image")
3311
3373
  val view = when {
3312
3374
  image != null -> OneKeyImageReusableView(reactContext).also {
3313
- bindImage(image, it, boundKey ?: "", 10 + index, "generic")
3375
+ bindImage(image, it, boundKey ?: "", 10 + index, "generic", hideUntilLoaded = true)
3314
3376
  selectorImages.add(it)
3315
3377
  }
3316
3378
  overlay.optString("text").isNotEmpty() -> TextView(context).apply {
@@ -4057,12 +4119,26 @@ internal class NativeListRowView(
4057
4119
  variant: String,
4058
4120
  onLoad: (() -> Unit)? = null,
4059
4121
  onError: (() -> Unit)? = null,
4122
+ hideUntilLoaded: Boolean = false,
4060
4123
  retryAttempt: Int = 0,
4061
4124
  ) {
4062
4125
  selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks)
4063
4126
  val expectedEpoch = bindingEpoch
4064
4127
  val retryLimit = source.optInt("retryTimes", 0).coerceAtLeast(0)
4065
4128
  val uri = source.optString("uri").trim().takeIf(String::isNotEmpty)
4129
+ if (hideUntilLoaded) imageView.visibility = INVISIBLE
4130
+ val handleLoad: (() -> Unit)? = if (hideUntilLoaded) ({
4131
+ if (bindingEpoch == expectedEpoch) {
4132
+ imageView.visibility = VISIBLE
4133
+ onLoad?.invoke()
4134
+ }
4135
+ }) else onLoad
4136
+ val handleError: (() -> Unit)? = if (hideUntilLoaded) ({
4137
+ if (bindingEpoch == expectedEpoch) {
4138
+ imageView.visibility = INVISIBLE
4139
+ onError?.invoke()
4140
+ }
4141
+ }) else onError
4066
4142
  imageView.configure(
4067
4143
  sourceUri = uri,
4068
4144
  sourceHeadersJson = source.optJSONObject("headers")?.toString(),
@@ -4074,20 +4150,21 @@ internal class NativeListRowView(
4074
4150
  optimizeTos = retryAttempt == 0 && source.optBoolean("optimizeTos", true),
4075
4151
  overscan = source.optDouble("overscan", 1.1),
4076
4152
  loadingStrategy = source.optString("loadingStrategy", "static"),
4077
- onLoad = if (retryLimit == 0) onLoad else ({
4153
+ placeholderColor = imagePlaceholderColor,
4154
+ onLoad = if (retryLimit == 0) handleLoad else ({
4078
4155
  if (bindingEpoch == expectedEpoch) {
4079
4156
  selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks)
4080
- onLoad?.invoke()
4157
+ handleLoad?.invoke()
4081
4158
  }
4082
4159
  }),
4083
- onError = if (retryLimit == 0) onError else ({
4160
+ onError = if (retryLimit == 0) handleError else ({
4084
4161
  if (bindingEpoch == expectedEpoch) {
4085
- if (retryAttempt >= retryLimit) onError?.invoke()
4162
+ if (retryAttempt >= retryLimit) handleError?.invoke()
4086
4163
  else if (!selectorImageRetries.containsKey(imageView)) {
4087
4164
  val retry = Runnable {
4088
4165
  if (bindingEpoch == expectedEpoch) {
4089
4166
  selectorImageRetries.remove(imageView)
4090
- bindImage(source, imageView, token, slot, variant, onLoad, onError, retryAttempt + 1)
4167
+ bindImage(source, imageView, token, slot, variant, onLoad, onError, hideUntilLoaded, retryAttempt + 1)
4091
4168
  }
4092
4169
  }
4093
4170
  selectorImageRetries[imageView] = retry
@@ -1276,6 +1276,8 @@ final class NativeListCell: UICollectionViewCell {
1276
1276
  leadingActionButton.isHidden = true
1277
1277
  leadingActionButton.setImage(nil, for: .normal)
1278
1278
  leadingActionButton.setImage(nil, for: .disabled)
1279
+ leadingActionButton.accessibilityIdentifier = nil
1280
+ leadingActionButton.accessibilityLabel = nil
1279
1281
  unreadDot.isHidden = true
1280
1282
  mediaBadgeLabel.isHidden = true
1281
1283
  mediaBadgeLabel.text = nil
@@ -1833,7 +1835,13 @@ final class NativeListCell: UICollectionViewCell {
1833
1835
  let label = UILabel()
1834
1836
  label.font = nativeListFont(ofSize: 14)
1835
1837
  label.lineBreakMode = .byTruncatingTail
1836
- label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
1838
+ // Match V1: the balance may shrink, but keep the shortened address intact.
1839
+ label.setContentCompressionResistancePriority(
1840
+ item.data.string("presentation") == "accountSelector" && segment.bool("separatorBefore")
1841
+ ? .defaultHigh
1842
+ : .defaultLow,
1843
+ for: .horizontal
1844
+ )
1837
1845
  label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme)
1838
1846
  setLineHeight(label, text: segment.string("text"), lineHeight: 20)
1839
1847
  let runs = segment.dictionaries("textSegments")
@@ -2312,6 +2320,28 @@ final class NativeListCell: UICollectionViewCell {
2312
2320
  rootTopConstraint.constant = verticalPadding
2313
2321
  rootBottomConstraint.constant = -verticalPadding
2314
2322
  rootStack.spacing = CGFloat(style?.double("leadingGap", default: variant == "perp" ? 8 : 14) ?? (variant == "perp" ? 8 : 14))
2323
+ if let leadingAction = item.data.dictionary("leadingAction") {
2324
+ leadingActionButton.isHidden = false
2325
+ let tintColor = UIColor(
2326
+ nativeListHex: leadingAction.string("tintColor", default: "#646464"),
2327
+ fallback: .darkGray
2328
+ )
2329
+ leadingActionButton.tintColor = tintColor
2330
+ if let image = nativeListIcon(named: leadingAction.string("name")) {
2331
+ leadingActionButton.setImage(image, for: .normal)
2332
+ leadingActionButton.setImage(
2333
+ image.withTintColor(tintColor, renderingMode: .alwaysOriginal),
2334
+ for: .disabled
2335
+ )
2336
+ }
2337
+ leadingActionButton.isEnabled = !leadingAction.bool("disabled")
2338
+ leadingActionButton.alpha = leadingActionButton.isEnabled ? 1 : 0.4
2339
+ leadingActionButton.accessibilityIdentifier = leadingAction["testID"] as? String
2340
+ leadingActionButton.accessibilityLabel = leadingAction["accessibilityLabel"] as? String
2341
+ leadingActionKey = leadingAction.string("actionKey")
2342
+ rootStack.addArrangedSubview(leadingActionButton)
2343
+ rootStack.setCustomSpacing(5, after: leadingActionButton)
2344
+ }
2315
2345
  leadingWidth.constant = imageWidth
2316
2346
  leadingHeight.constant = imageHeight
2317
2347
  if let visual = marketLeading(item, style: style) {
@@ -2567,13 +2597,15 @@ final class NativeListCell: UICollectionViewCell {
2567
2597
  subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
2568
2598
  if let networkImage = item.data.dictionary("networkImage") {
2569
2599
  mediaMetadataStack.addArrangedSubview(mediaNetworkImage)
2570
- mediaNetworkImage.isHidden = false
2600
+ // OneKey patch: Preserve badge layout without exposing a loading/error tile.
2601
+ // mediaNetworkImage.isHidden = false
2571
2602
  bindImage(
2572
2603
  networkImage,
2573
2604
  into: mediaNetworkImage,
2574
2605
  token: item.key,
2575
2606
  slot: 2,
2576
- variant: "network"
2607
+ variant: "network",
2608
+ hideUntilLoaded: true
2577
2609
  )
2578
2610
  }
2579
2611
  mainStack.insertArrangedSubview(mediaMetadataStack, at: 0)
@@ -3343,14 +3375,23 @@ final class NativeListCell: UICollectionViewCell {
3343
3375
  default: kind == "image" || mediaHeight.isActive ? "rounded" : "circle"
3344
3376
  )
3345
3377
  let cornerIcon = visual.dictionary("cornerIcon")
3346
- fallbackLabel.text = String(visual.string("fallbackText").prefix(2))
3378
+ let fallbackText = String(visual.string("fallbackText").prefix(2))
3379
+ let fallbackIconData = visual.dictionary("fallbackIcon")
3380
+ let handlesSourceFallback = !isIcon && !sources.isEmpty &&
3381
+ (visual["fallbackText"] != nil || fallbackIconData != nil)
3382
+ fallbackLabel.text = handlesSourceFallback ? nil : fallbackText
3383
+ let sourceFallbackBackground = currentTheme?["strongBackground"] as? String ?? "#0000000F"
3347
3384
  leadingContainer.backgroundColor = UIColor(
3348
- nativeListHex: visual.string("backgroundColor", default: isIcon ? "#F0F0F0" : "#E0E0E0"),
3385
+ // OneKey patch: Visual loading and fallback use the active list theme.
3386
+ // nativeListHex: visual.string("backgroundColor", default: isIcon ? "#F0F0F0" : "#E0E0E0"),
3387
+ nativeListHex: handlesSourceFallback
3388
+ ? sourceFallbackBackground
3389
+ : visual.string("backgroundColor", default: sourceFallbackBackground),
3349
3390
  fallback: .gray
3350
3391
  )
3351
3392
  leadingContainer.layer.cornerRadius = leadingCornerRadius(shape: shape)
3352
3393
  leadingContainer.clipsToBounds = true
3353
- fallbackLabel.isHidden = isIcon || !sources.isEmpty
3394
+ fallbackLabel.isHidden = isIcon || (!sources.isEmpty && !handlesSourceFallback)
3354
3395
  if isIcon {
3355
3396
  leadingContainer.layer.borderWidth = 1 / UIScreen.main.scale
3356
3397
  leadingContainer.layer.borderColor = UIColor(
@@ -3364,6 +3405,15 @@ final class NativeListCell: UICollectionViewCell {
3364
3405
  )
3365
3406
  leadingIconImageView.image = nativeListIcon(named: visual.string("name"))
3366
3407
  leadingIconImageView.contentMode = .scaleAspectFit
3408
+ } else if sources.isEmpty, let fallbackIconData {
3409
+ fallbackLabel.isHidden = true
3410
+ leadingIconImageView.isHidden = false
3411
+ leadingIconImageView.tintColor = UIColor(
3412
+ nativeListHex: fallbackIconData.string("tintColor", default: "#646464"),
3413
+ fallback: .darkGray
3414
+ )
3415
+ leadingIconImageView.image = nativeListIcon(named: fallbackIconData.string("name"))
3416
+ leadingIconImageView.contentMode = .scaleAspectFit
3367
3417
  }
3368
3418
  let visibleSources = Array(sources.prefix(leadingImages.count))
3369
3419
  let tokenPair = kind == "token" && visibleSources.count > 1
@@ -3398,7 +3448,10 @@ final class NativeListCell: UICollectionViewCell {
3398
3448
  }
3399
3449
  for (index, source) in visibleSources.enumerated() {
3400
3450
  let imageView = leadingImages[index]
3401
- imageView.isHidden = false
3451
+ // OneKey patch: Decorative badges and source-owned fallbacks stay hidden until loaded.
3452
+ // imageView.isHidden = false
3453
+ let ownsSourceFallback = index == 0 && handlesSourceFallback
3454
+ imageView.isHidden = index > 0 || ownsSourceFallback
3402
3455
  imageView.clipsToBounds = true
3403
3456
  leadingSlotConstraints.append(contentsOf: leadingConstraints(
3404
3457
  imageView,
@@ -3412,22 +3465,30 @@ final class NativeListCell: UICollectionViewCell {
3412
3465
  imageView.layer.cornerRadius = 0
3413
3466
  imageView.clipsToBounds = false
3414
3467
  }
3415
- let fallbackIcon = index == 0 ? visual.dictionary("fallbackIcon") : nil
3468
+ let fallbackIcon = index == 0 ? fallbackIconData : nil
3416
3469
  let expectedEpoch = bindingEpoch
3417
3470
  bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant,
3418
- onLoad: fallbackIcon == nil ? nil : { [weak self, weak imageView] in
3471
+ onLoad: !ownsSourceFallback ? nil : { [weak self, weak imageView] in
3419
3472
  guard let self, self.bindingEpoch == expectedEpoch else { return }
3420
3473
  imageView?.isHidden = false
3474
+ self.fallbackLabel.isHidden = true
3421
3475
  self.leadingIconImageView.isHidden = true
3422
3476
  },
3423
- onError: fallbackIcon == nil ? nil : { [weak self, weak imageView] in
3424
- guard let self, self.bindingEpoch == expectedEpoch, let fallbackIcon else { return }
3477
+ onError: !ownsSourceFallback ? nil : { [weak self, weak imageView] in
3478
+ guard let self, self.bindingEpoch == expectedEpoch else { return }
3425
3479
  imageView?.isHidden = true
3426
- self.fallbackLabel.isHidden = true
3427
- self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name"))
3428
- self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray)
3429
- self.leadingIconImageView.isHidden = false
3430
- })
3480
+ if let fallbackIcon {
3481
+ self.fallbackLabel.isHidden = true
3482
+ self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name"))
3483
+ self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray)
3484
+ self.leadingIconImageView.isHidden = false
3485
+ } else {
3486
+ self.leadingIconImageView.isHidden = true
3487
+ self.fallbackLabel.text = fallbackText
3488
+ self.fallbackLabel.isHidden = false
3489
+ }
3490
+ },
3491
+ hideUntilLoaded: index > 0 || ownsSourceFallback)
3431
3492
  }
3432
3493
  // OneKey patch: source-derived wallet decorations may occupy both corners.
3433
3494
  let overlays = visual.dictionaries("overlays")
@@ -3444,7 +3505,15 @@ final class NativeListCell: UICollectionViewCell {
3444
3505
  let width = CGFloat(overlay.double("width", default: isWalletText ? Double(naturalTextWidth) : Double(size)))
3445
3506
  let frame = UIView()
3446
3507
  frame.translatesAutoresizingMaskIntoConstraints = false
3447
- frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear)
3508
+ // OneKey patch: The row theme, not a light-only white, owns badge backing.
3509
+ // frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear)
3510
+ frame.backgroundColor = UIColor(
3511
+ nativeListHex: overlay.string(
3512
+ "backgroundColor",
3513
+ default: currentTheme?["rowBackground"] as? String ?? "#00000000"
3514
+ ),
3515
+ fallback: .clear
3516
+ )
3448
3517
  frame.layer.cornerRadius = min(width, height) / 2
3449
3518
  frame.clipsToBounds = true
3450
3519
  leadingContainer.addSubview(frame)
@@ -3459,7 +3528,14 @@ final class NativeListCell: UICollectionViewCell {
3459
3528
  let content: UIView
3460
3529
  if let image = overlay.dictionary("image") {
3461
3530
  let imageView = OneKeyImageReusableView(frame: .zero)
3462
- bindImage(image, into: imageView, token: key, slot: 10 + index, variant: "generic")
3531
+ bindImage(
3532
+ image,
3533
+ into: imageView,
3534
+ token: key,
3535
+ slot: 10 + index,
3536
+ variant: "generic",
3537
+ hideUntilLoaded: true
3538
+ )
3463
3539
  selectorImages.append(imageView)
3464
3540
  content = imageView
3465
3541
  } else if !overlay.string("text").isEmpty {
@@ -3919,6 +3995,7 @@ final class NativeListCell: UICollectionViewCell {
3919
3995
  variant: String,
3920
3996
  onLoad: (() -> Void)? = nil,
3921
3997
  onError: (() -> Void)? = nil,
3998
+ hideUntilLoaded: Bool = false,
3922
3999
  retryAttempt: Int = 0
3923
4000
  ) {
3924
4001
  let imageID = ObjectIdentifier(imageView)
@@ -3933,6 +4010,17 @@ final class NativeListCell: UICollectionViewCell {
3933
4010
  } else {
3934
4011
  headersJson = nil
3935
4012
  }
4013
+ if hideUntilLoaded { imageView.isHidden = true }
4014
+ let handleLoad: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in
4015
+ guard let self, self.bindingEpoch == expectedEpoch else { return }
4016
+ imageView?.isHidden = false
4017
+ onLoad?()
4018
+ } : onLoad
4019
+ let handleError: (() -> Void)? = hideUntilLoaded ? { [weak self, weak imageView] in
4020
+ guard let self, self.bindingEpoch == expectedEpoch else { return }
4021
+ imageView?.isHidden = true
4022
+ onError?()
4023
+ } : onError
3936
4024
  imageView.configure(
3937
4025
  sourceUri: source.string("uri").trimmingCharacters(in: .whitespacesAndNewlines),
3938
4026
  sourceHeadersJson: headersJson,
@@ -3944,19 +4032,20 @@ final class NativeListCell: UICollectionViewCell {
3944
4032
  optimizeTos: retryAttempt == 0 && (source["optimizeTos"] == nil || source.bool("optimizeTos")),
3945
4033
  overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"),
3946
4034
  loadingStrategy: source.string("loadingStrategy", default: "static"),
3947
- onLoad: retryLimit == 0 ? onLoad : { [weak self] in
4035
+ placeholderColor: currentTheme?["strongBackground"] as? String ?? "#0000000F",
4036
+ onLoad: retryLimit == 0 ? handleLoad : { [weak self] in
3948
4037
  guard let self, self.bindingEpoch == expectedEpoch else { return }
3949
4038
  self.selectorImageRetries.removeValue(forKey: imageID)?.cancel()
3950
- onLoad?()
4039
+ handleLoad?()
3951
4040
  },
3952
- onError: retryLimit == 0 ? onError : { [weak self, weak imageView] in
4041
+ onError: retryLimit == 0 ? handleError : { [weak self, weak imageView] in
3953
4042
  guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return }
3954
- guard retryAttempt < retryLimit else { onError?(); return }
4043
+ guard retryAttempt < retryLimit else { handleError?(); return }
3955
4044
  guard self.selectorImageRetries[imageID] == nil else { return }
3956
4045
  let retry = DispatchWorkItem { [weak self, weak imageView] in
3957
4046
  guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return }
3958
4047
  self.selectorImageRetries.removeValue(forKey: imageID)
3959
- self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, retryAttempt: retryAttempt + 1)
4048
+ self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, hideUntilLoaded: hideUntilLoaded, retryAttempt: retryAttempt + 1)
3960
4049
  }
3961
4050
  self.selectorImageRetries[imageID] = retry
3962
4051
  DispatchQueue.main.asyncAfter(deadline: .now() + Double(Int.random(in: 0...2)), execute: retry)
@@ -248,12 +248,11 @@ final class NativeListView: UIView {
248
248
  guard let next = try? NativeListConfig.parse(json: json) else { return }
249
249
  invalidateActionAnchor(reason: "snapshot")
250
250
  if let current = config, isControlledSelectionSnapshotUpdate(from: current, to: next) {
251
- let changedSummaryKeys = Set(zip(current.items, next.items).compactMap { old, new in
252
- old.content != new.content ? new.key : nil
253
- })
254
251
  config = next
255
252
  itemsByKey = Dictionary(uniqueKeysWithValues: next.items.map { ($0.key, $0) })
256
- refreshVisibleSelection(changedSummaryKeys: changedSummaryKeys)
253
+ // OneKey patch: A controlled selection echo is fully handled by the
254
+ // lightweight updater. Rebinding unchanged visuals clears cached images.
255
+ refreshVisibleSelection()
257
256
  return
258
257
  }
259
258
  let oldItems = itemsByKey
@@ -10,6 +10,17 @@ import { applyRowPatches, serializePatches, serializeSnapshot } from "./validati
10
10
  import { jsx as _jsx } from "react/jsx-runtime";
11
11
  const NativeListConfig = require('../nitrogen/generated/shared/json/NativeListConfig.json');
12
12
  const NativeListHost = getHostComponent('NativeList', () => NativeListConfig);
13
+ export function preloadNativeListAvatarImages(sources) {
14
+ return OneKeyImageCache.preload(sources.map(source => ({
15
+ uri: source.uri,
16
+ headers: source.headers,
17
+ resizeWidth: source.width,
18
+ resizeHeight: source.height,
19
+ optimizeTos: source.optimizeTos !== false,
20
+ overscan: source.overscan,
21
+ cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK
22
+ })));
23
+ }
13
24
  function parsePayload(payloadJson) {
14
25
  return JSON.parse(payloadJson);
15
26
  }
@@ -78,14 +89,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
78
89
  updateAvatarPrefetchRef.current = updateAvatarPrefetch;
79
90
  useEffect(() => {
80
91
  avatarLifecycle.current = 'mounted';
81
- const queue = new NativeAvatarPrefetchQueue(source => OneKeyImageCache.preload([{
82
- uri: source.uri,
83
- headers: source.headers,
84
- resizeWidth: source.width,
85
- resizeHeight: source.height,
86
- optimizeTos: false,
87
- cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK
88
- }]));
92
+ const queue = new NativeAvatarPrefetchQueue(source => preloadNativeListAvatarImages([source]));
89
93
  avatarQueueRef.current = queue;
90
94
  updateAvatarPrefetchRef.current();
91
95
  return () => {
@@ -5,7 +5,21 @@ import { View } from 'react-native';
5
5
  import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, resolveLocationIndex, scrollFailure, validateOffset } from "./scrolling.js";
6
6
  import { serializePatches, validateSnapshot } from "./validation.js";
7
7
  import { NativeListWebEngine } from "./web/NativeListWebEngine.js";
8
+ import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./web/NativeListWebAvatarCache.js";
8
9
  import { jsx as _jsx } from "react/jsx-runtime";
10
+ export function preloadNativeListAvatarImages(sources) {
11
+ if (typeof document === 'undefined') return Promise.resolve(false);
12
+ const uris = [...new Set(sources.map(source => canonicalNativeListAvatarUri(source.uri)).filter(uri => uri !== undefined))];
13
+ if (!uris.length) return Promise.resolve(false);
14
+ return Promise.all(uris.map(uri => new Promise(resolve => {
15
+ let release;
16
+ const settle = success => {
17
+ resolve(success);
18
+ queueMicrotask(() => release?.());
19
+ };
20
+ release = acquireNativeListAvatar(document, uri, () => settle(true), () => settle(false), 0);
21
+ }))).then(results => results.every(Boolean));
22
+ }
9
23
  export const NativeList = /*#__PURE__*/forwardRef(function NativeList({
10
24
  snapshot,
11
25
  webVirtualizationEnabled = true,
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- export { NativeList } from './NativeList';
3
+ export { NativeList, preloadNativeListAvatarImages } from './NativeList';
4
4
  export { applyRowPatches, serializePatches, serializeSnapshot, validatePatches, validateSnapshot } from "./validation.js";
5
5
  export { checkboxStateForKeys, checkboxStateForSection, isSelectableRow, reduceSelection, selectionStateFromSnapshot } from "./selection.js";
6
6
  //# sourceMappingURL=index.js.map
@@ -124,6 +124,13 @@ function assertMarketRow(row, path) {
124
124
  }
125
125
  assertText(row.price, `${path}.price`);
126
126
  assertText(row.change.text, `${path}.change.text`);
127
+ assertTrailingAccessories(row.leadingAction ? [row.leadingAction] : undefined, `${path}.leadingAction`);
128
+ if (row.leadingAction) {
129
+ assertText(row.leadingAction.name, `${path}.leadingAction.name`);
130
+ if (row.leadingAction.actionKey !== undefined) {
131
+ assertKey(row.leadingAction.actionKey, `${path}.leadingAction.actionKey`);
132
+ }
133
+ }
127
134
  assertLeadingVisual(row.leading, `${path}.leading`);
128
135
  const assertSegments = (segments, segmentPath) => {
129
136
  segments?.forEach((segment, index) => {
@@ -628,11 +628,32 @@ function configureWebAvatar(image, source, uri) {
628
628
  });
629
629
  webAvatarCleanup.set(image, dispose);
630
630
  }
631
+ const WEB_IMAGE_FADE_DELAY_MS = 100;
632
+ const WEB_IMAGE_FADE_DURATION_MS = 140;
633
+ function webImageNow(image) {
634
+ return image.ownerDocument.defaultView?.performance.now() ?? Date.now();
635
+ }
636
+ function revealWebImage(element, startedAt) {
637
+ element.getAnimations?.().forEach(animation => animation.cancel());
638
+ element.style.opacity = '1';
639
+ const view = element.ownerDocument.defaultView;
640
+ if (webImageNow(element) - startedAt < WEB_IMAGE_FADE_DELAY_MS || view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches || typeof element.animate !== 'function') return;
641
+ element.animate([{
642
+ opacity: 0
643
+ }, {
644
+ opacity: 1
645
+ }], {
646
+ duration: WEB_IMAGE_FADE_DURATION_MS,
647
+ easing: 'ease-out'
648
+ });
649
+ }
631
650
  function createImage(context, source, className) {
632
651
  const avatarUri = canonicalNativeListAvatarUri(source.uri);
633
652
  const uri = avatarUri ?? safeImageUri(source.uri);
634
653
  if (!uri) return undefined;
635
654
  const image = context.document.createElement('img');
655
+ const startedAt = webImageNow(image);
656
+ image.style.opacity = '0';
636
657
  if (className) image.className = className;
637
658
  // OneKey patch: consume recoverable errors before the visual's final fallback listener.
638
659
  if (avatarUri) {
@@ -646,6 +667,13 @@ function createImage(context, source, className) {
646
667
  image.loading = 'lazy';
647
668
  image.decoding = 'async';
648
669
  image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover';
670
+ image.addEventListener('load', () => {
671
+ if (image.dataset.nativeListSelectorPaint !== 'true') revealWebImage(image, startedAt);
672
+ });
673
+ image.addEventListener('error', () => {
674
+ image.style.opacity = '0';
675
+ });
676
+ image.dataset.nativeListImageStartedAt = String(startedAt);
649
677
  return image;
650
678
  }
651
679
 
@@ -655,14 +683,18 @@ function paintSelectorImageBackground(image, frame, inset = 0) {
655
683
  const paint = createElement(image.ownerDocument, 'span', 'ok-native-list-selector-image-background');
656
684
  paint.style.cssText = 'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat';
657
685
  paint.style.inset = String(inset) + 'px';
686
+ paint.style.opacity = '0';
658
687
  paint.style.backgroundSize = image.style.objectFit === 'fill' ? '100% 100%' : image.style.objectFit === 'center' ? 'auto' : image.style.objectFit;
688
+ image.dataset.nativeListSelectorPaint = 'true';
659
689
  image.style.opacity = '0';
660
690
  const update = () => {
661
691
  paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')';
692
+ revealWebImage(paint, Number(image.dataset.nativeListImageStartedAt) || webImageNow(image));
662
693
  };
663
694
  image.addEventListener('load', update);
664
695
  image.addEventListener('error', () => {
665
696
  paint.style.backgroundImage = 'none';
697
+ paint.style.opacity = '0';
666
698
  });
667
699
  frame.insertBefore(paint, image);
668
700
  if (image.complete && image.naturalWidth > 0) update();
@@ -949,8 +981,15 @@ function createVisual(context, visual, selectorPresentation) {
949
981
  frame.appendChild(fallback);
950
982
  return frame;
951
983
  }
952
- if ('backgroundColor' in visual && visual.backgroundColor) frame.style.background = visual.backgroundColor;
984
+
985
+ // OneKey patch: Every image visual starts from the current theme backing.
986
+ // if ('backgroundColor' in visual && visual.backgroundColor)
987
+ // frame.style.background = visual.backgroundColor;
988
+ frame.style.background = 'backgroundColor' in visual && visual.backgroundColor ? visual.backgroundColor : 'var(--nl-strong)';
953
989
  const source = visual.kind === 'image' ? visual.image : visual.image;
990
+ const fallbackIcon = 'fallbackIcon' in visual ? visual.fallbackIcon : undefined;
991
+ const handlesSourceFallback = !!source && (fallbackIcon !== undefined || 'fallbackText' in visual);
992
+ if (handlesSourceFallback) frame.style.background = 'var(--nl-strong)';
954
993
  const image = source ? createImage(context, source) : undefined;
955
994
  if (image) {
956
995
  image.className = 'ok-native-list-visual-main';
@@ -970,17 +1009,19 @@ function createVisual(context, visual, selectorPresentation) {
970
1009
  frame.appendChild(corner);
971
1010
  }
972
1011
  // OneKey patch: image failure uses the same source-derived fallback as v1.
973
- if ('fallbackIcon' in visual && visual.fallbackIcon) {
974
- const icon = visual.fallbackIcon;
1012
+ const hasFallbackText = 'fallbackText' in visual;
1013
+ if (fallbackIcon || hasFallbackText) {
975
1014
  const showFallback = () => {
976
1015
  if (image) {
977
1016
  disposeWebImageRetries(image);
978
1017
  image.remove();
979
1018
  }
980
1019
  frame.querySelector('.ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)')?.remove();
981
- const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback');
982
- applySelectorIcon(fallback, icon.name);
983
- if (icon.tintColor) fallback.style.color = icon.tintColor;
1020
+ const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', fallbackIcon ? undefined : 'fallbackText' in visual ? visual.fallbackText ?? '' : '');
1021
+ if (fallbackIcon) {
1022
+ applySelectorIcon(fallback, fallbackIcon.name);
1023
+ if (fallbackIcon.tintColor) fallback.style.color = fallbackIcon.tintColor;
1024
+ }
984
1025
  frame.prepend(fallback);
985
1026
  };
986
1027
  if (image) image.addEventListener('error', showFallback, {
@@ -1747,6 +1788,17 @@ function createMarketRow(context, row) {
1747
1788
  const layoutStyle = resolveWebMarketLayoutStyle(row);
1748
1789
  body.style.padding = String(layoutStyle.verticalPadding) + 'px ' + String(layoutStyle.horizontalPadding) + 'px';
1749
1790
  body.style.gap = '0px';
1791
+ if (row.leadingAction) {
1792
+ const action = createIconAction(context, row.leadingAction.name, row.leadingAction.actionKey, row.leadingAction.disabled, row.leadingAction.tintColor);
1793
+ action.style.flex = '0 0 36px';
1794
+ action.style.width = '36px';
1795
+ action.style.height = '36px';
1796
+ action.style.marginRight = '5px';
1797
+ setData(action, 'testid', row.leadingAction.testID);
1798
+ if (row.leadingAction.accessibilityLabel) action.setAttribute('aria-label', row.leadingAction.accessibilityLabel);
1799
+ markActionAnchorSource(action, 'leadingAction');
1800
+ body.appendChild(action);
1801
+ }
1750
1802
  const visual = createVisual(context, row.leading);
1751
1803
  if (visual) {
1752
1804
  const width = layoutStyle.imageWidth;
@@ -1822,16 +1874,45 @@ function createMarketRow(context, row) {
1822
1874
  titleLine.appendChild(element);
1823
1875
  });
1824
1876
  main.appendChild(titleLine);
1825
- if (row.subtitle || row.subtitleSegments?.length) {
1826
- const subtitle = createElement(context.document, 'span', 'ok-native-list-market-subtitle', row.subtitle);
1827
- applyMarketTextStyle(subtitle, style?.subtitle, {
1828
- fontSize: 14,
1829
- lineHeight: 20,
1830
- weight: 400,
1831
- alignment: 'start'
1832
- });
1833
- applyValueSegments(subtitle, row.subtitleSegments, style?.subtitle?.fontSize ?? 14, style?.subtitle?.lineHeight ?? 20, marketFontWeight(style?.subtitle?.fontWeight, 400));
1834
- main.appendChild(subtitle);
1877
+ if (row.subtitlePrefix || row.subtitle || row.subtitleSegments?.length) {
1878
+ const subtitleLine = createElement(context.document, 'span', 'ok-native-list-market-subtitle-line');
1879
+ subtitleLine.style.display = 'flex';
1880
+ subtitleLine.style.alignItems = 'center';
1881
+ subtitleLine.style.minWidth = '0';
1882
+ subtitleLine.style.overflow = 'hidden';
1883
+ subtitleLine.style.gap = String(row.subtitlePrefix ? row.subtitlePrefix.gap ?? 4 : 0) + 'px';
1884
+ if (row.subtitlePrefix) {
1885
+ const prefix = createElement(context.document, 'span', 'ok-native-list-market-subtitle-prefix', row.subtitlePrefix.text);
1886
+ applyMarketTextStyle(prefix, row.subtitlePrefix.style, {
1887
+ fontSize: 12,
1888
+ lineHeight: 16,
1889
+ weight: 400,
1890
+ alignment: 'start'
1891
+ });
1892
+ prefix.style.display = 'block';
1893
+ prefix.style.flex = '1 1 auto';
1894
+ prefix.style.minWidth = '0';
1895
+ prefix.style.overflow = 'hidden';
1896
+ prefix.style.textOverflow = 'ellipsis';
1897
+ prefix.style.color = row.subtitlePrefix.style?.color ?? 'var(--nl-secondary)';
1898
+ if (row.subtitlePrefix.maxWidth !== undefined) {
1899
+ prefix.style.maxWidth = String(row.subtitlePrefix.maxWidth) + 'px';
1900
+ }
1901
+ subtitleLine.appendChild(prefix);
1902
+ }
1903
+ if (row.subtitle || row.subtitleSegments?.length) {
1904
+ const subtitle = createElement(context.document, 'span', 'ok-native-list-market-subtitle', row.subtitle);
1905
+ applyMarketTextStyle(subtitle, style?.subtitle, {
1906
+ fontSize: 14,
1907
+ lineHeight: 20,
1908
+ weight: 400,
1909
+ alignment: 'start'
1910
+ });
1911
+ applyValueSegments(subtitle, row.subtitleSegments, style?.subtitle?.fontSize ?? 14, style?.subtitle?.lineHeight ?? 20, marketFontWeight(style?.subtitle?.fontWeight, 400));
1912
+ subtitle.style.flex = row.subtitlePrefix ? '0 0 auto' : '1 1 auto';
1913
+ subtitleLine.appendChild(subtitle);
1914
+ }
1915
+ main.appendChild(subtitleLine);
1835
1916
  }
1836
1917
  body.appendChild(main);
1837
1918
  const trailing = createElement(context.document, 'span', 'ok-native-list-market-trailing');
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
+ import type { ImageSource } from './models';
2
3
  import type { NativeListProps } from './NativeList.types';
3
4
  export type { NativeListProps, NativeListRef } from './NativeList.types';
4
5
  export type { ActionAnchorState, ScrollAlignment, ScrollPositionOptions, ScrollToEndParams, ScrollToIndexFailedInfo, ScrollToIndexParams, ScrollToItemParams, ScrollToKeyParams, ScrollToLocationParams, ScrollToOffsetParams, } from './NativeList.types';
6
+ export declare function preloadNativeListAvatarImages(sources: readonly ImageSource[]): Promise<boolean>;
5
7
  export declare const NativeList: React.ForwardRefExoticComponent<NativeListProps & React.RefAttributes<Readonly<{
6
8
  applySnapshot(snapshot: import("./models").NativeListSnapshot): void;
7
9
  applyPatches(patches: readonly import("./models").RowPatch[]): void;
@@ -1,8 +1,9 @@
1
1
  import React from 'react';
2
2
  import type { NativeListProps } from './NativeList.types';
3
- import type { NativeListSnapshot, RowPatch } from './models';
3
+ import type { ImageSource, NativeListSnapshot, RowPatch } from './models';
4
4
  export type { NativeListProps, NativeListRef } from './NativeList.types';
5
5
  export type { ActionAnchorState, ScrollAlignment, ScrollPositionOptions, ScrollToEndParams, ScrollToIndexFailedInfo, ScrollToIndexParams, ScrollToItemParams, ScrollToKeyParams, ScrollToLocationParams, ScrollToOffsetParams, } from './NativeList.types';
6
+ export declare function preloadNativeListAvatarImages(sources: readonly ImageSource[]): Promise<boolean>;
6
7
  export declare const NativeList: React.ForwardRefExoticComponent<NativeListProps & React.RefAttributes<Readonly<{
7
8
  applySnapshot(snapshot: NativeListSnapshot): void;
8
9
  applyPatches(patches: readonly RowPatch[]): void;
@@ -1,4 +1,4 @@
1
- export { NativeList } from './NativeList';
1
+ export { NativeList, preloadNativeListAvatarImages } from './NativeList';
2
2
  export type { ActionAnchorState, NativeListProps, NativeListRef, ScrollAlignment, ScrollPositionOptions, ScrollToEndParams, ScrollToIndexFailedInfo, ScrollToIndexParams, ScrollToItemParams, ScrollToKeyParams, ScrollToLocationParams, ScrollToOffsetParams, } from './NativeList';
3
3
  export type * from './models';
4
4
  export { applyRowPatches, serializePatches, serializeSnapshot, validatePatches, validateSnapshot, } from './validation';
@@ -329,6 +329,9 @@ export type DataRow = RowBase & Readonly<{
329
329
  export type MarketRow = RowBase & Readonly<{
330
330
  type: 'market';
331
331
  variant: 'token' | 'stock' | 'perp';
332
+ leadingAction?: Extract<TrailingAccessory, {
333
+ kind: 'icon';
334
+ }>;
332
335
  leading: LeadingVisual;
333
336
  title: string;
334
337
  subtitle?: string;
@@ -545,7 +548,7 @@ export type RowPatch = Readonly<{
545
548
  }> | Readonly<{
546
549
  type: 'market';
547
550
  key: string;
548
- changes: Partial<Pick<MarketRow, CommonPatchFields | 'leading' | 'title' | 'subtitle' | 'subtitleSegments' | 'price' | 'priceSegments' | 'change' | 'badges' | 'pressActionKey' | 'pressInActionKey' | 'longPressActionKey' | 'diagnostics' | 'style'>>;
551
+ changes: Partial<Pick<MarketRow, CommonPatchFields | 'leadingAction' | 'leading' | 'title' | 'subtitle' | 'subtitleSegments' | 'price' | 'priceSegments' | 'change' | 'badges' | 'pressActionKey' | 'pressInActionKey' | 'longPressActionKey' | 'diagnostics' | 'style'>>;
549
552
  }> | Readonly<{
550
553
  type: 'mediaTile';
551
554
  key: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-native-list",
3
- "version": "3.0.116",
3
+ "version": "3.0.118",
4
4
  "description": "Template-driven native RecyclerView and UICollectionView for React Native",
5
5
  "source": "./src/index.ts",
6
6
  "main": "./lib/module/index.js",
@@ -83,8 +83,8 @@
83
83
  "typescript": "^5.9.2"
84
84
  },
85
85
  "peerDependencies": {
86
- "@onekeyfe/react-native-image": "3.0.116",
87
- "@onekeyfe/react-native-native-logger": "3.0.116",
86
+ "@onekeyfe/react-native-image": "3.0.118",
87
+ "@onekeyfe/react-native-native-logger": "3.0.118",
88
88
  "react": "*",
89
89
  "react-native": "*",
90
90
  "react-native-nitro-modules": "0.37.0"
@@ -20,6 +20,7 @@ import type {
20
20
  } from './NativeList.nitro';
21
21
  import type {
22
22
  ActionAnchorInvalidatedEvent,
23
+ ImageSource,
23
24
  RowActionEvent,
24
25
  SelectionDeltaEvent,
25
26
  ReorderEvent,
@@ -64,6 +65,27 @@ const NativeListHost = getHostComponent<
64
65
  NativeListMethods
65
66
  >('NativeList', () => NativeListConfig);
66
67
 
68
+ export function preloadNativeListAvatarImages(
69
+ sources: readonly ImageSource[]
70
+ ): Promise<boolean> {
71
+ return OneKeyImageCache.preload(
72
+ sources.map((source) => ({
73
+ uri: source.uri,
74
+ headers: source.headers,
75
+ resizeWidth: source.width,
76
+ resizeHeight: source.height,
77
+ optimizeTos: source.optimizeTos !== false,
78
+ overscan: source.overscan,
79
+ cachePolicy:
80
+ source.cachePolicy === 'memory'
81
+ ? OneKeyImageCachePolicy.MEMORY
82
+ : source.cachePolicy === 'disk'
83
+ ? OneKeyImageCachePolicy.DISK
84
+ : OneKeyImageCachePolicy.MEMORY_DISK,
85
+ }))
86
+ );
87
+ }
88
+
67
89
  function parsePayload<T>(payloadJson: string): T {
68
90
  return JSON.parse(payloadJson) as T;
69
91
  }
@@ -151,21 +173,7 @@ export const NativeList = forwardRef<NativeListRef, NativeListProps>(
151
173
  useEffect(() => {
152
174
  avatarLifecycle.current = 'mounted';
153
175
  const queue = new NativeAvatarPrefetchQueue((source) =>
154
- OneKeyImageCache.preload([
155
- {
156
- uri: source.uri,
157
- headers: source.headers,
158
- resizeWidth: source.width,
159
- resizeHeight: source.height,
160
- optimizeTos: false,
161
- cachePolicy:
162
- source.cachePolicy === 'memory'
163
- ? OneKeyImageCachePolicy.MEMORY
164
- : source.cachePolicy === 'disk'
165
- ? OneKeyImageCachePolicy.DISK
166
- : OneKeyImageCachePolicy.MEMORY_DISK,
167
- },
168
- ])
176
+ preloadNativeListAvatarImages([source])
169
177
  );
170
178
  avatarQueueRef.current = queue;
171
179
  updateAvatarPrefetchRef.current();
@@ -9,7 +9,7 @@ import React, {
9
9
  } from 'react';
10
10
  import { View } from 'react-native';
11
11
  import type { NativeListProps, NativeListRef } from './NativeList.types';
12
- import type { NativeListSnapshot, RowPatch } from './models';
12
+ import type { ImageSource, NativeListSnapshot, RowPatch } from './models';
13
13
  import {
14
14
  normalizeIndexScroll,
15
15
  normalizeKeyScroll,
@@ -24,6 +24,10 @@ import {
24
24
  NativeListWebEngine,
25
25
  type NativeListWebCallbacks,
26
26
  } from './web/NativeListWebEngine';
27
+ import {
28
+ acquireNativeListAvatar,
29
+ canonicalNativeListAvatarUri,
30
+ } from './web/NativeListWebAvatarCache';
27
31
 
28
32
  export type { NativeListProps, NativeListRef } from './NativeList.types';
29
33
  export type {
@@ -39,6 +43,39 @@ export type {
39
43
  ScrollToOffsetParams,
40
44
  } from './NativeList.types';
41
45
 
46
+ export function preloadNativeListAvatarImages(
47
+ sources: readonly ImageSource[]
48
+ ): Promise<boolean> {
49
+ if (typeof document === 'undefined') return Promise.resolve(false);
50
+ const uris = [
51
+ ...new Set(
52
+ sources
53
+ .map((source) => canonicalNativeListAvatarUri(source.uri))
54
+ .filter((uri): uri is string => uri !== undefined)
55
+ ),
56
+ ];
57
+ if (!uris.length) return Promise.resolve(false);
58
+ return Promise.all(
59
+ uris.map(
60
+ (uri) =>
61
+ new Promise<boolean>((resolve) => {
62
+ let release: (() => void) | undefined;
63
+ const settle = (success: boolean) => {
64
+ resolve(success);
65
+ queueMicrotask(() => release?.());
66
+ };
67
+ release = acquireNativeListAvatar(
68
+ document,
69
+ uri,
70
+ () => settle(true),
71
+ () => settle(false),
72
+ 0
73
+ );
74
+ })
75
+ )
76
+ ).then((results) => results.every(Boolean));
77
+ }
78
+
42
79
  export const NativeList = forwardRef<NativeListRef, NativeListProps>(
43
80
  function NativeList(
44
81
  {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { NativeList } from './NativeList';
1
+ export { NativeList, preloadNativeListAvatarImages } from './NativeList';
2
2
  export type {
3
3
  ActionAnchorState,
4
4
  NativeListProps,
package/src/models.ts CHANGED
@@ -346,6 +346,7 @@ export type MarketRow = RowBase &
346
346
  Readonly<{
347
347
  type: 'market';
348
348
  variant: 'token' | 'stock' | 'perp';
349
+ leadingAction?: Extract<TrailingAccessory, { kind: 'icon' }>;
349
350
  leading: LeadingVisual;
350
351
  title: string;
351
352
  subtitle?: string;
@@ -667,6 +668,7 @@ export type RowPatch =
667
668
  Pick<
668
669
  MarketRow,
669
670
  | CommonPatchFields
671
+ | 'leadingAction'
670
672
  | 'leading'
671
673
  | 'title'
672
674
  | 'subtitle'
package/src/validation.ts CHANGED
@@ -242,6 +242,16 @@ function assertMarketRow(row: MarketRow, path: string): void {
242
242
  }
243
243
  assertText(row.price, `${path}.price`);
244
244
  assertText(row.change.text, `${path}.change.text`);
245
+ assertTrailingAccessories(
246
+ row.leadingAction ? [row.leadingAction] : undefined,
247
+ `${path}.leadingAction`
248
+ );
249
+ if (row.leadingAction) {
250
+ assertText(row.leadingAction.name, `${path}.leadingAction.name`);
251
+ if (row.leadingAction.actionKey !== undefined) {
252
+ assertKey(row.leadingAction.actionKey, `${path}.leadingAction.actionKey`);
253
+ }
254
+ }
245
255
  assertLeadingVisual(row.leading, `${path}.leading`);
246
256
  const assertSegments = (
247
257
  segments: MarketRow['priceSegments'],
@@ -1087,6 +1087,30 @@ function configureWebAvatar(
1087
1087
  webAvatarCleanup.set(image, dispose);
1088
1088
  }
1089
1089
 
1090
+ const WEB_IMAGE_FADE_DELAY_MS = 100;
1091
+ const WEB_IMAGE_FADE_DURATION_MS = 140;
1092
+
1093
+ function webImageNow(image: HTMLImageElement): number {
1094
+ return image.ownerDocument.defaultView?.performance.now() ?? Date.now();
1095
+ }
1096
+
1097
+ function revealWebImage(element: HTMLElement, startedAt: number) {
1098
+ element.getAnimations?.().forEach((animation) => animation.cancel());
1099
+ element.style.opacity = '1';
1100
+ const view = element.ownerDocument.defaultView;
1101
+ if (
1102
+ webImageNow(element as HTMLImageElement) - startedAt <
1103
+ WEB_IMAGE_FADE_DELAY_MS ||
1104
+ view?.matchMedia?.('(prefers-reduced-motion: reduce)').matches ||
1105
+ typeof element.animate !== 'function'
1106
+ )
1107
+ return;
1108
+ element.animate([{ opacity: 0 }, { opacity: 1 }], {
1109
+ duration: WEB_IMAGE_FADE_DURATION_MS,
1110
+ easing: 'ease-out',
1111
+ });
1112
+ }
1113
+
1090
1114
  function createImage(
1091
1115
  context: RenderContext,
1092
1116
  source: ImageSource,
@@ -1096,6 +1120,8 @@ function createImage(
1096
1120
  const uri = avatarUri ?? safeImageUri(source.uri);
1097
1121
  if (!uri) return undefined;
1098
1122
  const image = context.document.createElement('img');
1123
+ const startedAt = webImageNow(image);
1124
+ image.style.opacity = '0';
1099
1125
  if (className) image.className = className;
1100
1126
  // OneKey patch: consume recoverable errors before the visual's final fallback listener.
1101
1127
  if (avatarUri) {
@@ -1110,6 +1136,14 @@ function createImage(
1110
1136
  image.decoding = 'async';
1111
1137
  image.style.objectFit =
1112
1138
  source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover';
1139
+ image.addEventListener('load', () => {
1140
+ if (image.dataset.nativeListSelectorPaint !== 'true')
1141
+ revealWebImage(image, startedAt);
1142
+ });
1143
+ image.addEventListener('error', () => {
1144
+ image.style.opacity = '0';
1145
+ });
1146
+ image.dataset.nativeListImageStartedAt = String(startedAt);
1113
1147
  return image;
1114
1148
  }
1115
1149
 
@@ -1128,20 +1162,27 @@ function paintSelectorImageBackground(
1128
1162
  paint.style.cssText =
1129
1163
  'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat';
1130
1164
  paint.style.inset = String(inset) + 'px';
1165
+ paint.style.opacity = '0';
1131
1166
  paint.style.backgroundSize =
1132
1167
  image.style.objectFit === 'fill'
1133
1168
  ? '100% 100%'
1134
1169
  : image.style.objectFit === 'center'
1135
1170
  ? 'auto'
1136
1171
  : image.style.objectFit;
1172
+ image.dataset.nativeListSelectorPaint = 'true';
1137
1173
  image.style.opacity = '0';
1138
1174
  const update = () => {
1139
1175
  paint.style.backgroundImage =
1140
1176
  'url(' + JSON.stringify(image.currentSrc || image.src) + ')';
1177
+ revealWebImage(
1178
+ paint,
1179
+ Number(image.dataset.nativeListImageStartedAt) || webImageNow(image)
1180
+ );
1141
1181
  };
1142
1182
  image.addEventListener('load', update);
1143
1183
  image.addEventListener('error', () => {
1144
1184
  paint.style.backgroundImage = 'none';
1185
+ paint.style.opacity = '0';
1145
1186
  });
1146
1187
  frame.insertBefore(paint, image);
1147
1188
  if (image.complete && image.naturalWidth > 0) update();
@@ -1516,9 +1557,19 @@ function createVisual(
1516
1557
  return frame;
1517
1558
  }
1518
1559
 
1519
- if ('backgroundColor' in visual && visual.backgroundColor)
1520
- frame.style.background = visual.backgroundColor;
1560
+ // OneKey patch: Every image visual starts from the current theme backing.
1561
+ // if ('backgroundColor' in visual && visual.backgroundColor)
1562
+ // frame.style.background = visual.backgroundColor;
1563
+ frame.style.background =
1564
+ 'backgroundColor' in visual && visual.backgroundColor
1565
+ ? visual.backgroundColor
1566
+ : 'var(--nl-strong)';
1521
1567
  const source = visual.kind === 'image' ? visual.image : visual.image;
1568
+ const fallbackIcon =
1569
+ 'fallbackIcon' in visual ? visual.fallbackIcon : undefined;
1570
+ const handlesSourceFallback =
1571
+ !!source && (fallbackIcon !== undefined || 'fallbackText' in visual);
1572
+ if (handlesSourceFallback) frame.style.background = 'var(--nl-strong)';
1522
1573
  const image = source ? createImage(context, source) : undefined;
1523
1574
  if (image) {
1524
1575
  image.className = 'ok-native-list-visual-main';
@@ -1556,8 +1607,8 @@ function createVisual(
1556
1607
  frame.appendChild(corner);
1557
1608
  }
1558
1609
  // OneKey patch: image failure uses the same source-derived fallback as v1.
1559
- if ('fallbackIcon' in visual && visual.fallbackIcon) {
1560
- const icon = visual.fallbackIcon;
1610
+ const hasFallbackText = 'fallbackText' in visual;
1611
+ if (fallbackIcon || hasFallbackText) {
1561
1612
  const showFallback = () => {
1562
1613
  if (image) {
1563
1614
  disposeWebImageRetries(image);
@@ -1571,10 +1622,18 @@ function createVisual(
1571
1622
  const fallback = createElement(
1572
1623
  context.document,
1573
1624
  'span',
1574
- 'ok-native-list-visual-fallback'
1625
+ 'ok-native-list-visual-fallback',
1626
+ fallbackIcon
1627
+ ? undefined
1628
+ : 'fallbackText' in visual
1629
+ ? visual.fallbackText ?? ''
1630
+ : ''
1575
1631
  );
1576
- applySelectorIcon(fallback, icon.name);
1577
- if (icon.tintColor) fallback.style.color = icon.tintColor;
1632
+ if (fallbackIcon) {
1633
+ applySelectorIcon(fallback, fallbackIcon.name);
1634
+ if (fallbackIcon.tintColor)
1635
+ fallback.style.color = fallbackIcon.tintColor;
1636
+ }
1578
1637
  frame.prepend(fallback);
1579
1638
  };
1580
1639
  if (image) image.addEventListener('error', showFallback, { once: true });
@@ -3123,6 +3182,24 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement {
3123
3182
  String(layoutStyle.horizontalPadding) +
3124
3183
  'px';
3125
3184
  body.style.gap = '0px';
3185
+ if (row.leadingAction) {
3186
+ const action = createIconAction(
3187
+ context,
3188
+ row.leadingAction.name,
3189
+ row.leadingAction.actionKey,
3190
+ row.leadingAction.disabled,
3191
+ row.leadingAction.tintColor
3192
+ );
3193
+ action.style.flex = '0 0 36px';
3194
+ action.style.width = '36px';
3195
+ action.style.height = '36px';
3196
+ action.style.marginRight = '5px';
3197
+ setData(action, 'testid', row.leadingAction.testID);
3198
+ if (row.leadingAction.accessibilityLabel)
3199
+ action.setAttribute('aria-label', row.leadingAction.accessibilityLabel);
3200
+ markActionAnchorSource(action, 'leadingAction');
3201
+ body.appendChild(action);
3202
+ }
3126
3203
  const visual = createVisual(context, row.leading);
3127
3204
  if (visual) {
3128
3205
  const width = layoutStyle.imageWidth;
@@ -3236,27 +3313,67 @@ function createMarketRow(context: RenderContext, row: MarketRow): HTMLElement {
3236
3313
  titleLine.appendChild(element);
3237
3314
  });
3238
3315
  main.appendChild(titleLine);
3239
- if (row.subtitle || row.subtitleSegments?.length) {
3240
- const subtitle = createElement(
3316
+ if (row.subtitlePrefix || row.subtitle || row.subtitleSegments?.length) {
3317
+ const subtitleLine = createElement(
3241
3318
  context.document,
3242
3319
  'span',
3243
- 'ok-native-list-market-subtitle',
3244
- row.subtitle
3245
- );
3246
- applyMarketTextStyle(subtitle, style?.subtitle, {
3247
- fontSize: 14,
3248
- lineHeight: 20,
3249
- weight: 400,
3250
- alignment: 'start',
3251
- });
3252
- applyValueSegments(
3253
- subtitle,
3254
- row.subtitleSegments,
3255
- style?.subtitle?.fontSize ?? 14,
3256
- style?.subtitle?.lineHeight ?? 20,
3257
- marketFontWeight(style?.subtitle?.fontWeight, 400)
3258
- );
3259
- main.appendChild(subtitle);
3320
+ 'ok-native-list-market-subtitle-line'
3321
+ );
3322
+ subtitleLine.style.display = 'flex';
3323
+ subtitleLine.style.alignItems = 'center';
3324
+ subtitleLine.style.minWidth = '0';
3325
+ subtitleLine.style.overflow = 'hidden';
3326
+ subtitleLine.style.gap =
3327
+ String(row.subtitlePrefix ? row.subtitlePrefix.gap ?? 4 : 0) + 'px';
3328
+ if (row.subtitlePrefix) {
3329
+ const prefix = createElement(
3330
+ context.document,
3331
+ 'span',
3332
+ 'ok-native-list-market-subtitle-prefix',
3333
+ row.subtitlePrefix.text
3334
+ );
3335
+ applyMarketTextStyle(prefix, row.subtitlePrefix.style, {
3336
+ fontSize: 12,
3337
+ lineHeight: 16,
3338
+ weight: 400,
3339
+ alignment: 'start',
3340
+ });
3341
+ prefix.style.display = 'block';
3342
+ prefix.style.flex = '1 1 auto';
3343
+ prefix.style.minWidth = '0';
3344
+ prefix.style.overflow = 'hidden';
3345
+ prefix.style.textOverflow = 'ellipsis';
3346
+ prefix.style.color =
3347
+ row.subtitlePrefix.style?.color ?? 'var(--nl-secondary)';
3348
+ if (row.subtitlePrefix.maxWidth !== undefined) {
3349
+ prefix.style.maxWidth = String(row.subtitlePrefix.maxWidth) + 'px';
3350
+ }
3351
+ subtitleLine.appendChild(prefix);
3352
+ }
3353
+ if (row.subtitle || row.subtitleSegments?.length) {
3354
+ const subtitle = createElement(
3355
+ context.document,
3356
+ 'span',
3357
+ 'ok-native-list-market-subtitle',
3358
+ row.subtitle
3359
+ );
3360
+ applyMarketTextStyle(subtitle, style?.subtitle, {
3361
+ fontSize: 14,
3362
+ lineHeight: 20,
3363
+ weight: 400,
3364
+ alignment: 'start',
3365
+ });
3366
+ applyValueSegments(
3367
+ subtitle,
3368
+ row.subtitleSegments,
3369
+ style?.subtitle?.fontSize ?? 14,
3370
+ style?.subtitle?.lineHeight ?? 20,
3371
+ marketFontWeight(style?.subtitle?.fontWeight, 400)
3372
+ );
3373
+ subtitle.style.flex = row.subtitlePrefix ? '0 0 auto' : '1 1 auto';
3374
+ subtitleLine.appendChild(subtitle);
3375
+ }
3376
+ main.appendChild(subtitleLine);
3260
3377
  }
3261
3378
  body.appendChild(main);
3262
3379
  const trailing = createElement(