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<number | null>(null); const measuredWidthRef = useRef<number | null>(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 ? ( <> <Animated.View className="h-11" style={capsuleSizerStyle} /> <View pointerEvents="box-none" className="absolute h-11 items-center justify-center" style={{ width: labelWidth }} > <FloatingStatusLabel key={ props.status.kind === "working" || props.status.kind === "compacting" ? props.status.kind : `${props.status.kind}:${props.status.label}` } status={props.status} onLayout={handleLabelLayout} /> </View> </> ) : null; return ( <Animated.View pointerEvents="box-none" className="absolute left-0 right-0 z-20 items-center" style={{ top: -CONTROL_OVERLAY_OFFSET }} onLayout={(event) => 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 ? ( <UniwindGlassContainer spacing={GLASS_MERGE_SPACING} pointerEvents="box-none" className="flex-row items-center gap-4" > <AnimatedGlassView colorScheme={props.colorScheme} glassEffectStyle="regular" isInteractive={statusInteractive} pointerEvents={statusInteractive ? "box-none" : "none"} className="h-11 items-center justify-center overflow-hidden rounded-full" style={capsuleStyle} > {statusContent} </AnimatedGlassView> <AnimatedGlassView colorScheme={props.colorScheme} glassEffectStyle="regular" isInteractive pointerEvents={props.showScrollToEnd ? "auto" : "none"} accessibilityElementsHidden={!props.showScrollToEnd} importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"} className="h-11 w-11 items-center justify-center overflow-hidden rounded-full" style={arrowTransformStyle} > <Animated.View style={arrowContentStyle}> <ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} /> </Animated.View> </AnimatedGlassView> </UniwindGlassContainer> ) : props.status !== null ? ( <View pointerEvents="box-none" className="flex-row items-center gap-4"> <Animated.View pointerEvents={statusInteractive ? "box-none" : "none"} className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10" style={capsuleStyle} > {statusContent} </Animated.View> <Animated.View pointerEvents={props.showScrollToEnd ? "auto" : "none"} accessibilityElementsHidden={!props.showScrollToEnd} importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"} style={[arrowTransformStyle, arrowContentStyle]} > <ControlPill accessibilityLabel="Scroll to end" activateOnPressIn className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10" disabled={!props.showScrollToEnd} icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} onPress={props.onScrollToEnd} /> </Animated.View> </View> ) : NATIVE_LIQUID_GLASS_SUPPORTED ? ( <UniwindGlassView colorScheme={props.colorScheme} glassEffectStyle="regular" isInteractive className="h-11 w-11 items-center justify-center overflow-hidden rounded-full" > <ScrollToEndButton onPress={props.onScrollToEnd} /> </UniwindGlassView> ) : ( <ControlPill accessibilityLabel="Scroll to end" activateOnPressIn className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10" icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} onPress={props.onScrollToEnd} /> )} </Animated.View> );} function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) { return ( <StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}> <SymbolView name="arrow.down.right.and.arrow.up.left" size={13} tintColorClassName="foreground" type="monochrome" /> <Text className="font-t3-medium text-xs text-foreground">Compacting…</Text> </StatusLabelRow> );} 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 ( <StatusLabelRow key="syncing" accessibilityLabel={props.status.label} className="gap-2" onLayout={props.onLayout} > <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}> {props.status.label} </Text> </StatusLabelRow> ); } if (props.status.kind === "compacting") { return <CompactingLabel key="compacting" onLayout={props.onLayout} />; } if (props.status.kind === "connection") { return ( <StatusLabelRow key="connection" accessibilityLabel={props.status.label} accessibilityRole="button" className="gap-2" onLayout={props.onLayout} onPress={props.status.onPress} > {props.status.tone === "reconnecting" ? ( <ActivityIndicator size="small" colorClassName="accent-icon-muted" /> ) : ( <View className="h-2 w-2 rounded-full bg-red-500" /> )} <Text className="max-w-[260px] shrink font-t3-medium text-xs text-foreground" numberOfLines={1} > {props.status.label} </Text> </StatusLabelRow> ); } if (props.status.kind === "preparing") { return ( <StatusLabelRow key="preparing" accessibilityLabel={props.status.label} className="gap-1.5" onLayout={props.onLayout} > <SymbolView name="arrow.triangle.branch" size={13} tintColorClassName="foreground" type="monochrome" /> <ShimmeringWorkContent className="flex-none" textClassName="font-t3-medium" compact icon="arrow.triangle.branch" iconSubtleColor="transparent" label={props.status.label} showIcon={false} /> </StatusLabelRow> ); } return ( <WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} /> );} // 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 ( <Animated.View className="absolute max-w-full" entering={LABEL_ENTERING} exiting={LABEL_EXITING} onLayout={props.onLayout} > {props.onPress ? ( <Pressable accessibilityLabel={props.accessibilityLabel} accessibilityRole={props.accessibilityRole} className={`${rowClassName} active:opacity-70`} onPress={props.onPress} > {props.children} </Pressable> ) : ( <View accessible accessibilityLabel={props.accessibilityLabel} className={rowClassName}> {props.children} </View> )} </Animated.View> );} 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 ( <StatusLabelRow accessibilityLabel={label} onLayout={props.onLayout}> <Text className="font-t3-medium text-xs text-foreground">Working for </Text> <SystemText className="text-xs text-foreground" style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }} > {duration} </SystemText> </StatusLabelRow> );} 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 ( <ControlPill accessibilityLabel="Scroll to end" activateOnPressIn className="h-11 w-11 bg-transparent" disabled={props.disabled} icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }} onPress={props.onPress} /> );}
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<number | null>(null);
const measuredWidthRef = useRef<number | null>(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 ? (
<>
<Animated.View className="h-11" style={capsuleSizerStyle} />
<View
pointerEvents="box-none"
className="absolute h-11 items-center justify-center"
style={{ width: labelWidth }}
>
<FloatingStatusLabel
key={
props.status.kind === "working" || props.status.kind === "compacting"
? props.status.kind
: `${props.status.kind}:${props.status.label}`
}
status={props.status}
onLayout={handleLabelLayout}
/>
</View>
</>
) : null;
return (
<Animated.View
pointerEvents="box-none"
className="absolute left-0 right-0 z-20 items-center"
style={{ top: -CONTROL_OVERLAY_OFFSET }}
onLayout={(event) => 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 ? (
<UniwindGlassContainer
spacing={GLASS_MERGE_SPACING}
pointerEvents="box-none"
className="flex-row items-center gap-4"
>
<AnimatedGlassView
colorScheme={props.colorScheme}
glassEffectStyle="regular"
isInteractive={statusInteractive}
pointerEvents={statusInteractive ? "box-none" : "none"}
className="h-11 items-center justify-center overflow-hidden rounded-full"
style={capsuleStyle}
>
{statusContent}
</AnimatedGlassView>
<AnimatedGlassView
colorScheme={props.colorScheme}
glassEffectStyle="regular"
isInteractive
pointerEvents={props.showScrollToEnd ? "auto" : "none"}
accessibilityElementsHidden={!props.showScrollToEnd}
importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
style={arrowTransformStyle}
>
<Animated.View style={arrowContentStyle}>
<ScrollToEndButton disabled={!props.showScrollToEnd} onPress={props.onScrollToEnd} />
</Animated.View>
</AnimatedGlassView>
</UniwindGlassContainer>
) : props.status !== null ? (
<View pointerEvents="box-none" className="flex-row items-center gap-4">
<Animated.View
pointerEvents={statusInteractive ? "box-none" : "none"}
className="h-11 items-center justify-center overflow-hidden rounded-full border border-border bg-card shadow-md shadow-black/10"
style={capsuleStyle}
>
{statusContent}
</Animated.View>
<Animated.View
pointerEvents={props.showScrollToEnd ? "auto" : "none"}
accessibilityElementsHidden={!props.showScrollToEnd}
importantForAccessibility={props.showScrollToEnd ? "auto" : "no-hide-descendants"}
style={[arrowTransformStyle, arrowContentStyle]}
>
<ControlPill
accessibilityLabel="Scroll to end"
activateOnPressIn
className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
disabled={!props.showScrollToEnd}
icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
onPress={props.onScrollToEnd}
/>
</Animated.View>
</View>
) : NATIVE_LIQUID_GLASS_SUPPORTED ? (
<UniwindGlassView
colorScheme={props.colorScheme}
glassEffectStyle="regular"
isInteractive
className="h-11 w-11 items-center justify-center overflow-hidden rounded-full"
>
<ScrollToEndButton onPress={props.onScrollToEnd} />
</UniwindGlassView>
) : (
<ControlPill
accessibilityLabel="Scroll to end"
activateOnPressIn
className="h-11 w-11 border border-border bg-card shadow-md shadow-black/10"
icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
onPress={props.onScrollToEnd}
/>
)}
</Animated.View>
);
}
function CompactingLabel(props: { readonly onLayout: (event: LayoutChangeEvent) => void }) {
return (
<StatusLabelRow accessibilityLabel="Compacting" className="gap-1.5" onLayout={props.onLayout}>
<SymbolView
name="arrow.down.right.and.arrow.up.left"
size={13}
tintColorClassName="foreground"
type="monochrome"
/>
<Text className="font-t3-medium text-xs text-foreground">Compacting…</Text>
</StatusLabelRow>
);
}
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 (
<StatusLabelRow
key="syncing"
accessibilityLabel={props.status.label}
className="gap-2"
onLayout={props.onLayout}
>
<ActivityIndicator size="small" colorClassName="accent-icon-muted" />
<Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "compacting") {
return <CompactingLabel key="compacting" onLayout={props.onLayout} />;
}
if (props.status.kind === "connection") {
return (
<StatusLabelRow
key="connection"
accessibilityLabel={props.status.label}
accessibilityRole="button"
className="gap-2"
onLayout={props.onLayout}
onPress={props.status.onPress}
>
{props.status.tone === "reconnecting" ? (
<ActivityIndicator size="small" colorClassName="accent-icon-muted" />
) : (
<View className="h-2 w-2 rounded-full bg-red-500" />
)}
<Text
className="max-w-[260px] shrink font-t3-medium text-xs text-foreground"
numberOfLines={1}
>
{props.status.label}
</Text>
</StatusLabelRow>
);
}
if (props.status.kind === "preparing") {
return (
<StatusLabelRow
key="preparing"
accessibilityLabel={props.status.label}
className="gap-1.5"
onLayout={props.onLayout}
>
<SymbolView
name="arrow.triangle.branch"
size={13}
tintColorClassName="foreground"
type="monochrome"
/>
<ShimmeringWorkContent
className="flex-none"
textClassName="font-t3-medium"
compact
icon="arrow.triangle.branch"
iconSubtleColor="transparent"
label={props.status.label}
showIcon={false}
/>
</StatusLabelRow>
);
}
return (
<WorkingDuration key="working" startedAt={props.status.startedAt} onLayout={props.onLayout} />
);
}
// 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 (
<Animated.View
className="absolute max-w-full"
entering={LABEL_ENTERING}
exiting={LABEL_EXITING}
onLayout={props.onLayout}
>
{props.onPress ? (
<Pressable
accessibilityLabel={props.accessibilityLabel}
accessibilityRole={props.accessibilityRole}
className={`${rowClassName} active:opacity-70`}
onPress={props.onPress}
>
{props.children}
</Pressable>
) : (
<View accessible accessibilityLabel={props.accessibilityLabel} className={rowClassName}>
{props.children}
</View>
)}
</Animated.View>
);
}
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 (
<StatusLabelRow accessibilityLabel={label} onLayout={props.onLayout}>
<Text className="font-t3-medium text-xs text-foreground">Working for </Text>
<SystemText
className="text-xs text-foreground"
style={{ fontVariant: ["tabular-nums"], fontWeight: "500" }}
>
{duration}
</SystemText>
</StatusLabelRow>
);
}
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 (
<ControlPill
accessibilityLabel="Scroll to end"
activateOnPressIn
className="h-11 w-11 bg-transparent"
disabled={props.disabled}
icon={{ ios: "chevron.down", android: "keyboard_arrow_down" }}
onPress={props.onPress}
/>
);
}