import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { GlassContainer, GlassView } from "expo-glass-effect"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { ActivityIndicator, type LayoutChangeEvent, Pressable, Text as SystemText, useWindowDimensions, View, } from "react-native"; import Animated, { Easing, FadeIn, FadeOut, ReduceMotion, useAnimatedStyle, useSharedValue, withTiming, } from "react-native-reanimated"; import { withUniwind } from "uniwind"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import type { FloatingWorkingStatus } from "./floating-working-status"; import { ShimmeringWorkContent } from "./thread-work-log"; const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem // The collapsed composer capsule starts 6 below its overlay's top edge, so // the pill sits at (gap - 6) above the overlay to leave the same gap to the // capsule as the feed's end inset leaves between it and the last row. const CONTROL_GAP = 8; const COMPOSER_CAPSULE_INSET = 6; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); const CONTROL_TIMING = { duration: 240, easing: Easing.out(Easing.cubic), reduceMotion: ReduceMotion.System, } as const; const CONTROL_SEPARATION = (16 + CONTROL_HEIGHT) / 2; // Both rows share the same centered anchor, so the outgoing one clears fast and // the incoming one waits for it to be mostly gone before it starts to show. const LABEL_ENTERING = FadeIn.duration(160).delay(80).reduceMotion(ReduceMotion.System); const LABEL_EXITING = FadeOut.duration(100).reduceMotion(ReduceMotion.System); // Expo reapplies glass after native layout and window reattachment, when UIKit // can otherwise leave the label visible but lose the material behind it. const UniwindGlassView = withUniwind(GlassView, { style: { fromClassName: "className" }, }); const UniwindGlassContainer = withUniwind(GlassContainer, { style: { fromClassName: "className" }, }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; readonly status: FloatingWorkingStatus | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { const { width: windowWidth } = useWindowDimensions(); const [overlayWidth, setOverlayWidth] = useState(windowWidth); const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16); const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0); useEffect(() => { separationProgress.value = withTiming(props.showScrollToEnd ? 1 : 0, CONTROL_TIMING); }, [props.showScrollToEnd, separationProgress]); const arrowTransformStyle = useAnimatedStyle(() => ({ transform: [{ translateX: -CONTROL_SEPARATION * (1 - separationProgress.value) }], })); const arrowContentStyle = useAnimatedStyle(() => ({ opacity: separationProgress.value, })); // Animate an in-flow sizer so native glass receives real layout updates. // Measure labels in a separate, fixed-width host: measuring against the // animated capsule constrains the incoming text to each intermediate width // and repeatedly retargets the animation as it grows. const capsuleWidth = useSharedValue(null); const measuredWidthRef = useRef(null); const handleLabelLayout = (event: LayoutChangeEvent) => { const width = event.nativeEvent.layout.width; if (width === measuredWidthRef.current) { return; } const first = measuredWidthRef.current === null; measuredWidthRef.current = width; capsuleWidth.value = first ? width : withTiming(width, CONTROL_TIMING); }; // Forget the width while no label is shown so the next one appears at its // own size instead of animating from the previous label's. const hasStatus = props.status !== null; useEffect(() => { if (!hasStatus) { measuredWidthRef.current = null; capsuleWidth.value = null; } }, [capsuleWidth, hasStatus]); const capsuleStyle = useAnimatedStyle(() => ({ transform: [{ translateX: CONTROL_SEPARATION * (1 - separationProgress.value) }], })); // Zero until the first measurement lands, so the capsule never paints around // a label it has not sized to yet. const capsuleSizerStyle = useAnimatedStyle(() => ({ width: capsuleWidth.value ?? 0 })); if (props.status === null && !props.showScrollToEnd) { return null; } // Only the connection label is a button (tap to reconnect); the others // pass touches through to the feed like before. const statusInteractive = props.status?.kind === "connection"; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( <> ) : null; return ( setOverlayWidth(event.nativeEvent.layout.width)} entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( {statusContent} ) : props.status !== null ? ( {statusContent} ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( ) : ( )} ); } function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) { return ( Compacting… ); } function FloatingStatusLabel(props: { readonly status: FloatingWorkingStatus; readonly onLayout: (event: LayoutChangeEvent) => void; }) { // Keyed by kind so a swap mounts a fresh row and the two cross-fade while // the capsule animates to the new row's measured width. if (props.status.kind === "syncing") { return ( {props.status.label} ); } if (props.status.kind === "compacting") { return ; } if (props.status.kind === "connection") { return ( {props.status.tone === "reconnecting" ? ( ) : ( )} {props.status.label} ); } if (props.status.kind === "preparing") { return ( ); } return ( ); } // Absolute rows cross-fade around the same center without affecting each other. function StatusLabelRow(props: { readonly accessibilityLabel: string; readonly accessibilityRole?: "button"; readonly className?: string; readonly children: ReactNode; readonly onLayout: (event: LayoutChangeEvent) => void; readonly onPress?: () => void; }) { const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`; return ( {props.onPress ? ( {props.children} ) : ( {props.children} )} ); } function WorkingDuration(props: { readonly startedAt: string; readonly onLayout: (event: LayoutChangeEvent) => void; }) { const [nowMs, setNowMs] = useState(() => Date.now()); useEffect(() => { setNowMs(Date.now()); const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); return () => clearInterval(intervalId); }, [props.startedAt]); const duration = formatWorkingDuration(props.startedAt, nowMs); const label = `Working for ${duration}`; return ( Working for {duration} ); } function formatWorkingDuration(startedAt: string, nowMs: number): string { const startedAtMs = Date.parse(startedAt); if (!Number.isFinite(startedAtMs) || nowMs <= startedAtMs) { return "0s"; } const totalSeconds = Math.floor((nowMs - startedAtMs) / 1_000); if (totalSeconds < 60) { return `${totalSeconds}s`; } if (totalSeconds >= 3_600) { return formatDuration(totalSeconds * 1_000); } const minutes = Math.floor(totalSeconds / 60); const seconds = String(totalSeconds % 60).padStart(2, "0"); return `${minutes}m ${seconds}s`; } function ScrollToEndButton(props: { readonly disabled?: boolean; readonly onPress: () => void }) { return ( ); }