react-native-ai-animations 0.0.0-stage → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/lib/module/brand/index.js +4 -0
- package/lib/module/brand/index.js.map +1 -0
- package/lib/module/brand/spinning-mark.js +96 -0
- package/lib/module/brand/spinning-mark.js.map +1 -0
- package/lib/module/chat/effort-bar.js +853 -0
- package/lib/module/chat/effort-bar.js.map +1 -0
- package/lib/module/chat/index.js +8 -0
- package/lib/module/chat/index.js.map +1 -0
- package/lib/module/chat/question-card.js +720 -0
- package/lib/module/chat/question-card.js.map +1 -0
- package/lib/module/chat/reasoning.js +38 -0
- package/lib/module/chat/reasoning.js.map +1 -0
- package/lib/module/chat/status-row.js +140 -0
- package/lib/module/chat/status-row.js.map +1 -0
- package/lib/module/chat/thinking-block.js +255 -0
- package/lib/module/chat/thinking-block.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/internal/color.js +15 -0
- package/lib/module/internal/color.js.map +1 -0
- package/lib/module/internal/use-elapsed.js +20 -0
- package/lib/module/internal/use-elapsed.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/skia/dot-orb.js +256 -0
- package/lib/module/skia/dot-orb.js.map +1 -0
- package/lib/module/skia/index.js +8 -0
- package/lib/module/skia/index.js.map +1 -0
- package/lib/module/skia/liquid-orb.js +303 -0
- package/lib/module/skia/liquid-orb.js.map +1 -0
- package/lib/module/skia/matrix-orb.js +272 -0
- package/lib/module/skia/matrix-orb.js.map +1 -0
- package/lib/module/skia/media-generating.js +524 -0
- package/lib/module/skia/media-generating.js.map +1 -0
- package/lib/module/skia/voice-wave.js +278 -0
- package/lib/module/skia/voice-wave.js.map +1 -0
- package/lib/module/text/index.js +6 -0
- package/lib/module/text/index.js.map +1 -0
- package/lib/module/text/shimmer-text.js +122 -0
- package/lib/module/text/shimmer-text.js.map +1 -0
- package/lib/module/text/spoken-text.js +85 -0
- package/lib/module/text/spoken-text.js.map +1 -0
- package/lib/module/text/streaming-text.js +135 -0
- package/lib/module/text/streaming-text.js.map +1 -0
- package/lib/module/theme/index.js +5 -0
- package/lib/module/theme/index.js.map +1 -0
- package/lib/module/theme/palette.js +45 -0
- package/lib/module/theme/palette.js.map +1 -0
- package/lib/module/theme/provider.js +65 -0
- package/lib/module/theme/provider.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/brand/index.d.ts +2 -0
- package/lib/typescript/src/brand/index.d.ts.map +1 -0
- package/lib/typescript/src/brand/spinning-mark.d.ts +21 -0
- package/lib/typescript/src/brand/spinning-mark.d.ts.map +1 -0
- package/lib/typescript/src/chat/effort-bar.d.ts +42 -0
- package/lib/typescript/src/chat/effort-bar.d.ts.map +1 -0
- package/lib/typescript/src/chat/index.d.ts +6 -0
- package/lib/typescript/src/chat/index.d.ts.map +1 -0
- package/lib/typescript/src/chat/question-card.d.ts +73 -0
- package/lib/typescript/src/chat/question-card.d.ts.map +1 -0
- package/lib/typescript/src/chat/reasoning.d.ts +20 -0
- package/lib/typescript/src/chat/reasoning.d.ts.map +1 -0
- package/lib/typescript/src/chat/status-row.d.ts +20 -0
- package/lib/typescript/src/chat/status-row.d.ts.map +1 -0
- package/lib/typescript/src/chat/thinking-block.d.ts +35 -0
- package/lib/typescript/src/chat/thinking-block.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +3 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/internal/color.d.ts +2 -0
- package/lib/typescript/src/internal/color.d.ts.map +1 -0
- package/lib/typescript/src/internal/use-elapsed.d.ts +3 -0
- package/lib/typescript/src/internal/use-elapsed.d.ts.map +1 -0
- package/lib/typescript/src/skia/dot-orb.d.ts +16 -0
- package/lib/typescript/src/skia/dot-orb.d.ts.map +1 -0
- package/lib/typescript/src/skia/index.d.ts +6 -0
- package/lib/typescript/src/skia/index.d.ts.map +1 -0
- package/lib/typescript/src/skia/liquid-orb.d.ts +20 -0
- package/lib/typescript/src/skia/liquid-orb.d.ts.map +1 -0
- package/lib/typescript/src/skia/matrix-orb.d.ts +12 -0
- package/lib/typescript/src/skia/matrix-orb.d.ts.map +1 -0
- package/lib/typescript/src/skia/media-generating.d.ts +59 -0
- package/lib/typescript/src/skia/media-generating.d.ts.map +1 -0
- package/lib/typescript/src/skia/voice-wave.d.ts +17 -0
- package/lib/typescript/src/skia/voice-wave.d.ts.map +1 -0
- package/lib/typescript/src/text/index.d.ts +4 -0
- package/lib/typescript/src/text/index.d.ts.map +1 -0
- package/lib/typescript/src/text/shimmer-text.d.ts +13 -0
- package/lib/typescript/src/text/shimmer-text.d.ts.map +1 -0
- package/lib/typescript/src/text/spoken-text.d.ts +15 -0
- package/lib/typescript/src/text/spoken-text.d.ts.map +1 -0
- package/lib/typescript/src/text/streaming-text.d.ts +16 -0
- package/lib/typescript/src/text/streaming-text.d.ts.map +1 -0
- package/lib/typescript/src/theme/index.d.ts +3 -0
- package/lib/typescript/src/theme/index.d.ts.map +1 -0
- package/lib/typescript/src/theme/palette.d.ts +33 -0
- package/lib/typescript/src/theme/palette.d.ts.map +1 -0
- package/lib/typescript/src/theme/provider.d.ts +13 -0
- package/lib/typescript/src/theme/provider.d.ts.map +1 -0
- package/package.json +133 -4
- package/src/brand/index.ts +5 -0
- package/src/brand/spinning-mark.tsx +118 -0
- package/src/chat/effort-bar.tsx +748 -0
- package/src/chat/index.ts +45 -0
- package/src/chat/question-card.tsx +649 -0
- package/src/chat/reasoning.ts +30 -0
- package/src/chat/status-row.tsx +127 -0
- package/src/chat/thinking-block.tsx +250 -0
- package/src/index.ts +2 -0
- package/src/internal/color.ts +18 -0
- package/src/internal/use-elapsed.ts +21 -0
- package/src/skia/dot-orb.tsx +248 -0
- package/src/skia/index.ts +34 -0
- package/src/skia/liquid-orb.tsx +274 -0
- package/src/skia/matrix-orb.tsx +337 -0
- package/src/skia/media-generating.tsx +389 -0
- package/src/skia/voice-wave.tsx +215 -0
- package/src/text/index.ts +3 -0
- package/src/text/shimmer-text.tsx +140 -0
- package/src/text/spoken-text.tsx +82 -0
- package/src/text/streaming-text.tsx +144 -0
- package/src/theme/index.ts +11 -0
- package/src/theme/palette.ts +62 -0
- package/src/theme/provider.tsx +54 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type ReasoningEffort = "low" | "medium" | "high";
|
|
2
|
+
|
|
3
|
+
export type ReasoningLevel = {
|
|
4
|
+
label: string;
|
|
5
|
+
value: ReasoningEffort;
|
|
6
|
+
budget: number;
|
|
7
|
+
description: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const REASONING_EFFORTS: ReasoningLevel[] = [
|
|
11
|
+
{ label: "Low", value: "low", budget: 0.2, description: "About 20% of max tokens go to reasoning. Fastest replies." },
|
|
12
|
+
{ label: "Medium", value: "medium", budget: 0.5, description: "About half of max tokens go to reasoning. The default." },
|
|
13
|
+
{ label: "High", value: "high", budget: 0.8, description: "About 80% of max tokens go to reasoning. Slowest, most thorough." },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "medium";
|
|
17
|
+
|
|
18
|
+
export function reasoningRequest(effort: ReasoningEffort, options?: { exclude?: boolean }) {
|
|
19
|
+
return { reasoning: { effort, ...(options?.exclude ? { exclude: true } : {}) } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function reasoningBudget(effort: ReasoningEffort, maxTokens: number) {
|
|
23
|
+
const level = REASONING_EFFORTS.find((item) => item.value === effort) ?? REASONING_EFFORTS[1];
|
|
24
|
+
return Math.max(Math.min(Math.floor(maxTokens * level.budget), 128000), 1024);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function reasoningIndex(effort: ReasoningEffort) {
|
|
28
|
+
const index = REASONING_EFFORTS.findIndex((item) => item.value === effort);
|
|
29
|
+
return index < 0 ? 1 : index;
|
|
30
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
cancelAnimation,
|
|
5
|
+
Easing,
|
|
6
|
+
FadeInDown,
|
|
7
|
+
FadeOutUp,
|
|
8
|
+
useAnimatedStyle,
|
|
9
|
+
useReducedMotion,
|
|
10
|
+
useSharedValue,
|
|
11
|
+
withRepeat,
|
|
12
|
+
withSequence,
|
|
13
|
+
withTiming,
|
|
14
|
+
} from "react-native-reanimated";
|
|
15
|
+
import { formatElapsed, useElapsed } from "../internal/use-elapsed";
|
|
16
|
+
import { ShimmerText } from "../text/shimmer-text";
|
|
17
|
+
import { type MotionPalette } from "../theme/palette";
|
|
18
|
+
import { useMotionPalette } from "../theme/provider";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 14, lineHeight: 20, fontWeight: "500" };
|
|
21
|
+
|
|
22
|
+
export type StatusRowIconMotion = "pulse" | "spin" | "none";
|
|
23
|
+
|
|
24
|
+
export type StatusRowProps = {
|
|
25
|
+
label: string;
|
|
26
|
+
icon?: ReactNode;
|
|
27
|
+
iconMotion?: StatusRowIconMotion;
|
|
28
|
+
iconSize?: number;
|
|
29
|
+
active?: boolean;
|
|
30
|
+
startedAt?: number;
|
|
31
|
+
showTimer?: boolean;
|
|
32
|
+
formatTimer?: (seconds: number) => string;
|
|
33
|
+
textStyle?: StyleProp<TextStyle>;
|
|
34
|
+
timerStyle?: StyleProp<TextStyle>;
|
|
35
|
+
colors?: Partial<MotionPalette>;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function StatusRow({
|
|
40
|
+
label,
|
|
41
|
+
icon,
|
|
42
|
+
iconMotion = "pulse",
|
|
43
|
+
iconSize = 18,
|
|
44
|
+
active = true,
|
|
45
|
+
startedAt,
|
|
46
|
+
showTimer = true,
|
|
47
|
+
formatTimer = formatElapsed,
|
|
48
|
+
textStyle = DEFAULT_TEXT_STYLE,
|
|
49
|
+
timerStyle,
|
|
50
|
+
colors,
|
|
51
|
+
style,
|
|
52
|
+
}: StatusRowProps) {
|
|
53
|
+
const palette = useMotionPalette(colors);
|
|
54
|
+
const reduceMotion = useReducedMotion();
|
|
55
|
+
const seconds = useElapsed(startedAt, active);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<View
|
|
59
|
+
accessible
|
|
60
|
+
accessibilityRole="progressbar"
|
|
61
|
+
accessibilityLabel={label}
|
|
62
|
+
accessibilityState={{ busy: active }}
|
|
63
|
+
style={[{ flexDirection: "row", alignItems: "center", gap: 8 }, style]}
|
|
64
|
+
>
|
|
65
|
+
<StatusIcon size={iconSize} motion={active && !reduceMotion ? iconMotion : "none"}>
|
|
66
|
+
{icon ?? <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: palette.accent }} />}
|
|
67
|
+
</StatusIcon>
|
|
68
|
+
<View style={{ flexShrink: 1, overflow: "hidden" }}>
|
|
69
|
+
<Animated.View
|
|
70
|
+
key={label}
|
|
71
|
+
entering={reduceMotion ? undefined : FadeInDown.duration(240)}
|
|
72
|
+
exiting={reduceMotion ? undefined : FadeOutUp.duration(180)}
|
|
73
|
+
>
|
|
74
|
+
{active ? (
|
|
75
|
+
<ShimmerText
|
|
76
|
+
text={label}
|
|
77
|
+
activeColor={palette.text}
|
|
78
|
+
mutedColor={palette.textTertiary}
|
|
79
|
+
textStyle={textStyle}
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<Text style={[textStyle, { color: palette.textSecondary }]}>{label}</Text>
|
|
83
|
+
)}
|
|
84
|
+
</Animated.View>
|
|
85
|
+
</View>
|
|
86
|
+
{showTimer && startedAt !== undefined && (
|
|
87
|
+
<Text style={[textStyle, { color: palette.textTertiary, fontVariant: ["tabular-nums"] }, timerStyle]}>
|
|
88
|
+
{formatTimer(seconds)}
|
|
89
|
+
</Text>
|
|
90
|
+
)}
|
|
91
|
+
</View>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function StatusIcon({ size, motion, children }: { size: number; motion: StatusRowIconMotion; children: ReactNode }) {
|
|
96
|
+
const progress = useSharedValue(0);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
cancelAnimation(progress);
|
|
100
|
+
progress.value = 0;
|
|
101
|
+
if (motion === "pulse") {
|
|
102
|
+
const curve = Easing.inOut(Easing.sin);
|
|
103
|
+
progress.value = withRepeat(
|
|
104
|
+
withSequence(withTiming(1, { duration: 700, easing: curve }), withTiming(0, { duration: 700, easing: curve })),
|
|
105
|
+
-1,
|
|
106
|
+
false,
|
|
107
|
+
);
|
|
108
|
+
} else if (motion === "spin") {
|
|
109
|
+
progress.value = withRepeat(withTiming(1, { duration: 1200, easing: Easing.linear }), -1, false);
|
|
110
|
+
}
|
|
111
|
+
return () => cancelAnimation(progress);
|
|
112
|
+
}, [motion, progress]);
|
|
113
|
+
|
|
114
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
115
|
+
if (motion === "pulse") {
|
|
116
|
+
return { opacity: 0.45 + progress.value * 0.55, transform: [{ scale: 0.88 + progress.value * 0.12 }] };
|
|
117
|
+
}
|
|
118
|
+
if (motion === "spin") return { opacity: 1, transform: [{ rotate: `${progress.value * 360}deg` }] };
|
|
119
|
+
return { opacity: 1, transform: [] };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<Animated.View style={[{ width: size, height: size, alignItems: "center", justifyContent: "center" }, animatedStyle]}>
|
|
124
|
+
{children}
|
|
125
|
+
</Animated.View>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
type LayoutChangeEvent,
|
|
4
|
+
Pressable,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type TextStyle,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import Animated, {
|
|
12
|
+
cancelAnimation,
|
|
13
|
+
Easing,
|
|
14
|
+
FadeIn,
|
|
15
|
+
FadeInDown,
|
|
16
|
+
useAnimatedStyle,
|
|
17
|
+
useReducedMotion,
|
|
18
|
+
useSharedValue,
|
|
19
|
+
withRepeat,
|
|
20
|
+
withSequence,
|
|
21
|
+
withSpring,
|
|
22
|
+
withTiming,
|
|
23
|
+
} from "react-native-reanimated";
|
|
24
|
+
import { useElapsed } from "../internal/use-elapsed";
|
|
25
|
+
import { ShimmerText } from "../text/shimmer-text";
|
|
26
|
+
import { type MotionPalette } from "../theme/palette";
|
|
27
|
+
import { useMotionPalette } from "../theme/provider";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TEXT_STYLE: TextStyle = { fontSize: 14, lineHeight: 20, fontWeight: "500" };
|
|
30
|
+
const DEFAULT_DETAIL_STYLE: TextStyle = { fontSize: 14, lineHeight: 21 };
|
|
31
|
+
const OPEN_SPRING = { damping: 22, stiffness: 210, mass: 0.9 };
|
|
32
|
+
const DOT = 7;
|
|
33
|
+
|
|
34
|
+
export type ThinkingStepStatus = "done" | "active" | "pending";
|
|
35
|
+
|
|
36
|
+
export type ThinkingStep = {
|
|
37
|
+
key?: string;
|
|
38
|
+
title: string;
|
|
39
|
+
detail?: string;
|
|
40
|
+
status?: ThinkingStepStatus;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type ThinkingBlockLabels = {
|
|
44
|
+
thinking: string;
|
|
45
|
+
done: (seconds: number) => string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type ThinkingBlockProps = {
|
|
49
|
+
thinking: boolean;
|
|
50
|
+
startedAt?: number;
|
|
51
|
+
seconds?: number;
|
|
52
|
+
title?: string;
|
|
53
|
+
labels?: Partial<ThinkingBlockLabels>;
|
|
54
|
+
icon?: ReactNode;
|
|
55
|
+
chevron?: ReactNode;
|
|
56
|
+
steps?: ThinkingStep[];
|
|
57
|
+
renderStep?: (step: ThinkingStep, index: number) => ReactNode;
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
open?: boolean;
|
|
60
|
+
defaultOpen?: boolean;
|
|
61
|
+
onOpenChange?: (open: boolean) => void;
|
|
62
|
+
textStyle?: StyleProp<TextStyle>;
|
|
63
|
+
detailStyle?: StyleProp<TextStyle>;
|
|
64
|
+
colors?: Partial<MotionPalette>;
|
|
65
|
+
style?: StyleProp<ViewStyle>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const DEFAULT_LABELS: ThinkingBlockLabels = {
|
|
69
|
+
thinking: "Thinking",
|
|
70
|
+
done: (seconds) => (seconds < 1 ? "Thought for a moment" : `Thought for ${seconds}s`),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function ThinkingBlock({
|
|
74
|
+
thinking,
|
|
75
|
+
startedAt,
|
|
76
|
+
seconds,
|
|
77
|
+
title,
|
|
78
|
+
labels,
|
|
79
|
+
icon,
|
|
80
|
+
chevron,
|
|
81
|
+
steps,
|
|
82
|
+
renderStep,
|
|
83
|
+
children,
|
|
84
|
+
open: controlledOpen,
|
|
85
|
+
defaultOpen = false,
|
|
86
|
+
onOpenChange,
|
|
87
|
+
textStyle = DEFAULT_TEXT_STYLE,
|
|
88
|
+
detailStyle = DEFAULT_DETAIL_STYLE,
|
|
89
|
+
colors,
|
|
90
|
+
style,
|
|
91
|
+
}: ThinkingBlockProps) {
|
|
92
|
+
const palette = useMotionPalette(colors);
|
|
93
|
+
const reduceMotion = useReducedMotion();
|
|
94
|
+
const copy = { ...DEFAULT_LABELS, ...labels };
|
|
95
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
96
|
+
const open = controlledOpen ?? uncontrolledOpen;
|
|
97
|
+
const elapsed = useElapsed(startedAt, thinking);
|
|
98
|
+
const frozen = useRef(0);
|
|
99
|
+
if (thinking) frozen.current = elapsed;
|
|
100
|
+
const totalSeconds = seconds ?? frozen.current;
|
|
101
|
+
const hasBody = !!children || (steps?.length ?? 0) > 0;
|
|
102
|
+
|
|
103
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
104
|
+
const height = useSharedValue(0);
|
|
105
|
+
const turn = useSharedValue(open ? 1 : 0);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const target = open ? contentHeight : 0;
|
|
109
|
+
height.value = reduceMotion ? target : withSpring(target, OPEN_SPRING);
|
|
110
|
+
turn.value = reduceMotion ? (open ? 1 : 0) : withTiming(open ? 1 : 0, { duration: 220 });
|
|
111
|
+
}, [contentHeight, height, open, reduceMotion, turn]);
|
|
112
|
+
|
|
113
|
+
const toggle = () => {
|
|
114
|
+
if (!hasBody) return;
|
|
115
|
+
const next = !open;
|
|
116
|
+
if (controlledOpen === undefined) setUncontrolledOpen(next);
|
|
117
|
+
onOpenChange?.(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const bodyStyle = useAnimatedStyle(() => ({ height: height.value, opacity: Math.min(1, height.value / 24) }));
|
|
121
|
+
const chevronStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${turn.value * 90}deg` }] }));
|
|
122
|
+
|
|
123
|
+
const handleContentLayout = (event: LayoutChangeEvent) => {
|
|
124
|
+
const next = Math.ceil(event.nativeEvent.layout.height);
|
|
125
|
+
if (next !== contentHeight) setContentHeight(next);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const heading = title ?? (thinking ? copy.thinking : copy.done(totalSeconds));
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<View style={style}>
|
|
132
|
+
<Pressable
|
|
133
|
+
onPress={toggle}
|
|
134
|
+
disabled={!hasBody}
|
|
135
|
+
accessibilityRole="button"
|
|
136
|
+
accessibilityLabel={heading}
|
|
137
|
+
accessibilityState={{ expanded: open, busy: thinking }}
|
|
138
|
+
hitSlop={8}
|
|
139
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8, alignSelf: "flex-start" }}
|
|
140
|
+
>
|
|
141
|
+
{icon !== undefined && <View style={{ alignItems: "center", justifyContent: "center" }}>{icon}</View>}
|
|
142
|
+
<Animated.View key={thinking ? "thinking" : "done"} entering={reduceMotion ? undefined : FadeIn.duration(220)}>
|
|
143
|
+
{thinking && !reduceMotion ? (
|
|
144
|
+
<ShimmerText
|
|
145
|
+
text={thinking && startedAt !== undefined ? `${heading} ${elapsed}s` : heading}
|
|
146
|
+
activeColor={palette.text}
|
|
147
|
+
mutedColor={palette.textTertiary}
|
|
148
|
+
textStyle={textStyle}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<Text style={[textStyle, { color: palette.textSecondary }]}>{heading}</Text>
|
|
152
|
+
)}
|
|
153
|
+
</Animated.View>
|
|
154
|
+
{hasBody && (
|
|
155
|
+
<Animated.View style={chevronStyle}>
|
|
156
|
+
{chevron ?? <Text style={[textStyle, { color: palette.textTertiary }]}>›</Text>}
|
|
157
|
+
</Animated.View>
|
|
158
|
+
)}
|
|
159
|
+
</Pressable>
|
|
160
|
+
{hasBody && (
|
|
161
|
+
<Animated.View style={[{ overflow: "hidden" }, bodyStyle]}>
|
|
162
|
+
<View
|
|
163
|
+
onLayout={handleContentLayout}
|
|
164
|
+
style={{ position: "absolute", left: 0, right: 0, top: 0, paddingTop: 10 }}
|
|
165
|
+
>
|
|
166
|
+
{steps && steps.length > 0 && (
|
|
167
|
+
<View>
|
|
168
|
+
{steps.map((step, index) => (
|
|
169
|
+
<Animated.View
|
|
170
|
+
key={step.key ?? `${index}`}
|
|
171
|
+
entering={reduceMotion ? undefined : FadeInDown.duration(260).delay(index * 40)}
|
|
172
|
+
>
|
|
173
|
+
{renderStep ? (
|
|
174
|
+
renderStep(step, index)
|
|
175
|
+
) : (
|
|
176
|
+
<TimelineStep
|
|
177
|
+
step={step}
|
|
178
|
+
last={index === steps.length - 1}
|
|
179
|
+
palette={palette}
|
|
180
|
+
textStyle={textStyle}
|
|
181
|
+
detailStyle={detailStyle}
|
|
182
|
+
animate={!reduceMotion}
|
|
183
|
+
/>
|
|
184
|
+
)}
|
|
185
|
+
</Animated.View>
|
|
186
|
+
))}
|
|
187
|
+
</View>
|
|
188
|
+
)}
|
|
189
|
+
{children}
|
|
190
|
+
</View>
|
|
191
|
+
</Animated.View>
|
|
192
|
+
)}
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
type TimelineStepProps = {
|
|
198
|
+
step: ThinkingStep;
|
|
199
|
+
last: boolean;
|
|
200
|
+
palette: MotionPalette;
|
|
201
|
+
textStyle: StyleProp<TextStyle>;
|
|
202
|
+
detailStyle: StyleProp<TextStyle>;
|
|
203
|
+
animate: boolean;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
function TimelineStep({ step, last, palette, textStyle, detailStyle, animate }: TimelineStepProps) {
|
|
207
|
+
const status = step.status ?? "done";
|
|
208
|
+
return (
|
|
209
|
+
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
210
|
+
<View style={{ width: DOT, alignItems: "center" }}>
|
|
211
|
+
<TimelineDot status={status} palette={palette} animate={animate} />
|
|
212
|
+
{!last && <View style={{ flex: 1, width: 1, marginVertical: 4, backgroundColor: palette.raised }} />}
|
|
213
|
+
</View>
|
|
214
|
+
<View style={{ flex: 1, paddingBottom: last ? 0 : 14, marginTop: -6 }}>
|
|
215
|
+
<Text style={[textStyle, { color: status === "pending" ? palette.textTertiary : palette.text }]}>{step.title}</Text>
|
|
216
|
+
{step.detail ? (
|
|
217
|
+
<Text style={[detailStyle, { color: palette.textSecondary, marginTop: 2 }]}>{step.detail}</Text>
|
|
218
|
+
) : null}
|
|
219
|
+
</View>
|
|
220
|
+
</View>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function TimelineDot({ status, palette, animate }: { status: ThinkingStepStatus; palette: MotionPalette; animate: boolean }) {
|
|
225
|
+
const pulse = useSharedValue(1);
|
|
226
|
+
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
if (status !== "active" || !animate) {
|
|
229
|
+
cancelAnimation(pulse);
|
|
230
|
+
pulse.value = 1;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const curve = Easing.inOut(Easing.sin);
|
|
234
|
+
pulse.value = withRepeat(
|
|
235
|
+
withSequence(withTiming(0.35, { duration: 650, easing: curve }), withTiming(1, { duration: 650, easing: curve })),
|
|
236
|
+
-1,
|
|
237
|
+
false,
|
|
238
|
+
);
|
|
239
|
+
return () => cancelAnimation(pulse);
|
|
240
|
+
}, [animate, pulse, status]);
|
|
241
|
+
|
|
242
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: pulse.value }));
|
|
243
|
+
const color = status === "pending" ? palette.raised : status === "active" ? palette.accent : palette.textTertiary;
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<Animated.View
|
|
247
|
+
style={[{ width: DOT, height: DOT, borderRadius: DOT / 2, backgroundColor: color, marginTop: 1 }, animatedStyle]}
|
|
248
|
+
/>
|
|
249
|
+
);
|
|
250
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
2
|
+
if (color.startsWith("rgb")) {
|
|
3
|
+
const parts = color.replace(/rgba?\(|\)/g, "").split(",").map((part) => part.trim());
|
|
4
|
+
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
|
|
5
|
+
}
|
|
6
|
+
const hex = color.replace("#", "");
|
|
7
|
+
const full =
|
|
8
|
+
hex.length === 3
|
|
9
|
+
? hex
|
|
10
|
+
.split("")
|
|
11
|
+
.map((char) => char + char)
|
|
12
|
+
.join("")
|
|
13
|
+
: hex.slice(0, 6);
|
|
14
|
+
const r = parseInt(full.slice(0, 2), 16);
|
|
15
|
+
const g = parseInt(full.slice(2, 4), 16);
|
|
16
|
+
const b = parseInt(full.slice(4, 6), 16);
|
|
17
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function useElapsed(startedAt: number | undefined, running: boolean, interval = 1000) {
|
|
4
|
+
const [now, setNow] = useState(() => Date.now());
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
if (!running || startedAt === undefined) return;
|
|
8
|
+
setNow(Date.now());
|
|
9
|
+
const id = setInterval(() => setNow(Date.now()), interval);
|
|
10
|
+
return () => clearInterval(id);
|
|
11
|
+
}, [interval, running, startedAt]);
|
|
12
|
+
|
|
13
|
+
if (startedAt === undefined) return 0;
|
|
14
|
+
return Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatElapsed(seconds: number) {
|
|
18
|
+
const minutes = Math.floor(seconds / 60);
|
|
19
|
+
const rest = seconds % 60;
|
|
20
|
+
return minutes > 0 ? `${minutes}:${String(rest).padStart(2, "0")}` : `${rest}s`;
|
|
21
|
+
}
|