diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz deleted file mode 100644 index 33a1dcca0721d..0000000000000 Binary files a/apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz and /dev/null differ diff --git a/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz new file mode 100644 index 0000000000000..581307cf91be1 Binary files /dev/null and b/apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz differ diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index 1d57632eee5fc..32d48da78b314 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -57,6 +57,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( private var applyingNativeValue = false private var desiredLineHeightPx = 0 private var lastContentHeight = 0 + private var lastContentWidth = 0 private var contentInsetVertical = 0 private var tokensJson = "[]" private var tokens: List = emptyList() @@ -424,12 +425,19 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } private fun emitContentSizeIfNeeded() { - val height = editor.layout?.height ?: editor.measuredHeight - val contentHeight = height + contentInsetVertical * 2 - if (contentHeight == lastContentHeight) return + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters. Trailing whitespace hangs past a line without + // wrapping it, so getLineMax leaves it out. + val layout = editor.layout + val contentHeight = layout?.height ?: editor.measuredHeight + val contentWidth = if (layout?.lineCount == 1) layout.getLineMax(0).toInt() else 0 + if (contentHeight == lastContentHeight && contentWidth == lastContentWidth) return lastContentHeight = contentHeight + lastContentWidth = contentWidth + val density = resources.displayMetrics.density onComposerContentSizeChange( - mapOf("height" to contentHeight / resources.displayMetrics.density), + mapOf("width" to contentWidth / density, "height" to contentHeight / density), ) } diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 7a659cc510aee..b816bafde14ae 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -1230,7 +1230,28 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } private func emitContentSizeIfNeeded() { - let nextSize = textView.contentSize + // The laid-out text without insets. A one-line draft reports its natural width, so + // JS can tell whether it fits a narrower row than the one it is laid out in; past + // one line only the height matters, so layout stops at the second line. The width + // comes from the line fragment because usedRect spans the container once the view + // scrolls. A trailing newline's empty line only exists as the extra fragment. + let layout = textView.layoutManager + let container = textView.textContainer + var lines = 0 + var width: CGFloat = 0 + var height: CGFloat = 0 + let glyphs = NSRange(location: 0, length: layout.numberOfGlyphs) + layout.enumerateLineFragments(forGlyphRange: glyphs) { _, used, _, _, stop in + lines += 1 + width = used.width + height = used.maxY + if lines > 1 { stop.pointee = true } + } + if lines < 2, layout.extraLineFragmentTextContainer === container { + lines += 1 + height = max(height, layout.extraLineFragmentUsedRect.maxY) + } + let nextSize = CGSize(width: lines == 1 ? ceil(width) : 0, height: ceil(height)) guard abs(nextSize.width - lastContentSize.width) > 0.5 || abs(nextSize.height - lastContentSize.height) > 0.5 else { return diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c7e8406bc1746..441ee7beb6d35 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -134,7 +134,7 @@ "react-native-pdf-jsi": "5.0.0", "react-native-reanimated": "4.7.1", "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.29.tgz", + "react-native-screens": "file:deps/react-native-screens-5.0.0-t3.30.tgz", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.16.0", "react-native-webview": "14.0.1", diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index d6bbf9d992943..5c84720bedf97 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -13,6 +13,8 @@ import { type ViewStyle, } from "react-native"; +import Animated, { FadeIn, LinearTransition } from "react-native-reanimated"; + import { cn } from "../lib/cn"; import { AppText as Text } from "./ui/AppText"; import { SymbolView } from "./ui/AppSymbol"; @@ -21,6 +23,10 @@ import { useAndroidControlSizing } from "./ui/useAndroidControlSizing"; const COMPOSER_TOOLBAR_GAP = 8; const COMPOSER_TOOLBAR_FADE_WIDTH = 18; const COMPOSER_TOOLBAR_SCROLL_EPSILON = 4; +const INLINE_CONTROL_LABEL_ENTERING = FadeIn.duration(160); +// Android runs no composer layout transitions, so the toolbar never trails the dock. +const INLINE_CONTROL_WIDTH_TRANSITION = + Platform.OS === "android" ? undefined : LinearTransition.duration(180); export function ComposerInlineControl(props: { readonly accessibilityHint?: string; @@ -30,6 +36,8 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly renderIcon?: (size: number) => ReactNode; readonly label: string; + /** Show only the icon and chevron; the label still names the control for accessibility. */ + readonly iconOnly?: boolean; readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; @@ -38,56 +46,77 @@ export function ComposerInlineControl(props: { readonly showChevron?: boolean; }) { const { scale, smallIconSize } = useAndroidControlSizing(); + // The label fades in as the control widens, and leaves at once: an exiting view keeps + // drawing at its old place while the chevron moves into it. The clip keeps content laid out + // at the final width inside the frame while it eases there. + const animatesLabel = props.iconOnly !== undefined; return ( - - {props.renderIcon ? ( - - {props.renderIcon(smallIconSize)} - - ) : props.icon ? ( - - ) : null} - - {props.label} - - {props.showChevron === false ? null : ( - - )} - + {props.renderIcon ? ( + + {props.renderIcon(smallIconSize)} + + ) : props.icon ? ( + + ) : null} + {props.iconOnly ? null : ( + + + {props.label} + + + )} + {props.showChevron === false ? null : ( + + )} + + ); } diff --git a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx index b35c7cc58f8f2..1b259f1ecbfa1 100644 --- a/apps/mobile/src/components/ui/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/ui/AndroidScreenHeader.tsx @@ -1,5 +1,6 @@ import { AppActivityIndicator } from "./AppActivityIndicator"; -import { Column, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { Column, Row, Text as NativeText, RNHostView } from "@expo/ui/jetpack-compose"; +import { weight } from "@expo/ui/jetpack-compose/modifiers"; import { useState, type ReactNode } from "react"; import { View } from "react-native"; @@ -43,6 +44,8 @@ export function AndroidScreenHeader(props: { readonly onBack?: () => void; readonly embedded?: boolean; readonly hideBottomBorder?: boolean; + /** One row with the subtitle after the title, for screens short on height. */ + readonly compact?: boolean; }) { const { colors } = useControlTheme(); const titleTypography = useScaledTextRole("title"); @@ -65,6 +68,7 @@ export function AndroidScreenHeader(props: { }} > @@ -95,6 +99,26 @@ export function AndroidScreenHeader(props: { > {props.title} + ) : props.compact ? ( + + + {props.title} + + + {props.subtitle} + + ) : ( : undefined} hideBottomBorder={props.hideBottomBorder} + compact={props.compact} actions={ search ? [ diff --git a/apps/mobile/src/components/ui/AppBar.types.ts b/apps/mobile/src/components/ui/AppBar.types.ts index 621006d949c09..e3db494219d71 100644 --- a/apps/mobile/src/components/ui/AppBar.types.ts +++ b/apps/mobile/src/components/ui/AppBar.types.ts @@ -68,6 +68,8 @@ export interface AppBarProps { readonly onPress?: () => void; }; readonly hideBottomBorder?: boolean; + /** Android: one title row with the subtitle inline. iOS ignores it. */ + readonly compact?: boolean; readonly trailing?: ReactNode; readonly matchSearchSurface?: boolean; readonly options?: AppNativeStackNavigationOptions; diff --git a/apps/mobile/src/components/ui/NativeAppBar.android.tsx b/apps/mobile/src/components/ui/NativeAppBar.android.tsx index 2c028fded8038..22f8ca65cc425 100644 --- a/apps/mobile/src/components/ui/NativeAppBar.android.tsx +++ b/apps/mobile/src/components/ui/NativeAppBar.android.tsx @@ -10,9 +10,11 @@ export function NativeAppBar(props: { readonly title: ReactElement; readonly navigationIcon?: ReactElement; readonly actions?: ReactElement; + /** Shorter bar for a one-row title. */ + readonly singleLine?: boolean; }) { const { colors } = useControlTheme(); - const layout = useToolbarLayout(); + const layout = useToolbarLayout(false, props.singleLine); return ( // The bar's height is fixed, so React Native lays out the screen below it from the first frame // instead of at zero height until Compose has measured and reported the bar. diff --git a/apps/mobile/src/components/ui/useToolbarLayout.ts b/apps/mobile/src/components/ui/useToolbarLayout.ts index faa02c1982898..736ac27ed4eb7 100644 --- a/apps/mobile/src/components/ui/useToolbarLayout.ts +++ b/apps/mobile/src/components/ui/useToolbarLayout.ts @@ -4,8 +4,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useScaledTextRole } from "../../features/settings/appearance/useScaledTextRole"; import { useAndroidControlSizing } from "./useAndroidControlSizing"; -/** Shared Android header geometry; class-based spacing uses the app's 14dp rem. */ -export function useToolbarLayout(embedded = false) { +/** + * Shared Android header geometry; class-based spacing uses the app's 14dp rem. + * `singleLine` sizes the toolbar for a title row with the subtitle inline. + */ +export function useToolbarLayout(embedded = false, singleLine = false) { const insets = useSafeAreaInsets(); const title = useScaledTextRole("title"); const subtitle = useScaledTextRole("label"); @@ -13,7 +16,9 @@ export function useToolbarLayout(embedded = false) { const { fontScale } = useWindowDimensions(); return { height: Math.ceil( - Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), + singleLine + ? Math.max(48, 48 * scale, title.lineHeight * fontScale + 1) + : Math.max(48, 56 * scale, (title.lineHeight + subtitle.lineHeight) * fontScale + 1), ), paddingTop: embedded ? 8 * scale : Math.max(insets.top, 12 * scale), paddingBottom: 7 * scale, diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 480ca463dac8c..07e70f448a727 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -15,6 +15,7 @@ import { import { createContext, use, + useDeferredValue, useEffect, useEffectEvent, useMemo, @@ -23,6 +24,7 @@ import { type ReactNode, } from "react"; import { Platform, useWindowDimensions, View } from "react-native"; +import { KeyboardController, useKeyboardState } from "react-native-keyboard-controller"; import Animated, { runOnJS, useAnimatedStyle, @@ -73,7 +75,10 @@ import { useOpenThreadPullRequests } from "../threads/use-open-thread-pull-reque import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; -import { WorkspaceContentWidthContext } from "./workspace-content-width"; +import { + WorkspaceContentWidthContext, + WorkspaceSettledContentWidthContext, +} from "./workspace-content-width"; import { NativeWorkspaceModeContext, NativeWorkspaceInspectorContext, @@ -96,6 +101,13 @@ interface AdaptiveWorkspaceContextValue { readonly toggleAuxiliaryPane: () => void; readonly togglePrimarySidebar: () => void; readonly setAuxiliaryPaneWidth: (width: number) => void; + /** + * Android split view while the thread composer has the soft keyboard: the + * sidebar and rail step aside and the thread header drops to one line. + */ + readonly composerKeyboardActive: boolean; + /** The thread composer reports its focus so the layout can make room for typing. */ + readonly setComposerEditing: (editing: boolean) => void; } const compactLayout = deriveLayout({ width: 0, height: 0 }); @@ -122,6 +134,8 @@ const AdaptiveWorkspaceContext = createContext({ toggleAuxiliaryPane: () => undefined, togglePrimarySidebar: () => undefined, setAuxiliaryPaneWidth: () => undefined, + composerKeyboardActive: false, + setComposerEditing: () => undefined, }); export function useAdaptiveWorkspaceLayout(): AdaptiveWorkspaceContextValue { @@ -270,8 +284,19 @@ function AdaptiveWorkspaceLayoutContent( nativeWorkspace, usesSplitView: baseLayout.usesSplitView, }); + const [composerEditing, setComposerEditing] = useState(false); + const softKeyboardVisible = useKeyboardState((state) => state.isVisible); + // Keyed on the soft keyboard, not focus alone: a hardware keyboard leaves room + // for the sidebar, and Back closes the keyboard while the editor keeps focus. + // Deferred because the thread composer follows the keyboard only once its own render of this + // keyboard event commits; re-laying out the workspace in that commit held it behind the + // keyboard until the feed had reflowed. + const composerKeyboardActive = useDeferredValue( + Platform.OS === "android" && baseLayout.usesSplitView && composerEditing && softKeyboardVisible, + ); const showPrimarySidebar = - !workspacePage && (primarySidebarPinned || primarySidebarPreferredVisible); + !workspacePage && + (primarySidebarPinned || (primarySidebarPreferredVisible && !composerKeyboardActive)); const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; const navigationRailWidth = shouldRenderNavigationRail && (showPrimarySidebar || workspacePage) @@ -391,6 +416,12 @@ function AdaptiveWorkspaceLayoutContent( if (primarySidebarPinned) { return; } + // A sidebar the keyboard set aside returns with the keyboard; the user's preference stays + // intact. One the user had hidden still opens. + if (composerKeyboardActive) { + void KeyboardController.dismiss(); + if (primarySidebarPreferredVisible) return; + } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); @@ -399,6 +430,7 @@ function AdaptiveWorkspaceLayoutContent( setPrimarySidebarPreferredVisible((current) => !current); }; const revealPrimarySidebar = () => { + if (composerKeyboardActive) void KeyboardController.dismiss(); if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); } @@ -499,12 +531,10 @@ function AdaptiveWorkspaceLayoutContent( const contentTargetWidth = layout.usesSplitView ? Math.max(0, panes.contentPaneWidth - inspectorColumnTargetWidth) : null; - const [contentSizing, setContentSizing] = useState({ + const [listPaneSizing, setListPaneSizing] = useState({ viewportWidth, workspacePage, - target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - width: contentTargetWidth, listPaneTarget: layout.listPaneWidth, listPaneFloor: layout.listPaneWidth, listPaneCeiling: layout.listPaneWidth, @@ -512,69 +542,80 @@ function AdaptiveWorkspaceLayoutContent( }); if ( Platform.OS === "android" && - (contentSizing.viewportWidth !== viewportWidth || - contentSizing.workspacePage !== workspacePage || - contentSizing.target !== contentTargetWidth || - contentSizing.sidebarVisible !== panes.primarySidebarVisible || - contentSizing.listPaneTarget !== layout.listPaneWidth) + (listPaneSizing.viewportWidth !== viewportWidth || + listPaneSizing.workspacePage !== workspacePage || + listPaneSizing.sidebarVisible !== panes.primarySidebarVisible || + listPaneSizing.listPaneTarget !== layout.listPaneWidth) ) { - // A workspace page swaps the sidebar in the same frame, so nothing animates to wait for. - const sameViewport = - contentSizing.viewportWidth === viewportWidth && - contentSizing.workspacePage === workspacePage; - const openingSidebar = - sameViewport && !contentSizing.sidebarVisible && panes.primarySidebarVisible; // A book-posture hinge resizes the visible list pane instead of revealing it. const resizingSidebar = - sameViewport && - contentSizing.sidebarVisible && + listPaneSizing.viewportWidth === viewportWidth && + listPaneSizing.workspacePage === workspacePage && + listPaneSizing.sidebarVisible && panes.primarySidebarVisible && - contentSizing.listPaneTarget !== layout.listPaneWidth; - const keepWide = - (openingSidebar || resizingSidebar) && - contentSizing.width !== null && - contentTargetWidth !== null; - setContentSizing({ + listPaneSizing.listPaneTarget !== layout.listPaneWidth; + setListPaneSizing({ viewportWidth, workspacePage, - target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - // Clip the wide navigator while the sidebar opens or widens, then reflow once at the - // end. Narrowing it first exposes an empty strip before the sidebar arrives. - width: keepWide - ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) - : contentTargetWidth, listPaneTarget: layout.listPaneWidth, // While a hinge resizes the list, its width follows the pane edge within this span. A // retarget mid-animation widens the span, since the edge is still somewhere inside it. listPaneFloor: resizingSidebar - ? Math.min(contentSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) + ? Math.min(listPaneSizing.listPaneFloor ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, listPaneCeiling: resizingSidebar - ? Math.max(contentSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) + ? Math.max(listPaneSizing.listPaneCeiling ?? 0, layout.listPaneWidth ?? 0) : layout.listPaneWidth, - revision: contentSizing.revision + 1, + revision: listPaneSizing.revision + 1, }); } - const settleContentWidth = (revision: number) => { - setContentSizing((current) => + const settleListPane = (revision: number) => { + setListPaneSizing((current) => current.revision !== revision || - (current.width === current.target && - current.listPaneFloor === current.listPaneTarget && + (current.listPaneFloor === current.listPaneTarget && current.listPaneCeiling === current.listPaneTarget) ? current : { ...current, - width: current.target, listPaneFloor: current.listPaneTarget, listPaneCeiling: current.listPaneTarget, }, ); }; - const listPaneFloor = contentSizing.listPaneFloor ?? 0; - const listPaneCeiling = contentSizing.listPaneCeiling ?? 0; - const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; - const contentRevision = contentSizing.revision; + const listPaneFloor = listPaneSizing.listPaneFloor ?? 0; + const listPaneCeiling = listPaneSizing.listPaneCeiling ?? 0; + const listPaneRevision = listPaneSizing.revision; + // Android lays the content out at its new width as soon as a pane toggles, then holds it + // against the edge that stays put while the other one slides, so nothing reflows after the + // panes land. A sliding sidebar uncovers the strip it is about to fill, or covers the content + // until it is gone. + const contentLeftTarget = + navigationRailWidth + + (shouldRenderPrimarySidebar && !workspacePage && panes.primarySidebarVisible + ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) + : 0); + const [contentAnchor, setContentAnchor] = useState({ + viewportWidth, + left: contentLeftTarget, + inspector: inspectorColumnTargetWidth, + end: false, + }); + if ( + Platform.OS === "android" && + (contentAnchor.viewportWidth !== viewportWidth || + contentAnchor.left !== contentLeftTarget || + contentAnchor.inspector !== inspectorColumnTargetWidth) + ) { + setContentAnchor({ + viewportWidth, + left: contentLeftTarget, + inspector: inspectorColumnTargetWidth, + end: + contentAnchor.viewportWidth === viewportWidth && + contentAnchor.inspector === inspectorColumnTargetWidth, + }); + } const renderedSidebarWidth = useSharedValue( panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) + (layout.listPaneGap ?? 0) : 0, @@ -598,7 +639,7 @@ function AdaptiveWorkspaceLayoutContent( // The content beside it already took its final width when the page closed. renderedSidebarWidth.set( withTiming(targetWidth, snap ? { duration: 0 } : WORKSPACE_PANE_TIMING, (finished) => { - if (finished && Platform.OS === "android") runOnJS(settleContentWidth)(contentRevision); + if (finished && Platform.OS === "android") runOnJS(settleListPane)(listPaneRevision); }), ); }, [ @@ -609,8 +650,8 @@ function AdaptiveWorkspaceLayoutContent( panes.primarySidebarVisible, renderedSidebarWidth, renderedRailWidth, - settleContentWidth, - contentRevision, + settleListPane, + listPaneRevision, workspacePage, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); @@ -699,6 +740,8 @@ function AdaptiveWorkspaceLayoutContent( toggleAuxiliaryPane, togglePrimarySidebar, setAuxiliaryPaneWidth, + composerKeyboardActive, + setComposerEditing, }; if (nativeWorkspace) { @@ -796,18 +839,28 @@ function AdaptiveWorkspaceLayoutContent( + {/* Android holds the content still, so its floating controls keep its width. */} - {props.children} + + {props.children} + diff --git a/apps/mobile/src/features/layout/workspace-content-width.ts b/apps/mobile/src/features/layout/workspace-content-width.ts index cf24b1be68b43..e4811586c0afa 100644 --- a/apps/mobile/src/features/layout/workspace-content-width.ts +++ b/apps/mobile/src/features/layout/workspace-content-width.ts @@ -8,3 +8,12 @@ export const WorkspaceContentWidthContext = createContext | export function useWorkspaceContentWidth() { return use(WorkspaceContentWidthContext); } + +// The settled width itself. The feed lays its rows out at this width in the same commit that +// resizes the pane; waiting for its own onLayout re-measured every row twice, once against the +// old text widths, and the list jumped while it repositioned them. +export const WorkspaceSettledContentWidthContext = createContext(null); + +export function useWorkspaceSettledContentWidth() { + return use(WorkspaceSettledContentWidthContext); +} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 1619612a5aef3..8140517f4ab44 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -187,6 +188,7 @@ export interface ThreadComposerProps { readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; + /** True while the draft is stacked above the controls rather than in the one-row capsule. */ readonly onExpandedChange?: (expanded: boolean) => void; readonly onEditorFocusChange?: (focused: boolean) => void; } @@ -220,6 +222,11 @@ const composerHeightTransition: LayoutAnimationFunction = (values) => { }; export const COMPOSER_LAYOUT_TRANSITION = Platform.OS === "android" ? undefined : composerHeightTransition; +// Share of the one-row composer the draft keeps; a long model name truncates first. +const INLINE_EDITOR_MIN_WIDTH = "40%"; +// The one-row editor's horizontal padding plus room for the caret. +const INLINE_EDITOR_TEXT_SLACK = 10; +const INLINE_EDITOR_HEIGHT = 36; const COMPOSER_ATTACHMENT_ENTERING = Platform.OS === "android" @@ -495,8 +502,39 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ); const isVoiceInputPresented = voicePresentation.statusLabel !== null; const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; - const showsCompactDictation = isVoiceInputPresented && !isExpanded; - const isToolbarVisible = isExpanded || isVoiceInputPresented; + const { setComposerEditing } = useAdaptiveWorkspaceLayout(); + // The composer stays one row, with the model picker in it, until the draft would + // wrap. Then the draft stacks above the controls and returns to the row once it + // fits again. The native editor reports its laid-out text size: more than one + // line means the draft wrapped where it is, and the widest line is a one-line + // draft's natural width, which is compared with the row's text area so a draft + // laid out in the wider card still returns to the row when it fits, including + // when the pane widens. The measurements live in refs and only the verdict is + // state, so a keystroke that keeps the verdict costs no extra render. + const draftSizeRef = useRef({ width: 0, height: 0 }); + const rowGeometryRef = useRef({ surfaceWidth: 0, leadingWidth: 0, trailingWidth: 0 }); + const [draftOverflowsRow, setDraftOverflowsRow] = useState(false); + const lineHeight = bodyText.lineHeight; + const updateDraftOverflow = () => { + const { width, height } = draftSizeRef.current; + const row = rowGeometryRef.current; + const rowTextWidth = + row.surfaceWidth - row.leadingWidth - row.trailingWidth - INLINE_EDITOR_TEXT_SLACK; + setDraftOverflowsRow( + height > lineHeight * 1.5 || (row.surfaceWidth > 0 && width > rowTextWidth), + ); + }; + const measureRow = (key: keyof typeof rowGeometryRef.current, value: number) => { + if (rowGeometryRef.current[key] === value) return; + rowGeometryRef.current = { ...rowGeometryRef.current, [key]: value }; + updateDraftOverflow(); + }; + const usesCard = isExpanded && draftMessage.length > 0 && draftOverflowsRow; + const showsCompactDictation = isVoiceInputPresented && !usesCard; + const isToolbarVisible = usesCard || isVoiceInputPresented; + const hasEditingAttachments = + (queuedEdit !== null && queuedEdit.existingAttachments.length > 0) || + stripAttachments.length > 0; const attachmentBlockReason = composerAttachmentUploadBlockReason({ environmentId: props.environmentId, attachments: draftAttachments, @@ -519,8 +557,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps !modelUnavailable; useEffect(() => { - onExpandedChange?.(isExpanded); - }, [isExpanded, onExpandedChange]); + onExpandedChange?.(usesCard); + }, [usesCard, onExpandedChange]); + useEffect(() => () => setComposerEditing(false), [setComposerEditing]); const onPressPreview = (source: FilePreviewSource) => { wasExpandedBeforePreviewRef.current = isFocused; @@ -553,16 +592,14 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = () => { setIsFocused(true); - onExpandedChange?.(true); onEditorFocusChange?.(true); + setComposerEditing(true); }; const handleBlur = () => { setIsFocused(false); - if (!settingsSheetPresentation.keepsComposerExpanded) { - onExpandedChange?.(false); - } onEditorFocusChange?.(false); + setComposerEditing(false); }; const handleSend = async (followUp?: ActiveTurnComposerAction) => { if (voiceInput.blocksSubmission || pendingPastedTextAttachmentCountRef.current > 0) return; @@ -676,12 +713,80 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps [navigation, settingsSheetPresentation.onStackTransitionsFinished], ); + // Removable attachments: inside the card, or above the focused one-row composer. + const editingAttachments = hasEditingAttachments ? ( + <> + {queuedEdit !== null && queuedEdit.existingAttachments.length > 0 ? ( + + + + ) : null} + {stripAttachments.length > 0 ? ( + + undefined : props.onRemoveDraftImage} + onPressPreview={voiceInput.isBusy ? undefined : onPressPreview} + onPressVideo={voiceInput.isBusy ? undefined : onPressVideo} + onPressDocument={ + voiceInput.isBusy + ? undefined + : (attachment) => + openDraftDocument({ + attachmentId: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }) + } + /> + + ) : null} + + ) : null; + + // The row and the card's toolbar share this picker. It shrinks to the provider + // icon once the draft has text, so the draft keeps the width. + const modelControl = ( + + ( + + )} + label={currentModelOption?.label ?? currentModelSelection.model} + iconOnly={draftMessage.length > 0} + maxWidth="100%" + onPress={openSettings} + /> + + ); + return ( measureRow("surfaceWidth", event.nativeEvent.layout.width)} > {!voiceInput.isBusy && composerMenu.trigger && @@ -746,10 +852,9 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps + {isExpanded && !usesCard ? editingAttachments : null} - - ( - - )} - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - onPress={openSettings} - /> - + {modelControl} )} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d733b751ace64..727cf57824441 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,6 +1,7 @@ import { Button } from "../../components/ui/Button"; import * as Dialog from "../../components/ui/Dialog"; import { useOpenPullRequest } from "../../native/useOpenPullRequest"; +import { useWorkspaceSettledContentWidth } from "../layout/workspace-content-width"; import { ThreadMarkdownVideo, ThreadMediaVisibleContext } from "./ThreadMarkdownVideo"; import { useMarkdownImageSource } from "../../native/useMarkdownImageSource"; import { enrichedContextLinkCustomization } from "../../native/useEnrichedLinkVariants"; @@ -1696,9 +1697,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { previousTextSize.current = workRowSizing.textSizeKey; listRef.current?.clearCaches({ mode: "sizes" }); }, [workRowSizing.textSizeKey, listRef]); - const [viewportWidth, setViewportWidth] = useState(() => + const settledPaneWidth = useWorkspaceSettledContentWidth(); + const [measuredViewportWidth, setViewportWidth] = useState(() => props.layoutVariant === "split" ? 0 : windowWidth, ); + const viewportWidth = + settledPaneWidth !== null ? Math.round(settledPaneWidth) : measuredViewportWidth; const [viewportHeight, setViewportHeight] = useState(0); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); // LegendList's maintainScrollAtEnd alone re-pins the feed whenever the @@ -2402,6 +2406,14 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { if (previousContentWidth.current === contentWidth) return; previousContentWidth.current = contentWidth; if (hasHtmlRenders) listRef.current?.clearCaches({ mode: "sizes" }); + // A reflow can grow the visible rows past LegendList's near-end threshold in one batch, and + // maintainScrollAtEnd then stops following. Pin the end the feed was following once the new + // sizes are in. + if (!endFollowEnabledRef.current || !listRef.current?.getState().isAtEnd) return; + const frame = requestAnimationFrame(() => { + if (endFollowEnabledRef.current) void listRef.current?.scrollToEnd({ animated: false }); + }); + return () => cancelAnimationFrame(frame); }, [contentWidth, hasHtmlRenders, listRef]); const renderItem = (info: { item: PendingThreadFeedEntry; index: number }) => ( diff --git a/apps/mobile/src/features/threads/ThreadHeader.tsx b/apps/mobile/src/features/threads/ThreadHeader.tsx index 62e98edc00767..acdbaea83efac 100644 --- a/apps/mobile/src/features/threads/ThreadHeader.tsx +++ b/apps/mobile/src/features/threads/ThreadHeader.tsx @@ -20,7 +20,8 @@ export function ThreadHeader( }, ) { const goBack = useWorkspaceBackNavigation("threads"); - const { layout, panes, toggleAuxiliaryPane } = useAdaptiveWorkspaceLayout(); + const { layout, panes, toggleAuxiliaryPane, composerKeyboardActive } = + useAdaptiveWorkspaceLayout(); const { onOpenTerminal, onMergeBack } = props.gitControls; const native = useThreadHeaderOptions(props); const androidHeaderActions = useMemo>(() => { @@ -100,6 +101,7 @@ export function ThreadHeader( onBack={layout.usesSplitView ? undefined : goBack} actions={androidHeaderActions} hideBottomBorder + compact={composerKeyboardActive} /> {native.fallback} diff --git a/apps/mobile/src/native/T3ComposerEditor.ios.tsx b/apps/mobile/src/native/T3ComposerEditor.ios.tsx index f2d2699f7a895..dfbfff1921719 100644 --- a/apps/mobile/src/native/T3ComposerEditor.ios.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.ios.tsx @@ -99,6 +99,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; readonly onComposerSubmit?: (event: NativeSyntheticEvent<{ alternate: boolean }>) => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -125,6 +128,7 @@ export function ComposerEditor({ onFocus, onBlur, onSubmit, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -349,6 +353,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } onComposerSubmit={ onSubmit === undefined ? undefined diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index dce1589439adb..732df5f8f2e3d 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -98,6 +98,9 @@ interface NativeComposerEditorProps extends ViewProps { readonly onComposerPasteText?: (event: NativePasteTextEvent) => void; readonly onComposerFocus?: () => void; readonly onComposerBlur?: () => void; + readonly onComposerContentSizeChange?: ( + event: NativeSyntheticEvent<{ readonly width: number; readonly height: number }>, + ) => void; } const NativeView = requireNativeView(NATIVE_MODULE_NAME); @@ -123,6 +126,7 @@ export function ComposerEditor({ onPasteText, onFocus, onBlur, + onContentSizeChange, contentInsetVertical = 0, ...props }: ComposerEditorProps) { @@ -354,6 +358,11 @@ export function ComposerEditor({ }} onComposerFocus={onFocus} onComposerBlur={onBlur} + onComposerContentSizeChange={ + onContentSizeChange === undefined + ? undefined + : (event) => onContentSizeChange(event.nativeEvent) + } /> ); diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index 20554b4f1eb08..d621deb3c6ac8 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -19,6 +19,7 @@ export function ComposerEditor({ singleLineCentered: _singleLineCentered, enterBehavior: _enterBehavior, readOnly = false, + onContentSizeChange, ...props }: ComposerEditorProps) { const inputRef = useRef(null); @@ -45,6 +46,7 @@ export function ComposerEditor({ editable={(props.editable ?? true) && !readOnly} selection={selection} onSelectionChange={(event) => props.onSelectionChange?.(event.nativeEvent.selection)} + onContentSizeChange={(event) => onContentSizeChange?.(event.nativeEvent.contentSize)} multiline={props.multiline ?? true} placeholderTextColorClassName={"accent-placeholder"} className="text-foreground" diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index 4e2ddd178b06d..95726c9f8ba92 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -68,6 +68,14 @@ export interface ComposerEditorProps { readonly onPasteText?: (paste: ComposerTextPaste) => void; readonly onFocus?: () => void; readonly onBlur?: () => void; + /** + * Size of the laid-out text in dp, without insets. Width is the widest line, so a + * one-line draft reports its natural width; only fires when the size changes. + */ + readonly onContentSizeChange?: (size: { + readonly width: number; + readonly height: number; + }) => void; /** * Hardware-keyboard Return behavior on iOS. No-op on Android, which has no * hardware Return handling. diff --git a/docs/operations/mobile-screens-fork.md b/docs/operations/mobile-screens-fork.md index db92950963c6e..cd21aa551370b 100644 --- a/docs/operations/mobile-screens-fork.md +++ b/docs/operations/mobile-screens-fork.md @@ -1,7 +1,7 @@ # Building the mobile Screens dependency The mobile v5 stack uses a committed tarball from the -[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.29). +[T3 Screens fork](https://github.com/pingdotgg/react-native-screens/tree/t3-v5.0.0-t3.30). The fork owns the integration changes; the app does not patch this package. Each archive includes compiled JavaScript, declarations, native source and `t3-fork.json` recording its version, source commit and upstream base. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cd3fc1b2bae1..d1455a1b206b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -380,7 +380,7 @@ importers: version: 7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) '@react-navigation/native-stack': specifier: 7.21.0 - version: 7.21.0(patch_hash=caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e)(6f7595467acac269803072e24b2c2fee) + version: 7.21.0(patch_hash=caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e)(16f893500fb9de13b2f16d636ddf6f9b) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -592,8 +592,8 @@ importers: specifier: ~5.10.1 version: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-screens: - specifier: file:deps/react-native-screens-5.0.0-t3.29.tgz - version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + specifier: file:deps/react-native-screens-5.0.0-t3.30.tgz + version: file:apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -11271,9 +11271,9 @@ packages: react: '*' react-native: '*' - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz: - resolution: {integrity: sha512-Y5gaQmd/p1SOeCh7Esyaj1DEycDJ7XFNUdXS1ZDOsuwF5yK1h8yu4H4tCw6mKDrSVV8bNPBYhf4qs5/AaNe3zQ==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz} - version: 5.0.0-t3.29 + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz: + resolution: {integrity: sha512-Szxvbp1mt4KwJ6cLoirkLQ2kz7Ebia3fTYPTjTifwqw3djskueHmML1np+wSZEs3Y0NtZf681SG/8xN5iW3T5Q==, tarball: file:apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz} + version: 5.0.0-t3.30 peerDependencies: react: '*' react-native: '*' @@ -16475,7 +16475,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - '@react-navigation/native-stack@7.21.0(patch_hash=caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e)(6f7595467acac269803072e24b2c2fee)': + '@react-navigation/native-stack@7.21.0(patch_hash=caae6552051bce91a85f567f3a08f9c8d25ce4c53f65a9391a67f016bb1d798e)(16f893500fb9de13b2f16d636ddf6f9b)': dependencies: '@react-navigation/elements': 2.9.45(da16e244bd876bd7d4e1e8899c8eabfd) '@react-navigation/native': 7.5.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -16483,7 +16483,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.10.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) - react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + react-native-screens: file:apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -22649,7 +22649,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.29.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-screens@file:apps/mobile/deps/react-native-screens-5.0.0-t3.30.tgz(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-freeze: 1.0.4(react@19.3.0)