| author | |
| committer | |
| log | 32e7844837b35ca0a0ff038997a45ea75cb7103f |
| tree | 63dccadbf768bed7bcfebb45bc77dc5846eb6c9b |
| parent | 482d56233dc3c27f614c843e0f6cc02b3f648a5f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>12 files changed, 1830 insertions(+), 123 deletions(-)
.github/workflows/mobile-eas-production.yml created+110| ... | @@ -0,0 +1,110 @@ | ||
| 1 | name: Mobile EAS Production | ||
| 2 | |||
| 3 | # Production builds and OTA updates run from CI (Linux) — never from a laptop. | ||
| 4 | # Under the fingerprint runtime-version policy the fingerprint must be computed | ||
| 5 | # in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different | ||
| 6 | # fingerprint (platform-specific deps + pnpm version) and errors. On this Linux | ||
| 7 | # runner, with corepack pinning pnpm 10.24 in eas.json, local == build. | ||
| 8 | on: | ||
| 9 | workflow_dispatch: | ||
| 10 | inputs: | ||
| 11 | mode: | ||
| 12 | description: "build (+ auto-submit to TestFlight) or update (OTA)" | ||
| 13 | required: true | ||
| 14 | type: choice | ||
| 15 | default: build | ||
| 16 | options: | ||
| 17 | - build | ||
| 18 | - update | ||
| 19 | platform: | ||
| 20 | description: "Target platform" | ||
| 21 | required: true | ||
| 22 | type: choice | ||
| 23 | default: ios | ||
| 24 | options: | ||
| 25 | - ios | ||
| 26 | - android | ||
| 27 | - all | ||
| 28 | message: | ||
| 29 | description: "OTA update message (mode=update only)" | ||
| 30 | required: false | ||
| 31 | type: string | ||
| 32 | |||
| 33 | jobs: | ||
| 34 | production: | ||
| 35 | name: EAS Production ${{ inputs.mode }} | ||
| 36 | runs-on: blacksmith-8vcpu-ubuntu-2404 | ||
| 37 | permissions: | ||
| 38 | contents: read | ||
| 39 | env: | ||
| 40 | APP_VARIANT: production | ||
| 41 | NODE_OPTIONS: --max-old-space-size=8192 | ||
| 42 | steps: | ||
| 43 | - id: expo-token | ||
| 44 | name: Check for EXPO_TOKEN | ||
| 45 | env: | ||
| 46 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| 47 | run: | | ||
| 48 | if [ -n "$EXPO_TOKEN" ]; then | ||
| 49 | echo "present=true" >> "$GITHUB_OUTPUT" | ||
| 50 | else | ||
| 51 | echo "present=false" >> "$GITHUB_OUTPUT" | ||
| 52 | echo "EXPO_TOKEN is not available; skipping EAS production job." | ||
| 53 | fi | ||
| 54 | |||
| 55 | - name: Checkout | ||
| 56 | if: steps.expo-token.outputs.present == 'true' | ||
| 57 | uses: actions/checkout@v6 | ||
| 58 | with: | ||
| 59 | fetch-depth: 0 | ||
| 60 | |||
| 61 | - name: Setup Vite+ | ||
| 62 | if: steps.expo-token.outputs.present == 'true' | ||
| 63 | uses: voidzero-dev/setup-vp@v1 | ||
| 64 | with: | ||
| 65 | node-version-file: package.json | ||
| 66 | cache: true | ||
| 67 | run-install: true | ||
| 68 | |||
| 69 | - name: Expose pnpm | ||
| 70 | if: steps.expo-token.outputs.present == 'true' | ||
| 71 | run: | | ||
| 72 | pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" | ||
| 73 | vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" | ||
| 74 | echo "$vp_pnpm_bin" >> "$GITHUB_PATH" | ||
| 75 | "$vp_pnpm_bin/pnpm" --version | ||
| 76 | |||
| 77 | - name: Setup EAS | ||
| 78 | if: steps.expo-token.outputs.present == 'true' | ||
| 79 | uses: expo/expo-github-action@v8 | ||
| 80 | with: | ||
| 81 | eas-version: latest | ||
| 82 | token: ${{ secrets.EXPO_TOKEN }} | ||
| 83 | packager: pnpm | ||
| 84 | |||
| 85 | - name: Pull production environment variables | ||
| 86 | if: steps.expo-token.outputs.present == 'true' | ||
| 87 | working-directory: apps/mobile | ||
| 88 | env: | ||
| 89 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| 90 | run: eas env:pull production --non-interactive | ||
| 91 | |||
| 92 | - name: Build and submit | ||
| 93 | if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'build' | ||
| 94 | working-directory: apps/mobile | ||
| 95 | env: | ||
| 96 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| 97 | run: eas build --platform ${{ inputs.platform }} --profile production --auto-submit --non-interactive --no-wait | ||
| 98 | |||
| 99 | - name: Publish OTA update | ||
| 100 | if: steps.expo-token.outputs.present == 'true' && inputs.mode == 'update' | ||
| 101 | working-directory: apps/mobile | ||
| 102 | env: | ||
| 103 | EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} | ||
| 104 | run: | | ||
| 105 | eas update \ | ||
| 106 | --channel production \ | ||
| 107 | --environment production \ | ||
| 108 | --platform ${{ inputs.platform }} \ | ||
| 109 | --message "${{ inputs.message || format('Production OTA ({0})', github.sha) }}" \ | ||
| 110 | --non-interactive | ||
apps/mobile/app.config.ts+5-1| ... | @@ -66,7 +66,11 @@ const config: ExpoConfig = { | ... | @@ -66,7 +66,11 @@ const config: ExpoConfig = { |
| 66 | scheme: variant.scheme, | 66 | scheme: variant.scheme, |
| 67 | version: "0.1.0", | 67 | version: "0.1.0", |
| 68 | runtimeVersion: { | 68 | runtimeVersion: { |
| 69 | policy: process.env.MOBILE_VERSION_POLICY ?? "appVersion", | 69 | // Fingerprint (not appVersion) so an OTA only reaches binaries whose native |
| 70 | // project — native deps, config plugins, AND patches/ — matches the update. | ||
| 71 | // With appVersion, every 0.1.0 build shares a runtime version, so a JS update | ||
| 72 | // could land on a binary missing the native changes it needs and crash. | ||
| 73 | policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint", | ||
| 70 | }, | 74 | }, |
| 71 | orientation: "portrait", | 75 | orientation: "portrait", |
| 72 | icon: "./assets/icon.png", | 76 | icon: "./assets/icon.png", |
apps/mobile/eas.json+8-1| ... | @@ -6,6 +6,7 @@ | ... | @@ -6,6 +6,7 @@ |
| 6 | }, | 6 | }, |
| 7 | "build": { | 7 | "build": { |
| 8 | "development": { | 8 | "development": { |
| 9 | "corepack": true, | ||
| 9 | "env": { | 10 | "env": { |
| 10 | "APP_VARIANT": "development" | 11 | "APP_VARIANT": "development" |
| 11 | }, | 12 | }, |
| ... | @@ -14,6 +15,7 @@ | ... | @@ -14,6 +15,7 @@ |
| 14 | "distribution": "internal" | 15 | "distribution": "internal" |
| 15 | }, | 16 | }, |
| 16 | "preview": { | 17 | "preview": { |
| 18 | "corepack": true, | ||
| 17 | "env": { | 19 | "env": { |
| 18 | "APP_VARIANT": "preview" | 20 | "APP_VARIANT": "preview" |
| 19 | }, | 21 | }, |
| ... | @@ -22,6 +24,7 @@ | ... | @@ -22,6 +24,7 @@ |
| 22 | "distribution": "internal" | 24 | "distribution": "internal" |
| 23 | }, | 25 | }, |
| 24 | "preview:dev": { | 26 | "preview:dev": { |
| 27 | "corepack": true, | ||
| 25 | "env": { | 28 | "env": { |
| 26 | "APP_VARIANT": "preview", | 29 | "APP_VARIANT": "preview", |
| 27 | "MOBILE_VERSION_POLICY": "fingerprint" | 30 | "MOBILE_VERSION_POLICY": "fingerprint" |
| ... | @@ -35,6 +38,7 @@ | ... | @@ -35,6 +38,7 @@ |
| 35 | } | 38 | } |
| 36 | }, | 39 | }, |
| 37 | "production": { | 40 | "production": { |
| 41 | "corepack": true, | ||
| 38 | "env": { | 42 | "env": { |
| 39 | "APP_VARIANT": "production" | 43 | "APP_VARIANT": "production" |
| 40 | }, | 44 | }, |
| ... | @@ -46,7 +50,10 @@ | ... | @@ -46,7 +50,10 @@ |
| 46 | "submit": { | 50 | "submit": { |
| 47 | "production": { | 51 | "production": { |
| 48 | "ios": { | 52 | "ios": { |
| 49 | "ascAppId": "6761315631" | 53 | "ascAppId": "6787819824" |
| 54 | }, | ||
| 55 | "android": { | ||
| 56 | "track": "internal" | ||
| 50 | } | 57 | } |
| 51 | } | 58 | } |
| 52 | } | 59 | } |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+29-1| ... | @@ -1,5 +1,7 @@ | ... | @@ -1,5 +1,7 @@ |
| 1 | import { useAuth, useUser } from "@clerk/expo"; | 1 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | import Constants from "expo-constants"; | ||
| 2 | import * as Notifications from "expo-notifications"; | 3 | import * as Notifications from "expo-notifications"; |
| 4 | import * as Updates from "expo-updates"; | ||
| 3 | import { useNavigation } from "@react-navigation/native"; | 5 | import { useNavigation } from "@react-navigation/native"; |
| 4 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 5 | import { SymbolView } from "expo-symbols"; | 7 | import { SymbolView } from "expo-symbols"; |
| ... | @@ -422,6 +424,23 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -422,6 +424,23 @@ function ConfiguredSettingsRouteScreen() { |
| 422 | function AppSettingsSection() { | 424 | function AppSettingsSection() { |
| 423 | const icon = useThemeColor("--color-icon"); | 425 | const icon = useThemeColor("--color-icon"); |
| 424 | 426 | ||
| 427 | const version = Constants.expoConfig?.version ?? "0.0.0"; | ||
| 428 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | ||
| 429 | // missing variant never mislabels a production build as development. | ||
| 430 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | ||
| 431 | const variantLabel = variant === "production" ? "" : capitalize(variant); | ||
| 432 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; | ||
| 433 | // Which JS is actually running: the bundle shipped in the binary, or an OTA | ||
| 434 | // update downloaded on top of it. Surfacing this makes "am I even on the | ||
| 435 | // right build?" answerable at a glance. | ||
| 436 | const bundleLabel = Updates.isEnabled | ||
| 437 | ? Updates.isEmbeddedLaunch | ||
| 438 | ? "Embedded" | ||
| 439 | : Updates.updateId | ||
| 440 | ? `OTA ${Updates.updateId.slice(0, 7)}` | ||
| 441 | : null | ||
| 442 | : null; | ||
| 443 | |||
| 425 | return ( | 444 | return ( |
| 426 | <SettingsSection title="App"> | 445 | <SettingsSection title="App"> |
| 427 | <View className="flex-row items-center gap-4 p-4"> | 446 | <View className="flex-row items-center gap-4 p-4"> |
| ... | @@ -433,12 +452,21 @@ function AppSettingsSection() { | ... | @@ -433,12 +452,21 @@ function AppSettingsSection() { |
| 433 | weight="regular" | 452 | weight="regular" |
| 434 | /> | 453 | /> |
| 435 | <Text className="flex-1 text-lg text-foreground">Version</Text> | 454 | <Text className="flex-1 text-lg text-foreground">Version</Text> |
| 436 | <Text className="text-lg text-foreground-muted">Alpha</Text> | 455 | <View className="items-end"> |
| 456 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> | ||
| 457 | {bundleLabel ? ( | ||
| 458 | <Text className="text-xs text-foreground-muted/70">{bundleLabel}</Text> | ||
| 459 | ) : null} | ||
| 460 | </View> | ||
| 437 | </View> | 461 | </View> |
| 438 | </SettingsSection> | 462 | </SettingsSection> |
| 439 | ); | 463 | ); |
| 440 | } | 464 | } |
| 441 | 465 | ||
| 466 | function capitalize(value: string): string { | ||
| 467 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; | ||
| 468 | } | ||
| 469 | |||
| 442 | function ArchivedThreadsSettingsSection() { | 470 | function ArchivedThreadsSettingsSection() { |
| 443 | return ( | 471 | return ( |
| 444 | <SettingsSection title="Threads"> | 472 | <SettingsSection title="Threads"> |
apps/mobile/src/features/threads/ThreadComposer.tsx+87-70| ... | @@ -25,6 +25,7 @@ import { | ... | @@ -25,6 +25,7 @@ import { |
| 25 | type ViewStyle, | 25 | type ViewStyle, |
| 26 | } from "react-native"; | 26 | } from "react-native"; |
| 27 | import ImageViewing from "react-native-image-viewing"; | 27 | import ImageViewing from "react-native-image-viewing"; |
| 28 | import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; | ||
| 28 | import { useThemeColor } from "../../lib/useThemeColor"; | 29 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 29 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 30 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 30 | 31 | ||
| ... | @@ -112,6 +113,10 @@ export interface ThreadComposerProps { | ... | @@ -112,6 +113,10 @@ export interface ThreadComposerProps { |
| 112 | * The pill / card container — renders as LiquidGlassView on supported | 113 | * The pill / card container — renders as LiquidGlassView on supported |
| 113 | * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. | 114 | * iOS 26+ devices (progressive blur, native morph), opaque View otherwise. |
| 114 | */ | 115 | */ |
| 116 | // One timing for every piece of the expanded↔compact morph so the surface, | ||
| 117 | // toolbar, and siblings move together instead of popping between layouts. | ||
| 118 | const COMPOSER_LAYOUT_TRANSITION = LinearTransition.duration(220); | ||
| 119 | |||
| 115 | function ComposerSurface(props: { | 120 | function ComposerSurface(props: { |
| 116 | readonly children: ReactNode; | 121 | readonly children: ReactNode; |
| 117 | readonly style: ViewStyle; | 122 | readonly style: ViewStyle; |
| ... | @@ -130,7 +135,7 @@ function ComposerSurface(props: { | ... | @@ -130,7 +135,7 @@ function ComposerSurface(props: { |
| 130 | 135 | ||
| 131 | if (isLiquidGlassSupported) { | 136 | if (isLiquidGlassSupported) { |
| 132 | return ( | 137 | return ( |
| 133 | <View style={shadowStyle}> | 138 | <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}> |
| 134 | <LiquidGlassView | 139 | <LiquidGlassView |
| 135 | effect="regular" | 140 | effect="regular" |
| 136 | interactive | 141 | interactive |
| ... | @@ -139,12 +144,12 @@ function ComposerSurface(props: { | ... | @@ -139,12 +144,12 @@ function ComposerSurface(props: { |
| 139 | > | 144 | > |
| 140 | {props.children} | 145 | {props.children} |
| 141 | </LiquidGlassView> | 146 | </LiquidGlassView> |
| 142 | </View> | 147 | </Animated.View> |
| 143 | ); | 148 | ); |
| 144 | } | 149 | } |
| 145 | 150 | ||
| 146 | return ( | 151 | return ( |
| 147 | <View style={shadowStyle}> | 152 | <Animated.View layout={COMPOSER_LAYOUT_TRANSITION} style={shadowStyle}> |
| 148 | <View | 153 | <View |
| 149 | style={[ | 154 | style={[ |
| 150 | props.style, | 155 | props.style, |
| ... | @@ -157,7 +162,7 @@ function ComposerSurface(props: { | ... | @@ -157,7 +162,7 @@ function ComposerSurface(props: { |
| 157 | > | 162 | > |
| 158 | {props.children} | 163 | {props.children} |
| 159 | </View> | 164 | </View> |
| 160 | </View> | 165 | </Animated.View> |
| 161 | ); | 166 | ); |
| 162 | } | 167 | } |
| 163 | 168 | ||
| ... | @@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -669,7 +674,8 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 669 | } | 674 | } |
| 670 | 675 | ||
| 671 | return ( | 676 | return ( |
| 672 | <View | 677 | <Animated.View |
| 678 | layout={COMPOSER_LAYOUT_TRANSITION} | ||
| 673 | style={{ | 679 | style={{ |
| 674 | paddingHorizontal: 16, | 680 | paddingHorizontal: 16, |
| 675 | paddingTop: isExpanded ? 8 : 6, | 681 | paddingTop: isExpanded ? 8 : 6, |
| ... | @@ -679,8 +685,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -679,8 +685,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 679 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", | 685 | : "linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.6) 55%, rgba(255,255,255,0.9) 100%)", |
| 680 | }} | 686 | }} |
| 681 | > | 687 | > |
| 682 | <View | 688 | <Animated.View |
| 683 | className="w-full" | 689 | className="w-full" |
| 690 | layout={COMPOSER_LAYOUT_TRANSITION} | ||
| 684 | style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }} | 691 | style={{ alignSelf: "center", maxWidth: props.contentMaxWidth, position: "relative" }} |
| 685 | > | 692 | > |
| 686 | {composerTrigger && composerMenuItems.length > 0 ? ( | 693 | {composerTrigger && composerMenuItems.length > 0 ? ( |
| ... | @@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -733,13 +740,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 733 | > | 740 | > |
| 734 | {/* Attachment strip — inside the card, above the text input */} | 741 | {/* Attachment strip — inside the card, above the text input */} |
| 735 | {isExpanded ? ( | 742 | {isExpanded ? ( |
| 736 | <View style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }}> | 743 | <Animated.View |
| 744 | entering={FadeIn.duration(160)} | ||
| 745 | exiting={FadeOut.duration(120)} | ||
| 746 | style={{ paddingBottom: props.draftAttachments.length > 0 ? 10 : 0 }} | ||
| 747 | > | ||
| 737 | <ComposerAttachmentStrip | 748 | <ComposerAttachmentStrip |
| 738 | attachments={props.draftAttachments} | 749 | attachments={props.draftAttachments} |
| 739 | onRemove={props.onRemoveDraftImage} | 750 | onRemove={props.onRemoveDraftImage} |
| 740 | onPressImage={onPressImage} | 751 | onPressImage={onPressImage} |
| 741 | /> | 752 | /> |
| 742 | </View> | 753 | </Animated.View> |
| 743 | ) : null} | 754 | ) : null} |
| 744 | 755 | ||
| 745 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> | 756 | <View style={isExpanded ? undefined : { flex: 1, minWidth: 0 }}> |
| ... | @@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -812,81 +823,87 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 812 | </View> | 823 | </View> |
| 813 | ) : null} | 824 | ) : null} |
| 814 | {!isExpanded ? ( | 825 | {!isExpanded ? ( |
| 815 | showStopAction ? ( | 826 | <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}> |
| 816 | <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} /> | 827 | {showStopAction ? ( |
| 817 | ) : ( | 828 | <ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} /> |
| 818 | <ControlPill | 829 | ) : ( |
| 819 | icon="arrow.up" | 830 | <ControlPill |
| 820 | variant="primary" | 831 | icon="arrow.up" |
| 821 | disabled={!canSend} | 832 | variant="primary" |
| 822 | onPress={handleSend} | 833 | disabled={!canSend} |
| 823 | /> | 834 | onPress={handleSend} |
| 824 | ) | 835 | /> |
| 836 | )} | ||
| 837 | </Animated.View> | ||
| 825 | ) : null} | 838 | ) : null} |
| 826 | </ComposerSurface> | 839 | </ComposerSurface> |
| 827 | 840 | ||
| 828 | {/* Toolbar row — matches draft page layout (expanded only) */} | 841 | {/* Toolbar row — matches draft page layout (expanded only) */} |
| 829 | {isExpanded ? ( | 842 | {isExpanded ? ( |
| 830 | <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}> | 843 | <Animated.View entering={FadeIn.duration(160)} exiting={FadeOut.duration(120)}> |
| 831 | <ComposerToolbarScroller | 844 | <ComposerToolbarRow paddingBottom={8} paddingHorizontal={0} paddingTop={8}> |
| 832 | fadeOpaque={toolbarFadeOpaque} | 845 | <ComposerToolbarScroller |
| 833 | fadeTransparent={toolbarFadeTransparent} | 846 | fadeOpaque={toolbarFadeOpaque} |
| 834 | > | 847 | fadeTransparent={toolbarFadeTransparent} |
| 835 | <ComposerToolbarButton | ||
| 836 | icon="plus" | ||
| 837 | onPress={() => void props.onPickDraftImages()} | ||
| 838 | showChevron={false} | ||
| 839 | /> | ||
| 840 | <ControlPillMenu | ||
| 841 | actions={modelMenuActions} | ||
| 842 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} | ||
| 843 | > | ||
| 844 | <ComposerToolbarTrigger | ||
| 845 | accessibilityLabel="Model" | ||
| 846 | iconNode={ | ||
| 847 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> | ||
| 848 | } | ||
| 849 | label={currentModelOption?.label ?? currentModelSelection.model} | ||
| 850 | /> | ||
| 851 | </ControlPillMenu> | ||
| 852 | <ControlPillMenu | ||
| 853 | actions={optionsMenuActions} | ||
| 854 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 855 | > | 848 | > |
| 856 | <ComposerToolbarTrigger | ||
| 857 | accessibilityLabel="Configuration" | ||
| 858 | icon="slider.horizontal.3" | ||
| 859 | label={configurationLabel} | ||
| 860 | /> | ||
| 861 | </ControlPillMenu> | ||
| 862 | {showStopAction ? ( | ||
| 863 | <ComposerToolbarButton | 849 | <ComposerToolbarButton |
| 864 | icon="stop.fill" | 850 | icon="plus" |
| 865 | variant="danger" | 851 | onPress={() => void props.onPickDraftImages()} |
| 866 | onPress={props.onStopThread} | ||
| 867 | showChevron={false} | 852 | showChevron={false} |
| 868 | /> | 853 | /> |
| 869 | ) : null} | 854 | <ControlPillMenu |
| 870 | </ComposerToolbarScroller> | 855 | actions={modelMenuActions} |
| 871 | <ComposerToolbarButton | 856 | onPressAction={({ nativeEvent }) => handleModelMenuAction(nativeEvent.event)} |
| 872 | accessibilityLabel={sendLabel} | 857 | > |
| 873 | icon="arrow.up" | 858 | <ComposerToolbarTrigger |
| 874 | variant="primary" | 859 | accessibilityLabel="Model" |
| 875 | disabled={!canSend} | 860 | iconNode={ |
| 876 | onPress={handleSend} | 861 | <ProviderIcon provider={currentModelOption?.providerDriver} size={16} /> |
| 877 | showChevron={false} | 862 | } |
| 878 | /> | 863 | label={currentModelOption?.label ?? currentModelSelection.model} |
| 879 | </ComposerToolbarRow> | 864 | /> |
| 865 | </ControlPillMenu> | ||
| 866 | <ControlPillMenu | ||
| 867 | actions={optionsMenuActions} | ||
| 868 | onPressAction={({ nativeEvent }) => handleOptionsMenuAction(nativeEvent.event)} | ||
| 869 | > | ||
| 870 | <ComposerToolbarTrigger | ||
| 871 | accessibilityLabel="Configuration" | ||
| 872 | icon="slider.horizontal.3" | ||
| 873 | label={configurationLabel} | ||
| 874 | /> | ||
| 875 | </ControlPillMenu> | ||
| 876 | {showStopAction ? ( | ||
| 877 | <ComposerToolbarButton | ||
| 878 | icon="stop.fill" | ||
| 879 | variant="danger" | ||
| 880 | onPress={props.onStopThread} | ||
| 881 | showChevron={false} | ||
| 882 | /> | ||
| 883 | ) : null} | ||
| 884 | </ComposerToolbarScroller> | ||
| 885 | <ComposerToolbarButton | ||
| 886 | accessibilityLabel={sendLabel} | ||
| 887 | icon="arrow.up" | ||
| 888 | variant="primary" | ||
| 889 | disabled={!canSend} | ||
| 890 | onPress={handleSend} | ||
| 891 | showChevron={false} | ||
| 892 | /> | ||
| 893 | </ComposerToolbarRow> | ||
| 894 | </Animated.View> | ||
| 880 | ) : null} | 895 | ) : null} |
| 881 | 896 | ||
| 882 | {/* Queue count */} | 897 | {/* Queue count */} |
| 883 | {props.queueCount > 0 ? ( | 898 | {props.queueCount > 0 ? ( |
| 884 | <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}> | 899 | <Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(120)}> |
| 885 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send | 900 | <Text className="text-xs text-foreground-muted" style={{ paddingTop: 8 }}> |
| 886 | automatically. | 901 | {props.queueCount} queued message{props.queueCount === 1 ? "" : "s"} will send |
| 887 | </Text> | 902 | automatically. |
| 903 | </Text> | ||
| 904 | </Animated.View> | ||
| 888 | ) : null} | 905 | ) : null} |
| 889 | </View> | 906 | </Animated.View> |
| 890 | 907 | ||
| 891 | <ImageViewing | 908 | <ImageViewing |
| 892 | images={previewImageUri ? [{ uri: previewImageUri }] : []} | 909 | images={previewImageUri ? [{ uri: previewImageUri }] : []} |
| ... | @@ -896,6 +913,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer | ... | @@ -896,6 +913,6 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 896 | swipeToCloseEnabled | 913 | swipeToCloseEnabled |
| 897 | doubleTapToZoomEnabled | 914 | doubleTapToZoomEnabled |
| 898 | /> | 915 | /> |
| 899 | </View> | 916 | </Animated.View> |
| 900 | ); | 917 | ); |
| 901 | }); | 918 | }); |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+42-15| ... | @@ -17,8 +17,9 @@ import type { | ... | @@ -17,8 +17,9 @@ import type { |
| 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; | 17 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 18 | import * as Haptics from "expo-haptics"; | 18 | import * as Haptics from "expo-haptics"; |
| 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 19 | import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 20 | import { View, type GestureResponderEvent } from "react-native"; | 20 | import { Platform, View, type GestureResponderEvent } from "react-native"; |
| 21 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; | 21 | import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller"; |
| 22 | import Animated, { FadeInDown, FadeOut, LinearTransition } from "react-native-reanimated"; | ||
| 22 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 23 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 23 | 24 | ||
| 24 | import { AppText as Text } from "../../components/AppText"; | 25 | import { AppText as Text } from "../../components/AppText"; |
| ... | @@ -170,7 +171,11 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE | ... | @@ -170,7 +171,11 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray<ThreadFeedE |
| 170 | }, [threadId, feed]); | 171 | }, [threadId, feed]); |
| 171 | } | 172 | } |
| 172 | 173 | ||
| 173 | const WORKING_INDICATOR_HEIGHT = 44; | 174 | // Pre-measurement estimate for the working pill's slot (the real height is |
| 175 | // measured via onComposerLayout since the pill lives inside the composer | ||
| 176 | // overlay). Matches the rendered pill: pt-2 + pb-2 (16) wrapping a bordered | ||
| 177 | // px-3/py-2 row (~36), so ~52 — keep it in sync with WorkingDurationPill. | ||
| 178 | const WORKING_INDICATOR_HEIGHT = 52; | ||
| 174 | 179 | ||
| 175 | const WorkingDurationPill = memo(function WorkingDurationPill(props: { | 180 | const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 176 | readonly startedAt: string; | 181 | readonly startedAt: string; |
| ... | @@ -187,7 +192,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { | ... | @@ -187,7 +192,12 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 187 | const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; | 192 | const durationLabel = formatElapsed(props.startedAt, new Date(nowMs).toISOString()) ?? "0s"; |
| 188 | 193 | ||
| 189 | return ( | 194 | return ( |
| 190 | <View className="px-4 pb-2" style={{ flexShrink: 0 }}> | 195 | <Animated.View |
| 196 | className="px-4 pb-2 pt-2" | ||
| 197 | entering={FadeInDown.duration(200)} | ||
| 198 | exiting={FadeOut.duration(140)} | ||
| 199 | style={{ flexShrink: 0 }} | ||
| 200 | > | ||
| 191 | <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]"> | 201 | <View className="self-start rounded-full border border-neutral-200/80 bg-neutral-50/90 px-3 py-2 dark:border-white/[0.08] dark:bg-white/[0.04]"> |
| 192 | <View className="flex-row items-center gap-2"> | 202 | <View className="flex-row items-center gap-2"> |
| 193 | <View className="flex-row items-center gap-1"> | 203 | <View className="flex-row items-center gap-1"> |
| ... | @@ -200,7 +210,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { | ... | @@ -200,7 +210,7 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: { |
| 200 | </Text> | 210 | </Text> |
| 201 | </View> | 211 | </View> |
| 202 | </View> | 212 | </View> |
| 203 | </View> | 213 | </Animated.View> |
| 204 | ); | 214 | ); |
| 205 | }); | 215 | }); |
| 206 | 216 | ||
| ... | @@ -238,11 +248,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -238,11 +248,21 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 238 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; | 248 | const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; |
| 239 | const composerOverlapHeight = composerChrome + composerBottomInset; | 249 | const composerOverlapHeight = composerChrome + composerBottomInset; |
| 240 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; | 250 | const activeWorkIndicatorHeight = props.activeWorkStartedAt ? WORKING_INDICATOR_HEIGHT : 0; |
| 241 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight + 8; | 251 | const estimatedOverlayHeight = composerOverlapHeight + activeWorkIndicatorHeight; |
| 252 | // The overlay's measured height includes the home-indicator inset (the | ||
| 253 | // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes | ||
| 254 | // UIKit add the safe-area bottom to the content inset AGAIN — leaving a | ||
| 255 | // dead strip between the resting content and the composer. Report the | ||
| 256 | // overlay height minus the safe area; UIKit adds it back, and ThreadFeed | ||
| 257 | // hands LegendList the same delta via contentInsetEndStaticAdjustment so | ||
| 258 | // its end-scroll math matches the real resting position. | ||
| 259 | const nativeInsetOvercount = | ||
| 260 | props.usesAutomaticContentInsets === true && Platform.OS === "ios" ? insets.bottom : 0; | ||
| 242 | const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( | 261 | const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset( |
| 243 | listRef, | 262 | listRef, |
| 244 | composerOverlayRef, | 263 | composerOverlayRef, |
| 245 | estimatedOverlayHeight, | 264 | Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), |
| 265 | -nativeInsetOvercount, | ||
| 246 | ); | 266 | ); |
| 247 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); | 267 | const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); |
| 248 | const showContent = props.showContent ?? true; | 268 | const showContent = props.showContent ?? true; |
| ... | @@ -408,18 +428,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -408,18 +428,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 408 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} | 428 | style={{ position: "absolute", bottom: 0, left: 0, right: 0 }} |
| 409 | offset={{ closed: 0, opened: 0 }} | 429 | offset={{ closed: 0, opened: 0 }} |
| 410 | > | 430 | > |
| 411 | <View | 431 | {/* No paddingTop here: the overlay's measured height becomes the |
| 412 | ref={composerOverlayRef} | 432 | list's bottom inset, so any padding above the pill/composer |
| 413 | onLayout={onComposerLayout} | 433 | pushes the resting content floor up by the same amount. */} |
| 414 | style={{ width: "100%", paddingTop: 8 }} | 434 | <View ref={composerOverlayRef} onLayout={onComposerLayout} style={{ width: "100%" }}> |
| 415 | > | 435 | <Animated.View |
| 416 | <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}> | 436 | layout={LinearTransition.duration(220)} |
| 437 | style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }} | ||
| 438 | > | ||
| 417 | {props.activeWorkStartedAt ? ( | 439 | {props.activeWorkStartedAt ? ( |
| 418 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> | 440 | <WorkingDurationPill startedAt={props.activeWorkStartedAt} /> |
| 419 | ) : null} | 441 | ) : null} |
| 420 | 442 | ||
| 421 | {props.activePendingApproval || props.activePendingUserInput ? ( | 443 | {props.activePendingApproval || props.activePendingUserInput ? ( |
| 422 | <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}> | 444 | <Animated.View |
| 445 | className="gap-3 px-4 pb-3" | ||
| 446 | entering={FadeInDown.duration(220)} | ||
| 447 | exiting={FadeOut.duration(140)} | ||
| 448 | style={{ flexShrink: 0 }} | ||
| 449 | > | ||
| 423 | {props.activePendingApproval ? ( | 450 | {props.activePendingApproval ? ( |
| 424 | <PendingApprovalCard | 451 | <PendingApprovalCard |
| 425 | approval={props.activePendingApproval} | 452 | approval={props.activePendingApproval} |
| ... | @@ -438,9 +465,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread | ... | @@ -438,9 +465,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 438 | onSubmit={props.onSubmitUserInput} | 465 | onSubmit={props.onSubmitUserInput} |
| 439 | /> | 466 | /> |
| 440 | ) : null} | 467 | ) : null} |
| 441 | </View> | 468 | </Animated.View> |
| 442 | ) : null} | 469 | ) : null} |
| 443 | </View> | 470 | </Animated.View> |
| 444 | 471 | ||
| 445 | <ThreadComposer | 472 | <ThreadComposer |
| 446 | editorRef={composerEditorRef} | 473 | editorRef={composerEditorRef} |
apps/mobile/src/features/threads/ThreadFeed.tsx+111-11| ... | @@ -4,11 +4,14 @@ import { type LegendListRef } from "@legendapp/list/react-native"; | ... | @@ -4,11 +4,14 @@ import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; | 5 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | import { SymbolView } from "expo-symbols"; | 6 | import { SymbolView } from "expo-symbols"; |
| 7 | import { HeaderHeightContext } from "@react-navigation/elements"; | ||
| 7 | import { useNavigation } from "@react-navigation/native"; | 8 | import { useNavigation } from "@react-navigation/native"; |
| 8 | import { | 9 | import { |
| 9 | memo, | 10 | memo, |
| 10 | useCallback, | 11 | useCallback, |
| 12 | useContext, | ||
| 11 | useEffect, | 13 | useEffect, |
| 14 | useLayoutEffect, | ||
| 12 | useMemo, | 15 | useMemo, |
| 13 | useRef, | 16 | useRef, |
| 14 | useState, | 17 | useState, |
| ... | @@ -41,7 +44,13 @@ import { | ... | @@ -41,7 +44,13 @@ import { |
| 41 | import { TouchableOpacity } from "react-native-gesture-handler"; | 44 | import { TouchableOpacity } from "react-native-gesture-handler"; |
| 42 | import ImageViewing from "react-native-image-viewing"; | 45 | import ImageViewing from "react-native-image-viewing"; |
| 43 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 46 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 44 | import { type SharedValue } from "react-native-reanimated"; | 47 | import Animated, { |
| 48 | FadeIn, | ||
| 49 | FadeInUp, | ||
| 50 | FadeOut, | ||
| 51 | LinearTransition, | ||
| 52 | type SharedValue, | ||
| 53 | } from "react-native-reanimated"; | ||
| 45 | import { useThemeColor } from "../../lib/useThemeColor"; | 54 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 46 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | 55 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 47 | import { | 56 | import { |
| ... | @@ -96,6 +105,19 @@ function formatMessageTime(input: string): string { | ... | @@ -96,6 +105,19 @@ function formatMessageTime(input: string): string { |
| 96 | return MESSAGE_TIME_FORMATTER.format(timestamp); | 105 | return MESSAGE_TIME_FORMATTER.format(timestamp); |
| 97 | } | 106 | } |
| 98 | 107 | ||
| 108 | // Rows shift when content above them grows (streaming text, work-log folds); | ||
| 109 | // animating the container position turns those jumps into slides. | ||
| 110 | const FEED_ITEM_LAYOUT_TRANSITION = LinearTransition.duration(180); | ||
| 111 | |||
| 112 | // Entering animations must only play for rows born just now — LegendList | ||
| 113 | // remounts rows when they scroll back into view, and replaying an entrance for | ||
| 114 | // old content would be its own kind of jank. | ||
| 115 | const FRESH_ENTRY_WINDOW_MS = 3_000; | ||
| 116 | function isFreshTimestamp(input: string): boolean { | ||
| 117 | const timestamp = Date.parse(input); | ||
| 118 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ENTRY_WINDOW_MS; | ||
| 119 | } | ||
| 120 | |||
| 99 | export interface ThreadFeedProps { | 121 | export interface ThreadFeedProps { |
| 100 | readonly environmentId: EnvironmentId; | 122 | readonly environmentId: EnvironmentId; |
| 101 | readonly threadId: ThreadId; | 123 | readonly threadId: ThreadId; |
| ... | @@ -749,8 +771,12 @@ function renderFeedEntry( | ... | @@ -749,8 +771,12 @@ function renderFeedEntry( |
| 749 | !message.streaming; | 771 | !message.streaming; |
| 750 | 772 | ||
| 751 | if (isUser) { | 773 | if (isUser) { |
| 774 | const enterAnimated = isFreshTimestamp(message.createdAt); | ||
| 752 | return ( | 775 | return ( |
| 753 | <View className="mb-5 items-end"> | 776 | <Animated.View |
| 777 | className="mb-5 items-end" | ||
| 778 | {...(enterAnimated ? { entering: FadeInUp.duration(220) } : {})} | ||
| 779 | > | ||
| 754 | <View | 780 | <View |
| 755 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" | 781 | className="min-w-0 gap-2 rounded-[20px] px-3.5 py-2.5" |
| 756 | style={{ | 782 | style={{ |
| ... | @@ -794,7 +820,7 @@ function renderFeedEntry( | ... | @@ -794,7 +820,7 @@ function renderFeedEntry( |
| 794 | /> | 820 | /> |
| 795 | ) : null} | 821 | ) : null} |
| 796 | </View> | 822 | </View> |
| 797 | </View> | 823 | </Animated.View> |
| 798 | ); | 824 | ); |
| 799 | } | 825 | } |
| 800 | 826 | ||
| ... | @@ -804,8 +830,12 @@ function renderFeedEntry( | ... | @@ -804,8 +830,12 @@ function renderFeedEntry( |
| 804 | return null; | 830 | return null; |
| 805 | } | 831 | } |
| 806 | 832 | ||
| 833 | const enterAnimated = isFreshTimestamp(message.createdAt); | ||
| 807 | return ( | 834 | return ( |
| 808 | <View className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")}> | 835 | <Animated.View |
| 836 | className={cn(showAssistantMeta ? "mb-5 px-1" : "mb-2 px-1")} | ||
| 837 | {...(enterAnimated ? { entering: FadeIn.duration(220) } : {})} | ||
| 838 | > | ||
| 809 | {message.text.trim().length > 0 ? ( | 839 | {message.text.trim().length > 0 ? ( |
| 810 | hasNativeSelectableMarkdownText() ? ( | 840 | hasNativeSelectableMarkdownText() ? ( |
| 811 | <SelectableMarkdownText | 841 | <SelectableMarkdownText |
| ... | @@ -850,7 +880,7 @@ function renderFeedEntry( | ... | @@ -850,7 +880,7 @@ function renderFeedEntry( |
| 850 | </Text> | 880 | </Text> |
| 851 | </View> | 881 | </View> |
| 852 | ) : null} | 882 | ) : null} |
| 853 | </View> | 883 | </Animated.View> |
| 854 | ); | 884 | ); |
| 855 | } | 885 | } |
| 856 | 886 | ||
| ... | @@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1146,6 +1176,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1146 | const [viewportWidth, setViewportWidth] = useState(() => | 1176 | const [viewportWidth, setViewportWidth] = useState(() => |
| 1147 | props.layoutVariant === "split" ? 0 : windowWidth, | 1177 | props.layoutVariant === "split" ? 0 : windowWidth, |
| 1148 | ); | 1178 | ); |
| 1179 | const [viewportHeight, setViewportHeight] = useState(0); | ||
| 1149 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); | 1180 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 1150 | const [interactionState, setInteractionState] = useState<{ | 1181 | const [interactionState, setInteractionState] = useState<{ |
| 1151 | readonly copiedRowId: string | null; | 1182 | readonly copiedRowId: string | null; |
| ... | @@ -1177,6 +1208,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1177,6 +1208,17 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1177 | const bottomContentInset = props.contentBottomInset ?? 18; | 1208 | const bottomContentInset = props.contentBottomInset ?? 18; |
| 1178 | const usesNativeAutomaticInsets = | 1209 | const usesNativeAutomaticInsets = |
| 1179 | props.usesAutomaticContentInsets === true && Platform.OS === "ios"; | 1210 | props.usesAutomaticContentInsets === true && Platform.OS === "ios"; |
| 1211 | // With automatic insets the header inset lives in UIKit's adjustedContentInset, | ||
| 1212 | // which LegendList's JS anchoring math cannot see — it measures the anchored | ||
| 1213 | // end space from the scroll view's frame top. Fold the header height back into | ||
| 1214 | // the anchor offset or a just-sent message anchors underneath the header and | ||
| 1215 | // the oversized end space keeps maintainScrollAtEnd snapping away from earlier | ||
| 1216 | // messages. Read the context directly (useHeaderHeight throws outside a | ||
| 1217 | // header-providing screen) and fall back to the standard iOS bar height. | ||
| 1218 | const navigationHeaderHeight = useContext(HeaderHeightContext); | ||
| 1219 | const anchorTopInset = usesNativeAutomaticInsets | ||
| 1220 | ? navigationHeaderHeight || insets.top + 44 | ||
| 1221 | : topContentInset; | ||
| 1180 | 1222 | ||
| 1181 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); | 1223 | const iconSubtleColor = useThemeColor("--color-icon-subtle"); |
| 1182 | const userBubbleColor = useThemeColor("--color-user-bubble"); | 1224 | const userBubbleColor = useThemeColor("--color-user-bubble"); |
| ... | @@ -1242,13 +1284,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1242,13 +1284,19 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1242 | ); | 1284 | ); |
| 1243 | const handleScroll = useCallback( | 1285 | const handleScroll = useCallback( |
| 1244 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { | 1286 | (event: NativeSyntheticEvent<NativeScrollEvent>) => { |
| 1245 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + topContentInset > 6); | 1287 | // anchorTopInset, not topContentInset: under automatic insets the list |
| 1288 | // rests at contentOffset.y = -headerHeight (the inset lives only in | ||
| 1289 | // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the | ||
| 1290 | // header height back or the material toggles a full header too late. | ||
| 1291 | reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); | ||
| 1246 | }, | 1292 | }, |
| 1247 | [reportHeaderMaterialVisibility, topContentInset], | 1293 | [reportHeaderMaterialVisibility, anchorTopInset], |
| 1248 | ); | 1294 | ); |
| 1249 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { | 1295 | const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { |
| 1250 | const nextWidth = Math.round(event.nativeEvent.layout.width); | 1296 | const nextWidth = Math.round(event.nativeEvent.layout.width); |
| 1297 | const nextHeight = Math.round(event.nativeEvent.layout.height); | ||
| 1251 | setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); | 1298 | setViewportWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); |
| 1299 | setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); | ||
| 1252 | }, []); | 1300 | }, []); |
| 1253 | 1301 | ||
| 1254 | useEffect(() => { | 1302 | useEffect(() => { |
| ... | @@ -1275,15 +1323,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1275,15 +1323,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1275 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], | 1323 | [expandedTurnIds, expandedWorkGroupIds, props.feed, props.latestTurn], |
| 1276 | ); | 1324 | ); |
| 1277 | 1325 | ||
| 1326 | // The empty↔filled key below remounts the list, which resets its imperative | ||
| 1327 | // content-inset override — and useKeyboardChatComposerInset (mounted above | ||
| 1328 | // the remount boundary) deduplicates by height, so it never re-reports the | ||
| 1329 | // composer inset to the fresh instance. Without this, the remounted list's | ||
| 1330 | // initial scroll-to-end computes with a zero end inset and rests one | ||
| 1331 | // composer-height short of the end. Layout effect: it must land before the | ||
| 1332 | // list's first positioning tick or the one-shot initial scroll misses it. | ||
| 1333 | const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; | ||
| 1334 | useLayoutEffect(() => { | ||
| 1335 | const bottom = props.contentInsetEndAdjustment.value; | ||
| 1336 | if (bottom > 0) { | ||
| 1337 | props.listRef.current?.reportContentInset({ bottom }); | ||
| 1338 | } | ||
| 1339 | }, [listMountKey, props.contentInsetEndAdjustment, props.listRef]); | ||
| 1340 | |||
| 1278 | const anchoredEndSpace = useMemo( | 1341 | const anchoredEndSpace = useMemo( |
| 1279 | () => | 1342 | () => |
| 1280 | resolveChatListAnchoredEndSpace( | 1343 | resolveChatListAnchoredEndSpace( |
| 1281 | presentedFeed, | 1344 | presentedFeed, |
| 1282 | props.anchorMessageId, | 1345 | props.anchorMessageId, |
| 1283 | (entry) => (entry.type === "message" ? entry.id : null), | 1346 | (entry) => (entry.type === "message" ? entry.id : null), |
| 1284 | { anchorOffset: topContentInset + CHAT_LIST_ANCHOR_OFFSET }, | 1347 | { anchorOffset: anchorTopInset + CHAT_LIST_ANCHOR_OFFSET }, |
| 1285 | ), | 1348 | ), |
| 1286 | [presentedFeed, props.anchorMessageId, topContentInset], | 1349 | [presentedFeed, props.anchorMessageId, anchorTopInset], |
| 1287 | ); | 1350 | ); |
| 1288 | const terminalAssistantMessageIds = useMemo(() => { | 1351 | const terminalAssistantMessageIds = useMemo(() => { |
| 1289 | const terminalIdsByTurn = new Map<TurnId, string>(); | 1352 | const terminalIdsByTurn = new Map<TurnId, string>(); |
| ... | @@ -1506,7 +1569,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1506,7 +1569,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1506 | // an already-attached list under a transparent header can pin | 1569 | // an already-attached list under a transparent header can pin |
| 1507 | // short content at offset 0 (one header-height too high). A fresh | 1570 | // short content at offset 0 (one header-height too high). A fresh |
| 1508 | // mount positions during attach, where UIKit applies the inset. | 1571 | // mount positions during attach, where UIKit applies the inset. |
| 1509 | key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`} | 1572 | key={listMountKey} |
| 1510 | style={{ flex: 1 }} | 1573 | style={{ flex: 1 }} |
| 1511 | // RN 0.81+ drops touches inside the contentInset area | 1574 | // RN 0.81+ drops touches inside the contentInset area |
| 1512 | // (facebook/react-native#54123); the anchored end space after a send | 1575 | // (facebook/react-native#54123); the anchored end space after a send |
| ... | @@ -1527,13 +1590,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1527,13 +1590,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1527 | } | 1590 | } |
| 1528 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} | 1591 | : { scrollIndicatorInsets: { top: topContentInset, bottom: 0 } })} |
| 1529 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} | 1592 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1593 | itemLayoutAnimation={FEED_ITEM_LAYOUT_TRANSITION} | ||
| 1594 | // Patched LegendList prop (patches/@legendapp__list@3.2.0.patch): | ||
| 1595 | // lets its scroll math clamp programmatic scrolls to -headerInset | ||
| 1596 | // instead of 0, so initialScrollAtEnd/maintainScrollAtEnd on short | ||
| 1597 | // content rest below the transparent header rather than at frame top. | ||
| 1598 | contentInsetStartAdjustment={usesNativeAutomaticInsets ? anchorTopInset : 0} | ||
| 1530 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} | 1599 | contentInsetEndAdjustment={props.contentInsetEndAdjustment} |
| 1600 | // UIKit's automatic behavior adds the safe-area bottom on top of the | ||
| 1601 | // raw contentInset the keyboard integration writes. The detail screen | ||
| 1602 | // under-reports the composer inset by this amount (see | ||
| 1603 | // ThreadDetailScreen); this tells LegendList's scroll math about the | ||
| 1604 | // extra so programmatic end scrolls land at the true resting offset. | ||
| 1605 | contentInsetEndStaticAdjustment={usesNativeAutomaticInsets ? insets.bottom : 0} | ||
| 1606 | // The keyboard integration's offset math (end pinning, max scroll) | ||
| 1607 | // must add the same UIKit-added extra, or its keyboard-open end | ||
| 1608 | // targets land one safe-area short of the true resting offset. | ||
| 1609 | adjustedInsetCompensation={usesNativeAutomaticInsets ? insets.bottom : 0} | ||
| 1531 | freeze={props.freeze} | 1610 | freeze={props.freeze} |
| 1611 | // Animated: on send, the optimistic message's dataChange fires | ||
| 1612 | // maintainScrollAtEnd before any render-cycle suppression could | ||
| 1613 | // engage — an instant snap there teleports the feed to the anchor | ||
| 1614 | // instead of scrolling to it. Keeping it enabled (animated) during | ||
| 1615 | // anchor scrolls also lets it correct a scroll that landed on a | ||
| 1616 | // stale end target once the anchor row finishes measuring. | ||
| 1532 | maintainScrollAtEnd={ | 1617 | maintainScrollAtEnd={ |
| 1533 | disclosureToggleSettling | 1618 | disclosureToggleSettling |
| 1534 | ? false | 1619 | ? false |
| 1535 | : { | 1620 | : { |
| 1536 | animated: false, | 1621 | animated: true, |
| 1537 | on: { | 1622 | on: { |
| 1538 | dataChange: true, | 1623 | dataChange: true, |
| 1539 | itemLayout: true, | 1624 | itemLayout: true, |
| ... | @@ -1552,6 +1637,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1552,6 +1637,21 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1552 | keyboardShouldPersistTaps="always" | 1637 | keyboardShouldPersistTaps="always" |
| 1553 | keyboardDismissMode="none" | 1638 | keyboardDismissMode="none" |
| 1554 | keyboardLiftBehavior="whenAtEnd" | 1639 | keyboardLiftBehavior="whenAtEnd" |
| 1640 | // Seed the list's scroll math with the real viewport before its own | ||
| 1641 | // onLayout: the empty→filled remount can then tell at mount that | ||
| 1642 | // short content underflows the viewport and skip programmatic | ||
| 1643 | // positioning entirely (any offset write during screen attach races | ||
| 1644 | // UIKit's adjustedContentInset application and lands high or low). | ||
| 1645 | {...(viewportHeight > 0 && viewportWidth > 0 | ||
| 1646 | ? { estimatedListSize: { height: viewportHeight, width: viewportWidth } } | ||
| 1647 | : {})} | ||
| 1648 | // RN's native scrollTo command clamps targets to a floor of | ||
| 1649 | // -contentInset.top using the RAW inset — under automatic insets the | ||
| 1650 | // header inset only exists in adjustedContentInset, so scrolls to | ||
| 1651 | // negative offsets (content top below the transparent header) get | ||
| 1652 | // clamped to 0. This prop disables that clamp; UIKit still bounces | ||
| 1653 | // user overscroll back to the adjusted rest position. | ||
| 1654 | scrollToOverflowEnabled | ||
| 1555 | estimatedItemSize={180} | 1655 | estimatedItemSize={180} |
| 1556 | initialScrollAtEnd | 1656 | initialScrollAtEnd |
| 1557 | onScroll={handleScroll} | 1657 | onScroll={handleScroll} |
apps/mobile/src/features/threads/thread-work-log.tsx+14-2| ... | @@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re | ... | @@ -5,6 +5,7 @@ import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "re |
| 5 | import { AppText as Text } from "../../components/AppText"; | 5 | import { AppText as Text } from "../../components/AppText"; |
| 6 | import { cn } from "../../lib/cn"; | 6 | import { cn } from "../../lib/cn"; |
| 7 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; | 7 | import type { ThreadFeedActivity } from "../../lib/threadActivity"; |
| 8 | import Animated, { FadeIn } from "react-native-reanimated"; | ||
| 8 | 9 | ||
| 9 | const WORK_LOG_LAYOUT_ANIMATION = { | 10 | const WORK_LOG_LAYOUT_ANIMATION = { |
| 10 | duration: 180, | 11 | duration: 180, |
| ... | @@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { | ... | @@ -68,6 +69,14 @@ function workRowSymbolName(icon: ThreadFeedActivity["icon"]): SFSymbol { |
| 68 | } | 69 | } |
| 69 | } | 70 | } |
| 70 | 71 | ||
| 72 | // Entering fades only for rows created moments ago: rows remount whenever the | ||
| 73 | // list scrolls them back into view, and old rows must not replay an entrance. | ||
| 74 | const FRESH_ROW_WINDOW_MS = 3_000; | ||
| 75 | function isFreshRow(createdAt: string): boolean { | ||
| 76 | const timestamp = Date.parse(createdAt); | ||
| 77 | return Number.isFinite(timestamp) && Date.now() - timestamp < FRESH_ROW_WINDOW_MS; | ||
| 78 | } | ||
| 79 | |||
| 71 | export function ThreadWorkLog(props: { | 80 | export function ThreadWorkLog(props: { |
| 72 | readonly activities: ReadonlyArray<ThreadFeedActivity>; | 81 | readonly activities: ReadonlyArray<ThreadFeedActivity>; |
| 73 | readonly copiedRowId: string | null; | 82 | readonly copiedRowId: string | null; |
| ... | @@ -104,7 +113,10 @@ export function ThreadWorkLog(props: { | ... | @@ -104,7 +113,10 @@ export function ThreadWorkLog(props: { |
| 104 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; | 113 | const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; |
| 105 | 114 | ||
| 106 | return ( | 115 | return ( |
| 107 | <View key={row.id}> | 116 | <Animated.View |
| 117 | key={row.id} | ||
| 118 | {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})} | ||
| 119 | > | ||
| 108 | <Pressable | 120 | <Pressable |
| 109 | accessibilityRole={canExpand ? "button" : undefined} | 121 | accessibilityRole={canExpand ? "button" : undefined} |
| 110 | accessibilityLabel={displayText} | 122 | accessibilityLabel={displayText} |
| ... | @@ -207,7 +219,7 @@ export function ThreadWorkLog(props: { | ... | @@ -207,7 +219,7 @@ export function ThreadWorkLog(props: { |
| 207 | </ScrollView> | 219 | </ScrollView> |
| 208 | </View> | 220 | </View> |
| 209 | ) : null} | 221 | ) : null} |
| 210 | </View> | 222 | </Animated.View> |
| 211 | ); | 223 | ); |
| 212 | })} | 224 | })} |
| 213 | </View> | 225 | </View> |
patches/@legendapp__list@3.2.0.patch created+922| ... | @@ -0,0 +1,922 @@ | ||
| 1 | diff --git a/keyboard.d.ts b/keyboard.d.ts | ||
| 2 | index 5a115ea..2c65d31 100644 | ||
| 3 | --- a/keyboard.d.ts | ||
| 4 | +++ b/keyboard.d.ts | ||
| 5 | @@ -269,7 +269,7 @@ type KeyboardChatComposerInsetListRef = { | ||
| 6 | type KeyboardChatComposerRef = { | ||
| 7 | current: Pick<View, "measure"> | null; | ||
| 8 | }; | ||
| 9 | -declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number): { | ||
| 10 | +declare function useKeyboardChatComposerInset(listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, initialHeight?: number, heightAdjustment?: number): { | ||
| 11 | contentInsetEndAdjustment: SharedValue<number>; | ||
| 12 | onComposerLayout: (event: LayoutChangeEvent) => void; | ||
| 13 | }; | ||
| 14 | @@ -278,8 +278,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb | ||
| 15 | scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise<void>; | ||
| 16 | }; | ||
| 17 | declare const KeyboardAwareLegendList: <ItemT>(props: Omit<AnimatedLegendListProps<ItemT>, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { | ||
| 18 | + adjustedInsetCompensation?: number; | ||
| 19 | anchoredEndSpace?: AnchoredEndSpaceConfig; | ||
| 20 | contentInsetEndAdjustment?: SharedValue<number>; | ||
| 21 | + contentInsetEndStaticAdjustment?: number; | ||
| 22 | keyboardOffset?: number; | ||
| 23 | } & React.RefAttributes<LegendListRef>) => React.ReactElement | null; | ||
| 24 | |||
| 25 | diff --git a/keyboard.js b/keyboard.js | ||
| 26 | index 736286a..8218172 100644 | ||
| 27 | --- a/keyboard.js | ||
| 28 | +++ b/keyboard.js | ||
| 29 | @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. | ||
| 30 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | ||
| 31 | ); | ||
| 32 | } | ||
| 33 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | ||
| 34 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | ||
| 35 | const contentInsetEndAdjustment = reactNativeReanimated.useSharedValue(initialHeight); | ||
| 36 | const lastHeightRef = React.useRef(void 0); | ||
| 37 | const reportHeight = React.useCallback( | ||
| 38 | - (height) => { | ||
| 39 | + (rawHeight) => { | ||
| 40 | + const height = Math.max(0, rawHeight + heightAdjustment); | ||
| 41 | var _a; | ||
| 42 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | ||
| 43 | lastHeightRef.current = height; | ||
| 44 | contentInsetEndAdjustment.value = height; | ||
| 45 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | ||
| 46 | } | ||
| 47 | }, | ||
| 48 | - [contentInsetEndAdjustment, listRef] | ||
| 49 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | ||
| 50 | ); | ||
| 51 | React.useLayoutEffect(() => { | ||
| 52 | var _a; | ||
| 53 | @@ -84,9 +84,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | ||
| 54 | } | ||
| 55 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | ||
| 56 | const { | ||
| 57 | + adjustedInsetCompensation, | ||
| 58 | anchoredEndSpace, | ||
| 59 | applyWorkaroundForContentInsetHitTestBug, | ||
| 60 | contentInsetEndAdjustment, | ||
| 61 | + contentInsetEndStaticAdjustment, | ||
| 62 | freeze, | ||
| 63 | keyboardLiftBehavior, | ||
| 64 | keyboardOffset, | ||
| 65 | @@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 66 | includeInEndInset: true, | ||
| 67 | onSizeChanged: (size) => { | ||
| 68 | var _a; | ||
| 69 | - blankSpace.value = size; | ||
| 70 | + // The anchored blank is sized against the ADJUSTED viewport, but the | ||
| 71 | + // scroll component writes it as a raw contentInset that UIKit tops up | ||
| 72 | + // with the safe-area bottom — write it net of that or the end rest | ||
| 73 | + // (and the anchored message) sinks one safe-area under the header. | ||
| 74 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | ||
| 75 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | ||
| 76 | } | ||
| 77 | }; | ||
| 78 | - }, [anchoredEndSpace, blankSpace]); | ||
| 79 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | ||
| 80 | const onContentInsetChange = React.useCallback((insets) => { | ||
| 81 | var _a; | ||
| 82 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | ||
| 83 | @@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 84 | reactNativeKeyboardController.KeyboardChatScrollView, | ||
| 85 | { | ||
| 86 | ...scrollProps, | ||
| 87 | + adjustedInsetCompensation, | ||
| 88 | applyWorkaroundForContentInsetHitTestBug, | ||
| 89 | blankSpace, | ||
| 90 | extraContentPadding: contentInsetEndAdjustment, | ||
| 91 | @@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 92 | ); | ||
| 93 | }, | ||
| 94 | [ | ||
| 95 | + adjustedInsetCompensation, | ||
| 96 | applyWorkaroundForContentInsetHitTestBug, | ||
| 97 | blankSpace, | ||
| 98 | contentInsetEndAdjustment, | ||
| 99 | @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 100 | AnimatedLegendListInternal, | ||
| 101 | { | ||
| 102 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | ||
| 103 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | ||
| 104 | ref: combinedRef, | ||
| 105 | renderScrollComponent: memoList, | ||
| 106 | ...rest | ||
| 107 | diff --git a/keyboard.mjs b/keyboard.mjs | ||
| 108 | index c1dd270..cb0d142 100644 | ||
| 109 | --- a/keyboard.mjs | ||
| 110 | +++ b/keyboard.mjs | ||
| 111 | @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { | ||
| 112 | "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7." | ||
| 113 | ); | ||
| 114 | } | ||
| 115 | -function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0) { | ||
| 116 | +function useKeyboardChatComposerInset(listRef, composerRef, initialHeight = 0, heightAdjustment = 0) { | ||
| 117 | const contentInsetEndAdjustment = useSharedValue(initialHeight); | ||
| 118 | const lastHeightRef = useRef(void 0); | ||
| 119 | const reportHeight = useCallback( | ||
| 120 | - (height) => { | ||
| 121 | + (rawHeight) => { | ||
| 122 | + const height = Math.max(0, rawHeight + heightAdjustment); | ||
| 123 | var _a; | ||
| 124 | if (Number.isFinite(height) && height !== lastHeightRef.current) { | ||
| 125 | lastHeightRef.current = height; | ||
| 126 | contentInsetEndAdjustment.value = height; | ||
| 127 | - (_a = listRef.current) == null ? void 0 : _a.reportContentInset({ bottom: height }); | ||
| 128 | } | ||
| 129 | }, | ||
| 130 | - [contentInsetEndAdjustment, listRef] | ||
| 131 | + [contentInsetEndAdjustment, heightAdjustment, listRef] | ||
| 132 | ); | ||
| 133 | useLayoutEffect(() => { | ||
| 134 | var _a; | ||
| 135 | @@ -63,9 +63,11 @@ function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }) { | ||
| 136 | } | ||
| 137 | var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2(props, forwardedRef) { | ||
| 138 | const { | ||
| 139 | + adjustedInsetCompensation, | ||
| 140 | anchoredEndSpace, | ||
| 141 | applyWorkaroundForContentInsetHitTestBug, | ||
| 142 | contentInsetEndAdjustment, | ||
| 143 | + contentInsetEndStaticAdjustment, | ||
| 144 | freeze, | ||
| 145 | keyboardLiftBehavior, | ||
| 146 | keyboardOffset, | ||
| 147 | @@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 148 | includeInEndInset: true, | ||
| 149 | onSizeChanged: (size) => { | ||
| 150 | var _a; | ||
| 151 | - blankSpace.value = size; | ||
| 152 | + // The anchored blank is sized against the ADJUSTED viewport, but the | ||
| 153 | + // scroll component writes it as a raw contentInset that UIKit tops up | ||
| 154 | + // with the safe-area bottom — write it net of that or the end rest | ||
| 155 | + // (and the anchored message) sinks one safe-area under the header. | ||
| 156 | + blankSpace.value = size > 0 ? Math.max(0, size - (adjustedInsetCompensation || 0)) : 0; | ||
| 157 | (_a = anchoredEndSpace.onSizeChanged) == null ? void 0 : _a.call(anchoredEndSpace, size); | ||
| 158 | } | ||
| 159 | }; | ||
| 160 | - }, [anchoredEndSpace, blankSpace]); | ||
| 161 | + }, [adjustedInsetCompensation, anchoredEndSpace, blankSpace]); | ||
| 162 | const onContentInsetChange = useCallback((insets) => { | ||
| 163 | var _a; | ||
| 164 | (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); | ||
| 165 | @@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 166 | KeyboardChatScrollView, | ||
| 167 | { | ||
| 168 | ...scrollProps, | ||
| 169 | + adjustedInsetCompensation, | ||
| 170 | applyWorkaroundForContentInsetHitTestBug, | ||
| 171 | blankSpace, | ||
| 172 | extraContentPadding: contentInsetEndAdjustment, | ||
| 173 | @@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 174 | ); | ||
| 175 | }, | ||
| 176 | [ | ||
| 177 | + adjustedInsetCompensation, | ||
| 178 | applyWorkaroundForContentInsetHitTestBug, | ||
| 179 | blankSpace, | ||
| 180 | contentInsetEndAdjustment, | ||
| 181 | @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( | ||
| 182 | AnimatedLegendListInternal, | ||
| 183 | { | ||
| 184 | anchoredEndSpace: anchoredEndSpaceWithBlankSpace, | ||
| 185 | + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, | ||
| 186 | ref: combinedRef, | ||
| 187 | renderScrollComponent: memoList, | ||
| 188 | ...rest | ||
| 189 | diff --git a/react-native.d.ts b/react-native.d.ts | ||
| 190 | index 72d3f59..435a5fc 100644 | ||
| 191 | --- a/react-native.d.ts | ||
| 192 | +++ b/react-native.d.ts | ||
| 193 | @@ -284,6 +284,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | ||
| 194 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | ||
| 195 | */ | ||
| 196 | contentInsetEndAdjustment?: number; | ||
| 197 | + /** | ||
| 198 | + * Width/height of a leading content inset applied natively outside the list's knowledge | ||
| 199 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | ||
| 200 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | ||
| 201 | + */ | ||
| 202 | + contentInsetStartAdjustment?: number; | ||
| 203 | /** | ||
| 204 | * Number of columns to render items in. | ||
| 205 | * @default 1 | ||
| 206 | diff --git a/react-native.js b/react-native.js | ||
| 207 | index 8d4ff89..18f0d62 100644 | ||
| 208 | --- a/react-native.js | ||
| 209 | +++ b/react-native.js | ||
| 210 | @@ -1195,7 +1195,7 @@ function setInitialRenderState(ctx, { | ||
| 211 | if (didInitialScroll) { | ||
| 212 | state.didFinishInitialScroll = true; | ||
| 213 | } | ||
| 214 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | ||
| 215 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | ||
| 216 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | ||
| 217 | set$(ctx, "readyToRender", true); | ||
| 218 | setAdaptiveRender(ctx, "normal"); | ||
| 219 | @@ -1480,18 +1480,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | ||
| 220 | } | ||
| 221 | |||
| 222 | // src/core/clampScrollOffset.ts | ||
| 223 | +function getContentInsetStartAdjustment(ctx) { | ||
| 224 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | ||
| 225 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | ||
| 226 | +} | ||
| 227 | function clampScrollOffset(ctx, offset, scrollTarget) { | ||
| 228 | const state = ctx.state; | ||
| 229 | const contentSize = getContentSize(ctx); | ||
| 230 | + const minOffset = -getContentInsetStartAdjustment(ctx); | ||
| 231 | let clampedOffset = offset; | ||
| 232 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | ||
| 233 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | ||
| 234 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | ||
| 235 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | ||
| 236 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | ||
| 237 | const maxOffset = baseMaxOffset + extraEndOffset; | ||
| 238 | clampedOffset = Math.min(offset, maxOffset); | ||
| 239 | } | ||
| 240 | - clampedOffset = Math.max(0, clampedOffset); | ||
| 241 | + clampedOffset = Math.max(minOffset, clampedOffset); | ||
| 242 | return clampedOffset; | ||
| 243 | } | ||
| 244 | |||
| 245 | @@ -1626,10 +1631,10 @@ function checkFinishedScrollFrame(ctx) { | ||
| 246 | finishScrollTo(ctx); | ||
| 247 | } | ||
| 248 | } | ||
| 249 | -function scrollToFallbackOffset(ctx, offset) { | ||
| 250 | +function scrollToFallbackOffset(ctx, offset, animated) { | ||
| 251 | var _a3; | ||
| 252 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | ||
| 253 | - animated: false, | ||
| 254 | + animated: !!animated, | ||
| 255 | x: ctx.state.props.horizontal ? offset : 0, | ||
| 256 | y: ctx.state.props.horizontal ? 0 : offset | ||
| 257 | }); | ||
| 258 | @@ -1676,7 +1681,10 @@ function checkFinishedScrollFallback(ctx) { | ||
| 259 | }); | ||
| 260 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | ||
| 261 | } else if (shouldRetryUnalignedEndScroll) { | ||
| 262 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | ||
| 263 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | ||
| 264 | + if (!isActivelyAnimatingToEnd) { | ||
| 265 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | ||
| 266 | + } | ||
| 267 | scheduleFallbackCheck(100); | ||
| 268 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | ||
| 269 | finishScrollTo(ctx); | ||
| 270 | @@ -1737,9 +1745,18 @@ function doMaintainScrollAtEnd(ctx) { | ||
| 271 | } | ||
| 272 | state.pendingMaintainScrollAtEnd = false; | ||
| 273 | if (shouldMaintainScrollAtEnd) { | ||
| 274 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | ||
| 275 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | ||
| 276 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | ||
| 277 | + return false; | ||
| 278 | + } | ||
| 279 | const contentSize = getContentSize(ctx); | ||
| 280 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 281 | if (contentSize < state.scrollLength) { | ||
| 282 | - state.scroll = 0; | ||
| 283 | + state.scroll = -maintainInsetStartAdjustment; | ||
| 284 | + if (maintainInsetStartAdjustment > 0) { | ||
| 285 | + return true; | ||
| 286 | + } | ||
| 287 | } | ||
| 288 | if (!state.maintainingScrollAtEnd) { | ||
| 289 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | ||
| 290 | @@ -1759,9 +1776,18 @@ function doMaintainScrollAtEnd(ctx) { | ||
| 291 | y: 0 | ||
| 292 | }); | ||
| 293 | } else { | ||
| 294 | - scroller == null ? void 0 : scroller.scrollToEnd({ | ||
| 295 | - animated: maintainScrollAtEnd.animated | ||
| 296 | - }); | ||
| 297 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 298 | + if (insetStartAdjustment > 0) { | ||
| 299 | + scroller == null ? void 0 : scroller.scrollTo({ | ||
| 300 | + animated: maintainScrollAtEnd.animated, | ||
| 301 | + x: 0, | ||
| 302 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | ||
| 303 | + }); | ||
| 304 | + } else { | ||
| 305 | + scroller == null ? void 0 : scroller.scrollToEnd({ | ||
| 306 | + animated: maintainScrollAtEnd.animated | ||
| 307 | + }); | ||
| 308 | + } | ||
| 309 | } | ||
| 310 | setTimeout( | ||
| 311 | () => { | ||
| 312 | @@ -1888,7 +1914,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | ||
| 313 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | ||
| 314 | return 0; | ||
| 315 | } | ||
| 316 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | ||
| 317 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | ||
| 318 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | ||
| 319 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | ||
| 320 | const clampDelta = maxScroll - state.scroll; | ||
| 321 | if (unresolvedAmount < 0) { | ||
| 322 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | ||
| 323 | @@ -1950,7 +1978,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | ||
| 324 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | ||
| 325 | return true; | ||
| 326 | } | ||
| 327 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | ||
| 328 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | ||
| 329 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | ||
| 330 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | ||
| 331 | if (isAtExpectedNativeClamp) { | ||
| 332 | @@ -2083,7 +2111,7 @@ function prepareMVCP(ctx, dataChanged) { | ||
| 333 | if (diff > 0) { | ||
| 334 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | ||
| 335 | } else { | ||
| 336 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | ||
| 337 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | ||
| 338 | state.scroll = maxScroll; | ||
| 339 | state.scrollPending = maxScroll; | ||
| 340 | diff = 0; | ||
| 341 | @@ -2374,8 +2402,121 @@ function scrollToIndex(ctx, { | ||
| 342 | } | ||
| 343 | |||
| 344 | // src/core/initialScroll.ts | ||
| 345 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | ||
| 346 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | ||
| 347 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | ||
| 348 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | ||
| 349 | +function startInsetEndSettleWatchdog(ctx) { | ||
| 350 | + const state = ctx.state; | ||
| 351 | + if (state.insetEndSettleWatchdogActive) { | ||
| 352 | + return; | ||
| 353 | + } | ||
| 354 | + state.insetEndSettleWatchdogActive = true; | ||
| 355 | + state.didUserDrag = false; | ||
| 356 | + // Hold the readyToRender opacity gate until the end landing is stable, so | ||
| 357 | + // the estimated-to-measured settle chase happens before first VISIBLE | ||
| 358 | + // paint instead of in front of the user. Capped so slow measurement can | ||
| 359 | + // never hide content for long. | ||
| 360 | + state.insetEndRevealHold = true; | ||
| 361 | + let frames = 0; | ||
| 362 | + let settledFrames = 0; | ||
| 363 | + let revealStableFrames = 0; | ||
| 364 | + const releaseRevealHold = () => { | ||
| 365 | + if (state.insetEndRevealHold) { | ||
| 366 | + state.insetEndRevealHold = false; | ||
| 367 | + setInitialRenderState(ctx, {}); | ||
| 368 | + } | ||
| 369 | + }; | ||
| 370 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | ||
| 371 | + // can move the end target one more time — do that upgrade while still | ||
| 372 | + // hidden (phase 1), then require stability again before revealing (phase 2) | ||
| 373 | + // so the first visible frame is the settled one. | ||
| 374 | + let revealPhase = 1; | ||
| 375 | + const onRevealStability = () => { | ||
| 376 | + var _a3; | ||
| 377 | + if (!state.insetEndRevealHold) { | ||
| 378 | + return; | ||
| 379 | + } | ||
| 380 | + if (revealPhase === 1) { | ||
| 381 | + revealPhase = 2; | ||
| 382 | + revealStableFrames = 0; | ||
| 383 | + setAdaptiveRender(ctx, "normal"); | ||
| 384 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | ||
| 385 | + return; | ||
| 386 | + } | ||
| 387 | + releaseRevealHold(); | ||
| 388 | + }; | ||
| 389 | + const tick = () => { | ||
| 390 | + // The user owns the scroll from their first drag — a re-pin here would | ||
| 391 | + // pin the list under their finger. | ||
| 392 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | ||
| 393 | + state.insetEndSettleWatchdogActive = false; | ||
| 394 | + releaseRevealHold(); | ||
| 395 | + return; | ||
| 396 | + } | ||
| 397 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | ||
| 398 | + releaseRevealHold(); | ||
| 399 | + } | ||
| 400 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 401 | + const contentSize = getContentSize(ctx); | ||
| 402 | + const scrollLength = state.scrollLength; | ||
| 403 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | ||
| 404 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | ||
| 405 | + const distance = endOffset - state.scroll; | ||
| 406 | + // Estimated row sizes converging to measured ones can strand the initial | ||
| 407 | + // end landing when the library's own end-anchor bookkeeping gives up. | ||
| 408 | + // While still near the end (never fighting a user who scrolled away), | ||
| 409 | + // re-pin to the current true end until sizes stop changing. | ||
| 410 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | ||
| 411 | + settledFrames = 0; | ||
| 412 | + revealStableFrames = 0; | ||
| 413 | + const scroller = state.refScroller.current; | ||
| 414 | + if (scroller) { | ||
| 415 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | ||
| 416 | + } | ||
| 417 | + } else { | ||
| 418 | + settledFrames++; | ||
| 419 | + revealStableFrames++; | ||
| 420 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | ||
| 421 | + onRevealStability(); | ||
| 422 | + } | ||
| 423 | + } | ||
| 424 | + } else { | ||
| 425 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | ||
| 426 | + // programmatic scroll) count toward stability for the reveal. | ||
| 427 | + revealStableFrames++; | ||
| 428 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | ||
| 429 | + onRevealStability(); | ||
| 430 | + } | ||
| 431 | + } | ||
| 432 | + requestAnimationFrame(tick); | ||
| 433 | + }; | ||
| 434 | + requestAnimationFrame(tick); | ||
| 435 | +} | ||
| 436 | function dispatchInitialScroll(ctx, params) { | ||
| 437 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | ||
| 438 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 439 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | ||
| 440 | + if (ctx.state.scrollLength <= 0) { | ||
| 441 | + // The scroll length is unknown, so the resolved target is meaningless; | ||
| 442 | + // the initial-scroll machinery re-advances after layout. | ||
| 443 | + return; | ||
| 444 | + } | ||
| 445 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | ||
| 446 | + // Content underflows the viewport and nothing has scrolled yet: the | ||
| 447 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | ||
| 448 | + // end position. Dispatching a negative scroll here would race UIKit's | ||
| 449 | + // inset application during screen attach, so just finish the session. | ||
| 450 | + finishInitialScroll(ctx, { | ||
| 451 | + resolvedOffset | ||
| 452 | + }); | ||
| 453 | + return; | ||
| 454 | + } | ||
| 455 | + ctx.state.didDispatchInsetInitialScroll = true; | ||
| 456 | + if (target.viewPosition === 1) { | ||
| 457 | + startInsetEndSettleWatchdog(ctx); | ||
| 458 | + } | ||
| 459 | + } | ||
| 460 | const requestedIndex = target.index; | ||
| 461 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | ||
| 462 | const itemSize = getItemSizeAtIndex(ctx, index); | ||
| 463 | @@ -2804,7 +2945,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | ||
| 464 | return; | ||
| 465 | } | ||
| 466 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | ||
| 467 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | ||
| 468 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | ||
| 469 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | ||
| 470 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | ||
| 471 | if (!shouldKeepEndTargetAlive) { | ||
| 472 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | ||
| 473 | clearPendingInitialScrollFooterLayout(ctx, { | ||
| 474 | @@ -4646,7 +4789,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | ||
| 475 | } | ||
| 476 | contentBelowAnchor += footerSize + stylePaddingBottom; | ||
| 477 | isReady = !hasUnknownTailSize; | ||
| 478 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | ||
| 479 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | ||
| 480 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | ||
| 481 | } else if (anchorIndex >= 0) { | ||
| 482 | isReady = false; | ||
| 483 | } | ||
| 484 | @@ -4664,6 +4808,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | ||
| 485 | updateScroll(ctx, state.scroll, true); | ||
| 486 | } | ||
| 487 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | ||
| 488 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | ||
| 489 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | ||
| 490 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | ||
| 491 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | ||
| 492 | + updateScroll(ctx, state.scroll, true); | ||
| 493 | + } | ||
| 494 | } | ||
| 495 | return nextSize; | ||
| 496 | } | ||
| 497 | @@ -6462,6 +6612,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 498 | dataVersion, | ||
| 499 | drawDistance = 250, | ||
| 500 | contentInsetEndAdjustment, | ||
| 501 | + contentInsetStartAdjustment, | ||
| 502 | estimatedItemSize = 100, | ||
| 503 | estimatedListSize, | ||
| 504 | extraData, | ||
| 505 | @@ -6492,6 +6643,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 506 | onLayout: onLayoutProp, | ||
| 507 | onLoad, | ||
| 508 | onMomentumScrollEnd, | ||
| 509 | + onScrollBeginDrag, | ||
| 510 | onRefresh, | ||
| 511 | onScroll: onScrollProp, | ||
| 512 | onStartReached, | ||
| 513 | @@ -6710,6 +6862,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 514 | contentContainerAlignItems: contentContainerStyle.alignItems, | ||
| 515 | contentInset, | ||
| 516 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | ||
| 517 | + contentInsetStartAdjustment, | ||
| 518 | data: dataProp, | ||
| 519 | dataVersion, | ||
| 520 | drawDistance, | ||
| 521 | @@ -6789,6 +6942,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 522 | return void 0; | ||
| 523 | } | ||
| 524 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | ||
| 525 | + if (getContentInsetStartAdjustment(ctx) > 0) { | ||
| 526 | + // With a native leading inset (transparent header + automatic insets), | ||
| 527 | + // any mount-time contentOffset races UIKit's adjustedContentInset | ||
| 528 | + // application. The gated initial-scroll dispatch positions the list once | ||
| 529 | + // real sizes are known instead. | ||
| 530 | + return void 0; | ||
| 531 | + } | ||
| 532 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | ||
| 533 | }, [usesBootstrapInitialScroll]); | ||
| 534 | React2.useLayoutEffect(() => { | ||
| 535 | @@ -6995,6 +7155,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 536 | onMomentumScrollEnd(event); | ||
| 537 | } | ||
| 538 | }, | ||
| 539 | + onScrollBeginDrag: (event) => { | ||
| 540 | + ctx.state.didUserDrag = true; | ||
| 541 | + if (onScrollBeginDrag) { | ||
| 542 | + onScrollBeginDrag(event); | ||
| 543 | + } | ||
| 544 | + }, | ||
| 545 | onScroll: (event) => onScroll(ctx, event) | ||
| 546 | }), | ||
| 547 | [] | ||
| 548 | @@ -7019,6 +7185,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 549 | onLayout, | ||
| 550 | onLayoutFooter, | ||
| 551 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | ||
| 552 | + onScrollBeginDrag: fns.onScrollBeginDrag, | ||
| 553 | onScroll: onScrollHandler, | ||
| 554 | recycleItems, | ||
| 555 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { | ||
| 556 | diff --git a/react-native.mjs b/react-native.mjs | ||
| 557 | index 2e96ca7..6e8913e 100644 | ||
| 558 | --- a/react-native.mjs | ||
| 559 | +++ b/react-native.mjs | ||
| 560 | @@ -1174,7 +1174,7 @@ function setInitialRenderState(ctx, { | ||
| 561 | if (didInitialScroll) { | ||
| 562 | state.didFinishInitialScroll = true; | ||
| 563 | } | ||
| 564 | - const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll); | ||
| 565 | + const isReadyToRender = Boolean(state.didContainersLayout && state.didFinishInitialScroll && !state.insetEndRevealHold); | ||
| 566 | if (isReadyToRender && !peek$(ctx, "readyToRender")) { | ||
| 567 | set$(ctx, "readyToRender", true); | ||
| 568 | setAdaptiveRender(ctx, "normal"); | ||
| 569 | @@ -1459,18 +1459,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { | ||
| 570 | } | ||
| 571 | |||
| 572 | // src/core/clampScrollOffset.ts | ||
| 573 | +function getContentInsetStartAdjustment(ctx) { | ||
| 574 | + const adjustment = ctx.state.props.contentInsetStartAdjustment; | ||
| 575 | + return typeof adjustment === "number" && Number.isFinite(adjustment) ? Math.max(0, adjustment) : 0; | ||
| 576 | +} | ||
| 577 | function clampScrollOffset(ctx, offset, scrollTarget) { | ||
| 578 | const state = ctx.state; | ||
| 579 | const contentSize = getContentSize(ctx); | ||
| 580 | + const minOffset = -getContentInsetStartAdjustment(ctx); | ||
| 581 | let clampedOffset = offset; | ||
| 582 | if (Number.isFinite(contentSize) && Number.isFinite(state.scrollLength) && (Platform.OS !== "android" || state.lastLayout)) { | ||
| 583 | - const baseMaxOffset = Math.max(0, contentSize - state.scrollLength); | ||
| 584 | + const baseMaxOffset = Math.max(minOffset, contentSize - state.scrollLength); | ||
| 585 | const viewOffset = scrollTarget == null ? void 0 : scrollTarget.viewOffset; | ||
| 586 | const extraEndOffset = typeof viewOffset === "number" && viewOffset < 0 ? -viewOffset : 0; | ||
| 587 | const maxOffset = baseMaxOffset + extraEndOffset; | ||
| 588 | clampedOffset = Math.min(offset, maxOffset); | ||
| 589 | } | ||
| 590 | - clampedOffset = Math.max(0, clampedOffset); | ||
| 591 | + clampedOffset = Math.max(minOffset, clampedOffset); | ||
| 592 | return clampedOffset; | ||
| 593 | } | ||
| 594 | |||
| 595 | @@ -1605,10 +1610,10 @@ function checkFinishedScrollFrame(ctx) { | ||
| 596 | finishScrollTo(ctx); | ||
| 597 | } | ||
| 598 | } | ||
| 599 | -function scrollToFallbackOffset(ctx, offset) { | ||
| 600 | +function scrollToFallbackOffset(ctx, offset, animated) { | ||
| 601 | var _a3; | ||
| 602 | (_a3 = ctx.state.refScroller.current) == null ? void 0 : _a3.scrollTo({ | ||
| 603 | - animated: false, | ||
| 604 | + animated: !!animated, | ||
| 605 | x: ctx.state.props.horizontal ? offset : 0, | ||
| 606 | y: ctx.state.props.horizontal ? 0 : offset | ||
| 607 | }); | ||
| 608 | @@ -1655,7 +1660,10 @@ function checkFinishedScrollFallback(ctx) { | ||
| 609 | }); | ||
| 610 | scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); | ||
| 611 | } else if (shouldRetryUnalignedEndScroll) { | ||
| 612 | - scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); | ||
| 613 | + const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; | ||
| 614 | + if (!isActivelyAnimatingToEnd) { | ||
| 615 | + scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); | ||
| 616 | + } | ||
| 617 | scheduleFallbackCheck(100); | ||
| 618 | } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { | ||
| 619 | finishScrollTo(ctx); | ||
| 620 | @@ -1716,9 +1724,18 @@ function doMaintainScrollAtEnd(ctx) { | ||
| 621 | } | ||
| 622 | state.pendingMaintainScrollAtEnd = false; | ||
| 623 | if (shouldMaintainScrollAtEnd) { | ||
| 624 | + const maintainAnchoredEndSpace = state.props.anchoredEndSpace; | ||
| 625 | + const maintainAnchorIndex = maintainAnchoredEndSpace == null ? void 0 : maintainAnchoredEndSpace.anchorIndex; | ||
| 626 | + if (maintainAnchorIndex !== void 0 && maintainAnchorIndex >= 0 && maintainAnchorIndex < state.props.data.length && !areKnownOrFixedItemSizesAvailable(ctx, maintainAnchorIndex, state.props.data.length - 1)) { | ||
| 627 | + return false; | ||
| 628 | + } | ||
| 629 | const contentSize = getContentSize(ctx); | ||
| 630 | + const maintainInsetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 631 | if (contentSize < state.scrollLength) { | ||
| 632 | - state.scroll = 0; | ||
| 633 | + state.scroll = -maintainInsetStartAdjustment; | ||
| 634 | + if (maintainInsetStartAdjustment > 0) { | ||
| 635 | + return true; | ||
| 636 | + } | ||
| 637 | } | ||
| 638 | if (!state.maintainingScrollAtEnd) { | ||
| 639 | const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; | ||
| 640 | @@ -1738,9 +1755,18 @@ function doMaintainScrollAtEnd(ctx) { | ||
| 641 | y: 0 | ||
| 642 | }); | ||
| 643 | } else { | ||
| 644 | - scroller == null ? void 0 : scroller.scrollToEnd({ | ||
| 645 | - animated: maintainScrollAtEnd.animated | ||
| 646 | - }); | ||
| 647 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 648 | + if (insetStartAdjustment > 0) { | ||
| 649 | + scroller == null ? void 0 : scroller.scrollTo({ | ||
| 650 | + animated: maintainScrollAtEnd.animated, | ||
| 651 | + x: 0, | ||
| 652 | + y: Math.max(-insetStartAdjustment, getContentSize(ctx) - state.scrollLength) | ||
| 653 | + }); | ||
| 654 | + } else { | ||
| 655 | + scroller == null ? void 0 : scroller.scrollToEnd({ | ||
| 656 | + animated: maintainScrollAtEnd.animated | ||
| 657 | + }); | ||
| 658 | + } | ||
| 659 | } | ||
| 660 | setTimeout( | ||
| 661 | () => { | ||
| 662 | @@ -1867,7 +1893,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { | ||
| 663 | if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { | ||
| 664 | return 0; | ||
| 665 | } | ||
| 666 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | ||
| 667 | + const insetStartAdjustment = state.props.contentInsetStartAdjustment; | ||
| 668 | + const minScroll = typeof insetStartAdjustment === "number" && Number.isFinite(insetStartAdjustment) ? -Math.max(0, insetStartAdjustment) : 0; | ||
| 669 | + const maxScroll = Math.max(minScroll, totalSize - state.scrollLength); | ||
| 670 | const clampDelta = maxScroll - state.scroll; | ||
| 671 | if (unresolvedAmount < 0) { | ||
| 672 | return Math.max(unresolvedAmount, Math.min(0, clampDelta)); | ||
| 673 | @@ -1929,7 +1957,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { | ||
| 674 | settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); | ||
| 675 | return true; | ||
| 676 | } | ||
| 677 | - const expectedNativeClampScroll = Math.max(0, getContentSize(ctx) - state.scrollLength); | ||
| 678 | + const expectedNativeClampScroll = Math.max(-getContentInsetStartAdjustment(ctx), getContentSize(ctx) - state.scrollLength); | ||
| 679 | const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); | ||
| 680 | const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; | ||
| 681 | if (isAtExpectedNativeClamp) { | ||
| 682 | @@ -2062,7 +2090,7 @@ function prepareMVCP(ctx, dataChanged) { | ||
| 683 | if (diff > 0) { | ||
| 684 | diff = Math.max(0, totalSize - state.scroll - state.scrollLength); | ||
| 685 | } else { | ||
| 686 | - const maxScroll = Math.max(0, totalSize - state.scrollLength); | ||
| 687 | + const maxScroll = Math.max(-getContentInsetStartAdjustment(ctx), totalSize - state.scrollLength); | ||
| 688 | state.scroll = maxScroll; | ||
| 689 | state.scrollPending = maxScroll; | ||
| 690 | diff = 0; | ||
| 691 | @@ -2353,8 +2381,121 @@ function scrollToIndex(ctx, { | ||
| 692 | } | ||
| 693 | |||
| 694 | // src/core/initialScroll.ts | ||
| 695 | +var INSET_END_SETTLE_WATCHDOG_FRAMES = 150; | ||
| 696 | +var INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES = 20; | ||
| 697 | +var INSET_END_REVEAL_STABLE_FRAMES = 3; | ||
| 698 | +var INSET_END_REVEAL_MAX_HOLD_FRAMES = 40; | ||
| 699 | +function startInsetEndSettleWatchdog(ctx) { | ||
| 700 | + const state = ctx.state; | ||
| 701 | + if (state.insetEndSettleWatchdogActive) { | ||
| 702 | + return; | ||
| 703 | + } | ||
| 704 | + state.insetEndSettleWatchdogActive = true; | ||
| 705 | + state.didUserDrag = false; | ||
| 706 | + // Hold the readyToRender opacity gate until the end landing is stable, so | ||
| 707 | + // the estimated-to-measured settle chase happens before first VISIBLE | ||
| 708 | + // paint instead of in front of the user. Capped so slow measurement can | ||
| 709 | + // never hide content for long. | ||
| 710 | + state.insetEndRevealHold = true; | ||
| 711 | + let frames = 0; | ||
| 712 | + let settledFrames = 0; | ||
| 713 | + let revealStableFrames = 0; | ||
| 714 | + const releaseRevealHold = () => { | ||
| 715 | + if (state.insetEndRevealHold) { | ||
| 716 | + state.insetEndRevealHold = false; | ||
| 717 | + setInitialRenderState(ctx, {}); | ||
| 718 | + } | ||
| 719 | + }; | ||
| 720 | + // Revealing flips adaptive rendering to "normal", which re-renders rows and | ||
| 721 | + // can move the end target one more time — do that upgrade while still | ||
| 722 | + // hidden (phase 1), then require stability again before revealing (phase 2) | ||
| 723 | + // so the first visible frame is the settled one. | ||
| 724 | + let revealPhase = 1; | ||
| 725 | + const onRevealStability = () => { | ||
| 726 | + var _a3; | ||
| 727 | + if (!state.insetEndRevealHold) { | ||
| 728 | + return; | ||
| 729 | + } | ||
| 730 | + if (revealPhase === 1) { | ||
| 731 | + revealPhase = 2; | ||
| 732 | + revealStableFrames = 0; | ||
| 733 | + setAdaptiveRender(ctx, "normal"); | ||
| 734 | + (_a3 = state.triggerCalculateItemsInView) == null ? void 0 : _a3.call(state); | ||
| 735 | + return; | ||
| 736 | + } | ||
| 737 | + releaseRevealHold(); | ||
| 738 | + }; | ||
| 739 | + const tick = () => { | ||
| 740 | + // The user owns the scroll from their first drag — a re-pin here would | ||
| 741 | + // pin the list under their finger. | ||
| 742 | + if (frames++ >= INSET_END_SETTLE_WATCHDOG_FRAMES || settledFrames >= INSET_END_SETTLE_WATCHDOG_STABLE_FRAMES || state.didUserDrag || !ctx.state || ctx.state !== state) { | ||
| 743 | + state.insetEndSettleWatchdogActive = false; | ||
| 744 | + releaseRevealHold(); | ||
| 745 | + return; | ||
| 746 | + } | ||
| 747 | + if (frames >= INSET_END_REVEAL_MAX_HOLD_FRAMES) { | ||
| 748 | + releaseRevealHold(); | ||
| 749 | + } | ||
| 750 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 751 | + const contentSize = getContentSize(ctx); | ||
| 752 | + const scrollLength = state.scrollLength; | ||
| 753 | + if (insetStartAdjustment > 0 && scrollLength > 0 && Number.isFinite(contentSize) && contentSize > scrollLength && !state.scrollingTo && !state.maintainingScrollAtEnd) { | ||
| 754 | + const endOffset = Math.max(-insetStartAdjustment, contentSize - scrollLength); | ||
| 755 | + const distance = endOffset - state.scroll; | ||
| 756 | + // Estimated row sizes converging to measured ones can strand the initial | ||
| 757 | + // end landing when the library's own end-anchor bookkeeping gives up. | ||
| 758 | + // While still near the end (never fighting a user who scrolled away), | ||
| 759 | + // re-pin to the current true end until sizes stop changing. | ||
| 760 | + if (Math.abs(distance) > 2 && Math.abs(distance) <= scrollLength * 0.5) { | ||
| 761 | + settledFrames = 0; | ||
| 762 | + revealStableFrames = 0; | ||
| 763 | + const scroller = state.refScroller.current; | ||
| 764 | + if (scroller) { | ||
| 765 | + scroller.scrollTo({ animated: false, x: 0, y: endOffset }); | ||
| 766 | + } | ||
| 767 | + } else { | ||
| 768 | + settledFrames++; | ||
| 769 | + revealStableFrames++; | ||
| 770 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | ||
| 771 | + onRevealStability(); | ||
| 772 | + } | ||
| 773 | + } | ||
| 774 | + } else { | ||
| 775 | + // Conditions that make re-pinning unnecessary (underflow, in-flight | ||
| 776 | + // programmatic scroll) count toward stability for the reveal. | ||
| 777 | + revealStableFrames++; | ||
| 778 | + if (revealStableFrames >= INSET_END_REVEAL_STABLE_FRAMES) { | ||
| 779 | + onRevealStability(); | ||
| 780 | + } | ||
| 781 | + } | ||
| 782 | + requestAnimationFrame(tick); | ||
| 783 | + }; | ||
| 784 | + requestAnimationFrame(tick); | ||
| 785 | +} | ||
| 786 | function dispatchInitialScroll(ctx, params) { | ||
| 787 | const { forceScroll, resolvedOffset, target, waitForCompletionFrame } = params; | ||
| 788 | + const insetStartAdjustment = getContentInsetStartAdjustment(ctx); | ||
| 789 | + if (insetStartAdjustment > 0 && ctx.state.props.data.length > 0) { | ||
| 790 | + if (ctx.state.scrollLength <= 0) { | ||
| 791 | + // The scroll length is unknown, so the resolved target is meaningless; | ||
| 792 | + // the initial-scroll machinery re-advances after layout. | ||
| 793 | + return; | ||
| 794 | + } | ||
| 795 | + if (resolvedOffset <= -insetStartAdjustment + 1 && !ctx.state.didDispatchInsetInitialScroll) { | ||
| 796 | + // Content underflows the viewport and nothing has scrolled yet: the | ||
| 797 | + // untouched native rest position (UIKit's adjustedContentInset) IS the | ||
| 798 | + // end position. Dispatching a negative scroll here would race UIKit's | ||
| 799 | + // inset application during screen attach, so just finish the session. | ||
| 800 | + finishInitialScroll(ctx, { | ||
| 801 | + resolvedOffset | ||
| 802 | + }); | ||
| 803 | + return; | ||
| 804 | + } | ||
| 805 | + ctx.state.didDispatchInsetInitialScroll = true; | ||
| 806 | + if (target.viewPosition === 1) { | ||
| 807 | + startInsetEndSettleWatchdog(ctx); | ||
| 808 | + } | ||
| 809 | + } | ||
| 810 | const requestedIndex = target.index; | ||
| 811 | const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; | ||
| 812 | const itemSize = getItemSizeAtIndex(ctx, index); | ||
| 813 | @@ -2783,7 +2924,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { | ||
| 814 | return; | ||
| 815 | } | ||
| 816 | if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { | ||
| 817 | - const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && peek$(ctx, "isAtEnd"); | ||
| 818 | + const endTargetDistanceFromEnd = getContentSize(ctx) - state.scroll - state.scrollLength - getContentInsetEnd(ctx); | ||
| 819 | + const isNearEndForInsetList = getContentInsetStartAdjustment(ctx) > 0 && Number.isFinite(endTargetDistanceFromEnd) && endTargetDistanceFromEnd <= state.scrollLength * 0.5; | ||
| 820 | + const shouldKeepEndTargetAlive = isRetargetableBottomAlignedInitialScrollTarget(initialScroll) && (peek$(ctx, "isAtEnd") || isNearEndForInsetList); | ||
| 821 | if (!shouldKeepEndTargetAlive) { | ||
| 822 | if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { | ||
| 823 | clearPendingInitialScrollFooterLayout(ctx, { | ||
| 824 | @@ -4625,7 +4768,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { | ||
| 825 | } | ||
| 826 | contentBelowAnchor += footerSize + stylePaddingBottom; | ||
| 827 | isReady = !hasUnknownTailSize; | ||
| 828 | - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | ||
| 829 | + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); | ||
| 830 | + nextSize = hasUnknownTailSize ? Math.min(previousSize || 0, knownSizeBound) : knownSizeBound; | ||
| 831 | } else if (anchorIndex >= 0) { | ||
| 832 | isReady = false; | ||
| 833 | } | ||
| 834 | @@ -4643,6 +4787,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { | ||
| 835 | updateScroll(ctx, state.scroll, true); | ||
| 836 | } | ||
| 837 | (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); | ||
| 838 | + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { | ||
| 839 | + set$(ctx, "anchoredEndSpaceSize", nextSize); | ||
| 840 | + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); | ||
| 841 | + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { | ||
| 842 | + updateScroll(ctx, state.scroll, true); | ||
| 843 | + } | ||
| 844 | } | ||
| 845 | return nextSize; | ||
| 846 | } | ||
| 847 | @@ -6441,6 +6591,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 848 | dataVersion, | ||
| 849 | drawDistance = 250, | ||
| 850 | contentInsetEndAdjustment, | ||
| 851 | + contentInsetStartAdjustment, | ||
| 852 | estimatedItemSize = 100, | ||
| 853 | estimatedListSize, | ||
| 854 | extraData, | ||
| 855 | @@ -6471,6 +6622,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 856 | onLayout: onLayoutProp, | ||
| 857 | onLoad, | ||
| 858 | onMomentumScrollEnd, | ||
| 859 | + onScrollBeginDrag, | ||
| 860 | onRefresh, | ||
| 861 | onScroll: onScrollProp, | ||
| 862 | onStartReached, | ||
| 863 | @@ -6689,6 +6841,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 864 | contentContainerAlignItems: contentContainerStyle.alignItems, | ||
| 865 | contentInset, | ||
| 866 | contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, | ||
| 867 | + contentInsetStartAdjustment, | ||
| 868 | data: dataProp, | ||
| 869 | dataVersion, | ||
| 870 | drawDistance, | ||
| 871 | @@ -6768,6 +6921,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 872 | return void 0; | ||
| 873 | } | ||
| 874 | const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); | ||
| 875 | + if (getContentInsetStartAdjustment(ctx) > 0) { | ||
| 876 | + // With a native leading inset (transparent header + automatic insets), | ||
| 877 | + // any mount-time contentOffset races UIKit's adjustedContentInset | ||
| 878 | + // application. The gated initial-scroll dispatch positions the list once | ||
| 879 | + // real sizes are known instead. | ||
| 880 | + return void 0; | ||
| 881 | + } | ||
| 882 | return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; | ||
| 883 | }, [usesBootstrapInitialScroll]); | ||
| 884 | useLayoutEffect(() => { | ||
| 885 | @@ -6974,6 +7134,12 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 886 | onMomentumScrollEnd(event); | ||
| 887 | } | ||
| 888 | }, | ||
| 889 | + onScrollBeginDrag: (event) => { | ||
| 890 | + ctx.state.didUserDrag = true; | ||
| 891 | + if (onScrollBeginDrag) { | ||
| 892 | + onScrollBeginDrag(event); | ||
| 893 | + } | ||
| 894 | + }, | ||
| 895 | onScroll: (event) => onScroll(ctx, event) | ||
| 896 | }), | ||
| 897 | [] | ||
| 898 | @@ -6998,6 +7164,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded | ||
| 899 | onLayout, | ||
| 900 | onLayoutFooter, | ||
| 901 | onMomentumScrollEnd: fns.onMomentumScrollEnd, | ||
| 902 | + onScrollBeginDrag: fns.onScrollBeginDrag, | ||
| 903 | onScroll: onScrollHandler, | ||
| 904 | recycleItems, | ||
| 905 | refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { | ||
| 906 | diff --git a/reanimated.d.ts b/reanimated.d.ts | ||
| 907 | index 7e2d11f..d5b0d66 100644 | ||
| 908 | --- a/reanimated.d.ts | ||
| 909 | +++ b/reanimated.d.ts | ||
| 910 | @@ -285,6 +285,12 @@ interface LegendListSpecificProps<ItemT, TItemType extends string | undefined> { | ||
| 911 | * The adjustment is also rendered as real content padding so the browser scroll range includes it. | ||
| 912 | */ | ||
| 913 | contentInsetEndAdjustment?: number; | ||
| 914 | + /** | ||
| 915 | + * Width/height of a leading content inset applied natively outside the list's knowledge | ||
| 916 | + * (e.g. iOS contentInsetAdjustmentBehavior="automatic" under a transparent header). | ||
| 917 | + * Programmatic scrolls clamp to -adjustment instead of 0 so content can rest below the header. | ||
| 918 | + */ | ||
| 919 | + contentInsetStartAdjustment?: number; | ||
| 920 | /** | ||
| 921 | * Number of columns to render items in. | ||
| 922 | * @default 1 | ||
patches/react-native-keyboard-controller@1.21.13.patch created+483| ... | @@ -0,0 +1,483 @@ | ||
| 1 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/index.js b/lib/commonjs/components/KeyboardChatScrollView/index.js | ||
| 2 | index db8cfb1d289f91563f13c4dd842c783c99facc32..940e1dd80c6c9dfc42eab916445be414372ce52e 100644 | ||
| 3 | --- a/lib/commonjs/components/KeyboardChatScrollView/index.js | ||
| 4 | +++ b/lib/commonjs/components/KeyboardChatScrollView/index.js | ||
| 5 | @@ -26,9 +26,11 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ | ||
| 6 | offset = 0, | ||
| 7 | extraContentPadding = ZERO_CONTENT_PADDING, | ||
| 8 | blankSpace = ZERO_BLANK_SPACE, | ||
| 9 | + adjustedInsetCompensation = 0, | ||
| 10 | applyWorkaroundForContentInsetHitTestBug = false, | ||
| 11 | onLayout: onLayoutProp, | ||
| 12 | onContentSizeChange: onContentSizeChangeProp, | ||
| 13 | + onContentInsetChange, | ||
| 14 | onEndVisible, | ||
| 15 | ...rest | ||
| 16 | }, ref) => { | ||
| 17 | @@ -50,13 +52,15 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ | ||
| 18 | freeze: freezeSV, | ||
| 19 | offset, | ||
| 20 | blankSpace, | ||
| 21 | - extraContentPadding | ||
| 22 | + extraContentPadding, | ||
| 23 | + adjustedInsetCompensation | ||
| 24 | }); | ||
| 25 | (0, _useExtraContentPadding.useExtraContentPadding)({ | ||
| 26 | scrollViewRef, | ||
| 27 | extraContentPadding, | ||
| 28 | keyboardPadding: padding, | ||
| 29 | blankSpace, | ||
| 30 | + adjustedInsetCompensation, | ||
| 31 | scroll, | ||
| 32 | layout, | ||
| 33 | size, | ||
| 34 | @@ -82,10 +86,21 @@ const KeyboardChatScrollView = /*#__PURE__*/(0, _react.forwardRef)(({ | ||
| 35 | // a bug for you, please open an issue. | ||
| 36 | const totalPadding = (0, _reactNativeReanimated.useDerivedValue)(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); | ||
| 37 | |||
| 38 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) | ||
| 39 | + // to the consumer - a virtualized list needs it in its own scroll math or | ||
| 40 | + // its end/maintain targets point at the under-the-keyboard resting offset. | ||
| 41 | + (0, _reactNativeReanimated.useAnimatedReaction)(() => totalPadding.value, (current, previous) => { | ||
| 42 | + if (onContentInsetChange && current !== previous) { | ||
| 43 | + (0, _reactNativeReanimated.runOnJS)(onContentInsetChange)({ | ||
| 44 | + bottom: current | ||
| 45 | + }); | ||
| 46 | + } | ||
| 47 | + }, [onContentInsetChange]); | ||
| 48 | + | ||
| 49 | // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). | ||
| 50 | // Apps that render into the unsafe area can supply a negative | ||
| 51 | // scrollIndicatorInsets adjustment at the application layer. | ||
| 52 | - const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value + extraContentPadding.value); | ||
| 53 | + const indicatorPadding = (0, _reactNativeReanimated.useDerivedValue)(() => padding.value); | ||
| 54 | const onLayout = (0, _react.useCallback)(e => { | ||
| 55 | onLayoutInternal(e); | ||
| 56 | onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); | ||
| 57 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 58 | index 2073da84b8b2be291aa3181700c2b18a75d0fc56..f43f2efdc4f0eda0460720839544dc6e7f4a54e0 100644 | ||
| 59 | --- a/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 60 | +++ b/lib/commonjs/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 61 | @@ -32,7 +32,8 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 62 | freeze, | ||
| 63 | offset, | ||
| 64 | blankSpace, | ||
| 65 | - extraContentPadding | ||
| 66 | + extraContentPadding, | ||
| 67 | + adjustedInsetCompensation | ||
| 68 | } = options; | ||
| 69 | const padding = (0, _reactNativeReanimated.useSharedValue)(0); | ||
| 70 | const currentHeight = (0, _reactNativeReanimated.useSharedValue)(0); | ||
| 71 | @@ -66,7 +67,7 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 72 | const visiblePadding = visibleFraction * blankSpace.value; | ||
| 73 | const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); | ||
| 74 | const scrollEffective = (0, _helpers.getScrollEffective)(effective, minimumPaddingAbsorbed); | ||
| 75 | - const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); | ||
| 76 | + const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; | ||
| 77 | |||
| 78 | // persistent mode: when keyboard shrinks, clamp to valid range | ||
| 79 | if (keyboardLiftBehavior === "persistent" && effective < padding.value) { | ||
| 80 | @@ -134,7 +135,7 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 81 | const effective = (0, _helpers.getEffectiveHeight)(e.height, targetKeyboardHeight.value, offset); | ||
| 82 | padding.value = effective; | ||
| 83 | } | ||
| 84 | - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); | ||
| 85 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); | ||
| 86 | return { | ||
| 87 | padding, | ||
| 88 | currentHeight, | ||
| 89 | diff --git a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 90 | index 0d50bdfeb7bbc5c14a31ed344f8a690e4fd340b3..22ca193257ab0068f732c088b09145dabf39a05e 100644 | ||
| 91 | --- a/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 92 | +++ b/lib/commonjs/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 93 | @@ -29,6 +29,7 @@ function useExtraContentPadding(options) { | ||
| 94 | extraContentPadding, | ||
| 95 | keyboardPadding, | ||
| 96 | blankSpace, | ||
| 97 | + adjustedInsetCompensation, | ||
| 98 | scroll, | ||
| 99 | layout, | ||
| 100 | size, | ||
| 101 | @@ -68,8 +69,8 @@ function useExtraContentPadding(options) { | ||
| 102 | } | ||
| 103 | |||
| 104 | // Compute effective delta considering blankSpace floor | ||
| 105 | - const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); | ||
| 106 | - const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); | ||
| 107 | + const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; | ||
| 108 | + const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; | ||
| 109 | const effectiveDelta = currentTotal - previousTotal; | ||
| 110 | if (effectiveDelta === 0) { | ||
| 111 | // blankSpace absorbed the change | ||
| 112 | @@ -92,6 +93,6 @@ function useExtraContentPadding(options) { | ||
| 113 | const target = Math.min(scroll.value + effectiveDelta, maxScroll); | ||
| 114 | scrollToTarget(target); | ||
| 115 | } | ||
| 116 | - }, [inverted, keyboardLiftBehavior]); | ||
| 117 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); | ||
| 118 | } | ||
| 119 | //# sourceMappingURL=index.js.map | ||
| 120 | \ No newline at end of file | ||
| 121 | diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js | ||
| 122 | index 612dd8bd9bd6cc3e30a5acac937ea3383eb1b630..ac79433fdf0b36f89525b9840447a116da63d58c 100644 | ||
| 123 | --- a/lib/module/components/KeyboardChatScrollView/index.js | ||
| 124 | +++ b/lib/module/components/KeyboardChatScrollView/index.js | ||
| 125 | @@ -1,7 +1,7 @@ | ||
| 126 | function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); } | ||
| 127 | import React, { forwardRef, useCallback, useMemo } from "react"; | ||
| 128 | import { StyleSheet } from "react-native"; | ||
| 129 | -import { makeMutable, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; | ||
| 130 | +import { makeMutable, runOnJS, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useDerivedValue } from "react-native-reanimated"; | ||
| 131 | import Reanimated from "react-native-reanimated"; | ||
| 132 | import useCombinedRef from "../hooks/useCombinedRef"; | ||
| 133 | import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; | ||
| 134 | @@ -19,9 +19,11 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | ||
| 135 | offset = 0, | ||
| 136 | extraContentPadding = ZERO_CONTENT_PADDING, | ||
| 137 | blankSpace = ZERO_BLANK_SPACE, | ||
| 138 | + adjustedInsetCompensation = 0, | ||
| 139 | applyWorkaroundForContentInsetHitTestBug = false, | ||
| 140 | onLayout: onLayoutProp, | ||
| 141 | onContentSizeChange: onContentSizeChangeProp, | ||
| 142 | + onContentInsetChange, | ||
| 143 | onEndVisible, | ||
| 144 | ...rest | ||
| 145 | }, ref) => { | ||
| 146 | @@ -43,13 +45,15 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | ||
| 147 | freeze: freezeSV, | ||
| 148 | offset, | ||
| 149 | blankSpace, | ||
| 150 | - extraContentPadding | ||
| 151 | + extraContentPadding, | ||
| 152 | + adjustedInsetCompensation | ||
| 153 | }); | ||
| 154 | useExtraContentPadding({ | ||
| 155 | scrollViewRef, | ||
| 156 | extraContentPadding, | ||
| 157 | keyboardPadding: padding, | ||
| 158 | blankSpace, | ||
| 159 | + adjustedInsetCompensation, | ||
| 160 | scroll, | ||
| 161 | layout, | ||
| 162 | size, | ||
| 163 | @@ -75,10 +79,21 @@ const KeyboardChatScrollView = /*#__PURE__*/forwardRef(({ | ||
| 164 | // a bug for you, please open an issue. | ||
| 165 | const totalPadding = useDerivedValue(() => Math.min(layout.value.height, Math.max(blankSpace.value, padding.value + extraContentPadding.value))); | ||
| 166 | |||
| 167 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) | ||
| 168 | + // to the consumer - a virtualized list needs it in its own scroll math or | ||
| 169 | + // its end/maintain targets point at the under-the-keyboard resting offset. | ||
| 170 | + useAnimatedReaction(() => totalPadding.value, (current, previous) => { | ||
| 171 | + if (onContentInsetChange && current !== previous) { | ||
| 172 | + runOnJS(onContentInsetChange)({ | ||
| 173 | + bottom: current | ||
| 174 | + }); | ||
| 175 | + } | ||
| 176 | + }, [onContentInsetChange]); | ||
| 177 | + | ||
| 178 | // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). | ||
| 179 | // Apps that render into the unsafe area can supply a negative | ||
| 180 | // scrollIndicatorInsets adjustment at the application layer. | ||
| 181 | - const indicatorPadding = useDerivedValue(() => padding.value + extraContentPadding.value); | ||
| 182 | + const indicatorPadding = useDerivedValue(() => padding.value); | ||
| 183 | const onLayout = useCallback(e => { | ||
| 184 | onLayoutInternal(e); | ||
| 185 | onLayoutProp === null || onLayoutProp === void 0 || onLayoutProp(e); | ||
| 186 | diff --git a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 187 | index 52943c3a7d6a68fe2094dc1d112c07e6b9d890e4..c8685c18a53ad078c24fc3e3b6572669b2b63397 100644 | ||
| 188 | --- a/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 189 | +++ b/lib/module/components/KeyboardChatScrollView/useChatKeyboard/index.ios.js | ||
| 190 | @@ -25,7 +25,8 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 191 | freeze, | ||
| 192 | offset, | ||
| 193 | blankSpace, | ||
| 194 | - extraContentPadding | ||
| 195 | + extraContentPadding, | ||
| 196 | + adjustedInsetCompensation | ||
| 197 | } = options; | ||
| 198 | const padding = useSharedValue(0); | ||
| 199 | const currentHeight = useSharedValue(0); | ||
| 200 | @@ -59,7 +60,7 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 201 | const visiblePadding = visibleFraction * blankSpace.value; | ||
| 202 | const minimumPaddingAbsorbed = Math.max(0, visiblePadding - extraContentPadding.value); | ||
| 203 | const scrollEffective = getScrollEffective(effective, minimumPaddingAbsorbed); | ||
| 204 | - const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value); | ||
| 205 | + const actualTotalPadding = Math.max(blankSpace.value, effective + extraContentPadding.value) + adjustedInsetCompensation; | ||
| 206 | |||
| 207 | // persistent mode: when keyboard shrinks, clamp to valid range | ||
| 208 | if (keyboardLiftBehavior === "persistent" && effective < padding.value) { | ||
| 209 | @@ -127,7 +128,7 @@ function useChatKeyboard(scrollViewRef, options) { | ||
| 210 | const effective = getEffectiveHeight(e.height, targetKeyboardHeight.value, offset); | ||
| 211 | padding.value = effective; | ||
| 212 | } | ||
| 213 | - }, [inverted, keyboardLiftBehavior, offset, extraContentPadding]); | ||
| 214 | + }, [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation]); | ||
| 215 | return { | ||
| 216 | padding, | ||
| 217 | currentHeight, | ||
| 218 | diff --git a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 219 | index 1afa50987a8d2a5fe3f36b20945efe804d48a873..e2966d89d4329a7dc1233ff060cab6f365f745d6 100644 | ||
| 220 | --- a/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 221 | +++ b/lib/module/components/KeyboardChatScrollView/useExtraContentPadding/index.js | ||
| 222 | @@ -23,6 +23,7 @@ function useExtraContentPadding(options) { | ||
| 223 | extraContentPadding, | ||
| 224 | keyboardPadding, | ||
| 225 | blankSpace, | ||
| 226 | + adjustedInsetCompensation, | ||
| 227 | scroll, | ||
| 228 | layout, | ||
| 229 | size, | ||
| 230 | @@ -62,8 +63,8 @@ function useExtraContentPadding(options) { | ||
| 231 | } | ||
| 232 | |||
| 233 | // Compute effective delta considering blankSpace floor | ||
| 234 | - const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous); | ||
| 235 | - const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current); | ||
| 236 | + const previousTotal = Math.max(blankSpace.value, keyboardPadding.value + previous) + adjustedInsetCompensation; | ||
| 237 | + const currentTotal = Math.max(blankSpace.value, keyboardPadding.value + current) + adjustedInsetCompensation; | ||
| 238 | const effectiveDelta = currentTotal - previousTotal; | ||
| 239 | if (effectiveDelta === 0) { | ||
| 240 | // blankSpace absorbed the change | ||
| 241 | @@ -86,7 +87,7 @@ function useExtraContentPadding(options) { | ||
| 242 | const target = Math.min(scroll.value + effectiveDelta, maxScroll); | ||
| 243 | scrollToTarget(target); | ||
| 244 | } | ||
| 245 | - }, [inverted, keyboardLiftBehavior]); | ||
| 246 | + }, [inverted, keyboardLiftBehavior, adjustedInsetCompensation]); | ||
| 247 | } | ||
| 248 | export { useExtraContentPadding }; | ||
| 249 | //# sourceMappingURL=index.js.map | ||
| 250 | \ No newline at end of file | ||
| 251 | diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts | ||
| 252 | index a036b431f03efb9d5379527c17db6fce62bfee09..6ca6fdf60fc9fae1e28a86cf24e21beed99b3a76 100644 | ||
| 253 | --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts | ||
| 254 | +++ b/lib/typescript/components/KeyboardChatScrollView/types.d.ts | ||
| 255 | @@ -86,6 +86,8 @@ export type KeyboardChatScrollViewProps = { | ||
| 256 | * Default is `undefined` (equivalent to `0` — no minimum floor). | ||
| 257 | */ | ||
| 258 | blankSpace?: SharedValue<number>; | ||
| 259 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ | ||
| 260 | + adjustedInsetCompensation?: number; | ||
| 261 | /** | ||
| 262 | * Fires whenever the effective content inset changes — the static `contentInset` | ||
| 263 | * prop combined with the dynamic keyboard-driven padding. | ||
| 264 | diff --git a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts | ||
| 265 | index aff9b5a8dbc2464546396437eaf6c5ae955b9f29..67edbe1a1eac27b5a571611979753ebf3134bc8b 100644 | ||
| 266 | --- a/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts | ||
| 267 | +++ b/lib/typescript/components/KeyboardChatScrollView/useChatKeyboard/types.d.ts | ||
| 268 | @@ -9,6 +9,8 @@ type UseChatKeyboardOptions = { | ||
| 269 | blankSpace: SharedValue<number>; | ||
| 270 | /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ | ||
| 271 | extraContentPadding: SharedValue<number>; | ||
| 272 | + /** Safe-area extra beyond raw contentInset. Offset math only. */ | ||
| 273 | + adjustedInsetCompensation: number; | ||
| 274 | }; | ||
| 275 | type UseChatKeyboardReturn = { | ||
| 276 | /** Extra scrollable space (= keyboard height). Used as contentInset on iOS, contentInsetBottom/contentInsetTop on Android. */ | ||
| 277 | diff --git a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts | ||
| 278 | index ec73f70544a062fbecfc1d8be92d839d3e0bea6f..6fe16cd0b1200a2f4d4d8eeb9b555747186dbfe0 100644 | ||
| 279 | --- a/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts | ||
| 280 | +++ b/lib/typescript/components/KeyboardChatScrollView/useExtraContentPadding/index.d.ts | ||
| 281 | @@ -8,6 +8,8 @@ type UseExtraContentPaddingOptions = { | ||
| 282 | keyboardPadding: SharedValue<number>; | ||
| 283 | /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ | ||
| 284 | blankSpace: SharedValue<number>; | ||
| 285 | + /** Safe-area extra beyond raw contentInset. Offset math only. */ | ||
| 286 | + adjustedInsetCompensation: number; | ||
| 287 | /** Current vertical scroll offset. */ | ||
| 288 | scroll: SharedValue<number>; | ||
| 289 | /** Visible viewport dimensions. */ | ||
| 290 | diff --git a/src/components/KeyboardChatScrollView/index.tsx b/src/components/KeyboardChatScrollView/index.tsx | ||
| 291 | index 03f5f74e9aaaabc75db1c01643a655ee4fdfa5f2..d657002ebbce53c47e3a713c0921c35dca6056f3 100644 | ||
| 292 | --- a/src/components/KeyboardChatScrollView/index.tsx | ||
| 293 | +++ b/src/components/KeyboardChatScrollView/index.tsx | ||
| 294 | @@ -2,6 +2,8 @@ import React, { forwardRef, useCallback, useMemo } from "react"; | ||
| 295 | import { StyleSheet } from "react-native"; | ||
| 296 | import { | ||
| 297 | makeMutable, | ||
| 298 | + runOnJS, | ||
| 299 | + useAnimatedReaction, | ||
| 300 | useAnimatedRef, | ||
| 301 | useAnimatedStyle, | ||
| 302 | useDerivedValue, | ||
| 303 | @@ -35,9 +37,11 @@ const KeyboardChatScrollView = forwardRef< | ||
| 304 | offset = 0, | ||
| 305 | extraContentPadding = ZERO_CONTENT_PADDING, | ||
| 306 | blankSpace = ZERO_BLANK_SPACE, | ||
| 307 | + adjustedInsetCompensation = 0, | ||
| 308 | applyWorkaroundForContentInsetHitTestBug = false, | ||
| 309 | onLayout: onLayoutProp, | ||
| 310 | onContentSizeChange: onContentSizeChangeProp, | ||
| 311 | + onContentInsetChange, | ||
| 312 | onEndVisible, | ||
| 313 | ...rest | ||
| 314 | }, | ||
| 315 | @@ -64,6 +68,7 @@ const KeyboardChatScrollView = forwardRef< | ||
| 316 | offset, | ||
| 317 | blankSpace, | ||
| 318 | extraContentPadding, | ||
| 319 | + adjustedInsetCompensation, | ||
| 320 | }); | ||
| 321 | |||
| 322 | useExtraContentPadding({ | ||
| 323 | @@ -71,6 +76,7 @@ const KeyboardChatScrollView = forwardRef< | ||
| 324 | extraContentPadding, | ||
| 325 | keyboardPadding: padding, | ||
| 326 | blankSpace, | ||
| 327 | + adjustedInsetCompensation, | ||
| 328 | scroll, | ||
| 329 | layout, | ||
| 330 | size, | ||
| 331 | @@ -102,13 +108,25 @@ const KeyboardChatScrollView = forwardRef< | ||
| 332 | ), | ||
| 333 | ); | ||
| 334 | |||
| 335 | - // Scroll indicator inset = keyboard + extraContentPadding (excludes blankSpace). | ||
| 336 | - // Apps that render into the unsafe area can supply a negative | ||
| 337 | - // scrollIndicatorInsets adjustment at the application layer. | ||
| 338 | - const indicatorPadding = useDerivedValue( | ||
| 339 | - () => padding.value + extraContentPadding.value, | ||
| 340 | + // Mirror the effective bottom padding (keyboard + composer + blank floor) | ||
| 341 | + // to the consumer — a virtualized list needs it in its own scroll math or | ||
| 342 | + // its end/maintain targets point at the under-the-keyboard resting offset. | ||
| 343 | + useAnimatedReaction( | ||
| 344 | + () => totalPadding.value, | ||
| 345 | + (current, previous) => { | ||
| 346 | + if (onContentInsetChange && current !== previous) { | ||
| 347 | + runOnJS(onContentInsetChange)({ bottom: current }); | ||
| 348 | + } | ||
| 349 | + }, | ||
| 350 | + [onContentInsetChange], | ||
| 351 | ); | ||
| 352 | |||
| 353 | + // Scroll indicator inset = keyboard only (excludes extraContentPadding and | ||
| 354 | + // blankSpace): with a floating composer the indicator track should run the | ||
| 355 | + // full height of the scroll view, behind the composer, like iOS Messages. | ||
| 356 | + // The keyboard still lifts it so it never disappears under the keyboard. | ||
| 357 | + const indicatorPadding = useDerivedValue(() => padding.value); | ||
| 358 | + | ||
| 359 | const onLayout = useCallback( | ||
| 360 | (e: LayoutChangeEvent) => { | ||
| 361 | onLayoutInternal(e); | ||
| 362 | diff --git a/src/components/KeyboardChatScrollView/types.ts b/src/components/KeyboardChatScrollView/types.ts | ||
| 363 | index dd222b57bc7a71729524670bad3812f30920bd73..40249a4357991b7a9c69b87214db2ceb6ff4ba7d 100644 | ||
| 364 | --- a/src/components/KeyboardChatScrollView/types.ts | ||
| 365 | +++ b/src/components/KeyboardChatScrollView/types.ts | ||
| 366 | @@ -90,6 +90,15 @@ export type KeyboardChatScrollViewProps = { | ||
| 367 | * Default is `undefined` (equivalent to `0` — no minimum floor). | ||
| 368 | */ | ||
| 369 | blankSpace?: SharedValue<number>; | ||
| 370 | + /** | ||
| 371 | + * Extra bottom inset UIKit adds on top of the raw `contentInset` this | ||
| 372 | + * component writes (e.g. the home-indicator safe area under | ||
| 373 | + * `contentInsetAdjustmentBehavior="automatic"`). Used ONLY in scroll-offset | ||
| 374 | + * math (max scroll / end pinning) — never written into `contentInset`. | ||
| 375 | + * | ||
| 376 | + * Default is `0`. | ||
| 377 | + */ | ||
| 378 | + adjustedInsetCompensation?: number; | ||
| 379 | /** | ||
| 380 | * Fires whenever the effective content inset changes — the static `contentInset` | ||
| 381 | * prop combined with the dynamic keyboard-driven padding. | ||
| 382 | diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts | ||
| 383 | index 560df54bae1a8c41a2e9ac0e2a8d2fd9b843968a..a0cb412692cf47fee372a01132e6a670b59bcd66 100644 | ||
| 384 | --- a/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts | ||
| 385 | +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/index.ios.ts | ||
| 386 | @@ -43,6 +43,7 @@ function useChatKeyboard( | ||
| 387 | offset, | ||
| 388 | blankSpace, | ||
| 389 | extraContentPadding, | ||
| 390 | + adjustedInsetCompensation, | ||
| 391 | } = options; | ||
| 392 | |||
| 393 | const padding = useSharedValue(0); | ||
| 394 | @@ -104,10 +105,12 @@ function useChatKeyboard( | ||
| 395 | effective, | ||
| 396 | minimumPaddingAbsorbed, | ||
| 397 | ); | ||
| 398 | - const actualTotalPadding = Math.max( | ||
| 399 | - blankSpace.value, | ||
| 400 | - effective + extraContentPadding.value, | ||
| 401 | - ); | ||
| 402 | + // UIKit adds adjustedInsetCompensation (safe area) on top of the raw | ||
| 403 | + // inset; include it here so end/max-scroll targets match the real | ||
| 404 | + // resting offsets. | ||
| 405 | + const actualTotalPadding = | ||
| 406 | + Math.max(blankSpace.value, effective + extraContentPadding.value) + | ||
| 407 | + adjustedInsetCompensation; | ||
| 408 | |||
| 409 | // persistent mode: when keyboard shrinks, clamp to valid range | ||
| 410 | if ( | ||
| 411 | @@ -242,7 +245,7 @@ function useChatKeyboard( | ||
| 412 | padding.value = effective; | ||
| 413 | }, | ||
| 414 | }, | ||
| 415 | - [inverted, keyboardLiftBehavior, offset, extraContentPadding], | ||
| 416 | + [inverted, keyboardLiftBehavior, offset, extraContentPadding, adjustedInsetCompensation], | ||
| 417 | ); | ||
| 418 | |||
| 419 | return { | ||
| 420 | diff --git a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts | ||
| 421 | index 02abf5cd9490900826678175462b234e07e30e92..3f261fa8f1913a79fa1de2004bbac20415a89acc 100644 | ||
| 422 | --- a/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts | ||
| 423 | +++ b/src/components/KeyboardChatScrollView/useChatKeyboard/types.ts | ||
| 424 | @@ -11,6 +11,8 @@ type UseChatKeyboardOptions = { | ||
| 425 | blankSpace: SharedValue<number>; | ||
| 426 | /** Extra content padding shared value — needed on iOS to correctly clamp contentOffset. */ | ||
| 427 | extraContentPadding: SharedValue<number>; | ||
| 428 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ | ||
| 429 | + adjustedInsetCompensation: number; | ||
| 430 | }; | ||
| 431 | |||
| 432 | type UseChatKeyboardReturn = { | ||
| 433 | diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts | ||
| 434 | index 833acbe78f1b1245251ddd3431d6546decdd0ade..49d679446b03199217faa16a503d8d15e83a29b9 100644 | ||
| 435 | --- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts | ||
| 436 | +++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts | ||
| 437 | @@ -16,6 +16,8 @@ type UseExtraContentPaddingOptions = { | ||
| 438 | keyboardPadding: SharedValue<number>; | ||
| 439 | /** Minimum inset floor — used to absorb keyboard and extraContentPadding changes. */ | ||
| 440 | blankSpace: SharedValue<number>; | ||
| 441 | + /** Extra bottom inset UIKit adds beyond the raw contentInset (safe area). Offset math only. */ | ||
| 442 | + adjustedInsetCompensation: number; | ||
| 443 | /** Current vertical scroll offset. */ | ||
| 444 | scroll: SharedValue<number>; | ||
| 445 | /** Visible viewport dimensions. */ | ||
| 446 | @@ -49,6 +51,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { | ||
| 447 | extraContentPadding, | ||
| 448 | keyboardPadding, | ||
| 449 | blankSpace, | ||
| 450 | + adjustedInsetCompensation, | ||
| 451 | scroll, | ||
| 452 | layout, | ||
| 453 | size, | ||
| 454 | @@ -97,14 +100,12 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { | ||
| 455 | } | ||
| 456 | |||
| 457 | // Compute effective delta considering blankSpace floor | ||
| 458 | - const previousTotal = Math.max( | ||
| 459 | - blankSpace.value, | ||
| 460 | - keyboardPadding.value + previous, | ||
| 461 | - ); | ||
| 462 | - const currentTotal = Math.max( | ||
| 463 | - blankSpace.value, | ||
| 464 | - keyboardPadding.value + current, | ||
| 465 | - ); | ||
| 466 | + const previousTotal = | ||
| 467 | + Math.max(blankSpace.value, keyboardPadding.value + previous) + | ||
| 468 | + adjustedInsetCompensation; | ||
| 469 | + const currentTotal = | ||
| 470 | + Math.max(blankSpace.value, keyboardPadding.value + current) + | ||
| 471 | + adjustedInsetCompensation; | ||
| 472 | const effectiveDelta = currentTotal - previousTotal; | ||
| 473 | |||
| 474 | if (effectiveDelta === 0) { | ||
| 475 | @@ -146,7 +147,7 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { | ||
| 476 | scrollToTarget(target); | ||
| 477 | } | ||
| 478 | }, | ||
| 479 | - [inverted, keyboardLiftBehavior], | ||
| 480 | + [inverted, keyboardLiftBehavior, adjustedInsetCompensation], | ||
| 481 | ); | ||
| 482 | } | ||
| 483 | |||
pnpm-lock.yaml+12-6| ... | @@ -76,6 +76,9 @@ patchedDependencies: | ... | @@ -76,6 +76,9 @@ patchedDependencies: |
| 76 | '@ff-labs/fff-node@0.9.4': | 76 | '@ff-labs/fff-node@0.9.4': |
| 77 | hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 | 77 | hash: 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 |
| 78 | path: patches/@ff-labs__fff-node@0.9.4.patch | 78 | path: patches/@ff-labs__fff-node@0.9.4.patch |
| 79 | '@legendapp/list@3.2.0': | ||
| 80 | hash: 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 | ||
| 81 | path: patches/@legendapp__list@3.2.0.patch | ||
| 79 | '@pierre/diffs@1.3.0-beta.5': | 82 | '@pierre/diffs@1.3.0-beta.5': |
| 80 | hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a | 83 | hash: 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a |
| 81 | path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch | 84 | path: patches/@pierre%2Fdiffs@1.3.0-beta.5.patch |
| ... | @@ -94,6 +97,9 @@ patchedDependencies: | ... | @@ -94,6 +97,9 @@ patchedDependencies: |
| 94 | react-native-gesture-handler@2.31.2: | 97 | react-native-gesture-handler@2.31.2: |
| 95 | hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 | 98 | hash: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 |
| 96 | path: patches/react-native-gesture-handler@2.31.2.patch | 99 | path: patches/react-native-gesture-handler@2.31.2.patch |
| 100 | react-native-keyboard-controller@1.21.13: | ||
| 101 | hash: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 | ||
| 102 | path: patches/react-native-keyboard-controller@1.21.13.patch | ||
| 97 | react-native-nitro-modules@0.35.9: | 103 | react-native-nitro-modules@0.35.9: |
| 98 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 | 104 | hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 |
| 99 | path: patches/react-native-nitro-modules@0.35.9.patch | 105 | path: patches/react-native-nitro-modules@0.35.9.patch |
| ... | @@ -226,7 +232,7 @@ importers: | ... | @@ -226,7 +232,7 @@ importers: |
| 226 | version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) | 232 | version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) |
| 227 | '@legendapp/list': | 233 | '@legendapp/list': |
| 228 | specifier: 3.2.0 | 234 | specifier: 3.2.0 |
| 229 | version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 235 | version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 230 | '@noble/curves': | 236 | '@noble/curves': |
| 231 | specifier: 'catalog:' | 237 | specifier: 'catalog:' |
| 232 | version: 1.9.1 | 238 | version: 1.9.1 |
| ... | @@ -379,7 +385,7 @@ importers: | ... | @@ -379,7 +385,7 @@ importers: |
| 379 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 385 | version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 380 | react-native-keyboard-controller: | 386 | react-native-keyboard-controller: |
| 381 | specifier: 1.21.13 | 387 | specifier: 1.21.13 |
| 382 | version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 388 | version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 383 | react-native-nitro-markdown: | 389 | react-native-nitro-markdown: |
| 384 | specifier: ^0.5.0 | 390 | specifier: ^0.5.0 |
| 385 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 391 | version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | @@ -537,7 +543,7 @@ importers: | ... | @@ -537,7 +543,7 @@ importers: |
| 537 | version: 0.9.0 | 543 | version: 0.9.0 |
| 538 | '@legendapp/list': | 544 | '@legendapp/list': |
| 539 | specifier: 3.2.0 | 545 | specifier: 3.2.0 |
| 540 | version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) | 546 | version: 3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 541 | '@lexical/react': | 547 | '@lexical/react': |
| 542 | specifier: ^0.41.0 | 548 | specifier: ^0.41.0 |
| 543 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) | 549 | version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) |
| ... | @@ -12654,7 +12660,7 @@ snapshots: | ... | @@ -12654,7 +12660,7 @@ snapshots: |
| 12654 | dependencies: | 12660 | dependencies: |
| 12655 | jsbi: 4.3.2 | 12661 | jsbi: 4.3.2 |
| 12656 | 12662 | ||
| 12657 | '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12663 | '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12658 | dependencies: | 12664 | dependencies: |
| 12659 | react: 19.2.3 | 12665 | react: 19.2.3 |
| 12660 | use-sync-external-store: 1.6.0(react@19.2.3) | 12666 | use-sync-external-store: 1.6.0(react@19.2.3) |
| ... | @@ -12662,7 +12668,7 @@ snapshots: | ... | @@ -12662,7 +12668,7 @@ snapshots: |
| 12662 | react-dom: 19.2.3(react@19.2.3) | 12668 | react-dom: 19.2.3(react@19.2.3) |
| 12663 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12669 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12664 | 12670 | ||
| 12665 | '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': | 12671 | '@legendapp/list@3.2.0(patch_hash=45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': |
| 12666 | dependencies: | 12672 | dependencies: |
| 12667 | react: 19.2.6 | 12673 | react: 19.2.6 |
| 12668 | use-sync-external-store: 1.6.0(react@19.2.6) | 12674 | use-sync-external-store: 1.6.0(react@19.2.6) |
| ... | @@ -18950,7 +18956,7 @@ snapshots: | ... | @@ -18950,7 +18956,7 @@ snapshots: |
| 18950 | react: 19.2.3 | 18956 | react: 19.2.3 |
| 18951 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18957 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18952 | 18958 | ||
| 18953 | react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 18959 | react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18954 | dependencies: | 18960 | dependencies: |
| 18955 | react: 19.2.3 | 18961 | react: 19.2.3 |
| 18956 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 18962 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
pnpm-workspace.yaml+7-16| ... | @@ -5,20 +5,6 @@ packages: | ... | @@ -5,20 +5,6 @@ packages: |
| 5 | - packages/* | 5 | - packages/* |
| 6 | - scripts | 6 | - scripts |
| 7 | 7 | ||
| 8 | # Install both Windows and Linux native binaries so the WSL (Linux) backend | ||
| 9 | # can load platform-gated optional deps (e.g. @yuuang/ffi-rs-linux-x64-gnu) | ||
| 10 | # out of the same node_modules the Windows desktop uses. | ||
| 11 | supportedArchitectures: | ||
| 12 | os: | ||
| 13 | - current | ||
| 14 | - linux | ||
| 15 | cpu: | ||
| 16 | - current | ||
| 17 | - x64 | ||
| 18 | libc: | ||
| 19 | - current | ||
| 20 | - glibc | ||
| 21 | |||
| 22 | catalog: | 8 | catalog: |
| 23 | "@clerk/backend": 3.8.4 | 9 | "@clerk/backend": 3.8.4 |
| 24 | "@clerk/clerk-js": 6.22.0 | 10 | "@clerk/clerk-js": 6.22.0 |
| ... | @@ -84,8 +70,6 @@ overrides: | ... | @@ -84,8 +70,6 @@ overrides: |
| 84 | "@pierre/diffs>@shikijs/transformers": ^4.2.0 | 70 | "@pierre/diffs>@shikijs/transformers": ^4.2.0 |
| 85 | "@types/node": "catalog:" | 71 | "@types/node": "catalog:" |
| 86 | effect: "catalog:" | 72 | effect: "catalog:" |
| 87 | # Pin to the version our patch targets; expo-modules-core's ~56.0.10 range | ||
| 88 | # otherwise floats to newer releases on fresh resolves (ERR_PNPM_UNUSED_PATCH). | ||
| 89 | expo-modules-jsi: 56.0.10 | 73 | expo-modules-jsi: 56.0.10 |
| 90 | vite: "catalog:" | 74 | vite: "catalog:" |
| 91 | yaml: "catalog:" | 75 | yaml: "catalog:" |
| ... | @@ -105,12 +89,14 @@ patchedDependencies: | ... | @@ -105,12 +89,14 @@ patchedDependencies: |
| 105 | "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch | 89 | "@effect/vitest@4.0.0-beta.78": patches/@effect__vitest@4.0.0-beta.78.patch |
| 106 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch | 90 | "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch |
| 107 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch | 91 | "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch |
| 92 | "@legendapp/list@3.2.0": patches/@legendapp__list@3.2.0.patch | ||
| 108 | "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch | 93 | "@pierre/diffs@1.3.0-beta.5": patches/@pierre%2Fdiffs@1.3.0-beta.5.patch |
| 109 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch | 94 | "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch |
| 110 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch | 95 | "@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch |
| 111 | effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch | 96 | effect@4.0.0-beta.78: patches/effect@4.0.0-beta.78.patch |
| 112 | expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch | 97 | expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch |
| 113 | react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch | 98 | react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch |
| 99 | react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch | ||
| 114 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch | 100 | react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch |
| 115 | react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch | 101 | react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch |
| 116 | 102 | ||
| ... | @@ -119,3 +105,8 @@ peerDependencyRules: | ... | @@ -119,3 +105,8 @@ peerDependencyRules: |
| 119 | - vite | 105 | - vite |
| 120 | allowedVersions: | 106 | allowedVersions: |
| 121 | vite: "*" | 107 | vite: "*" |
| 108 | |||
| 109 | supportedArchitectures: | ||
| 110 | cpu: [current, x64] | ||
| 111 | libc: [current, glibc] | ||
| 112 | os: [current, linux] |