@vvedantb/native-ui 0.3.16 → 0.4.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.
@@ -0,0 +1,146 @@
1
+ import { createContext, useContext, type ComponentType } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+ import {
4
+ IconCopy,
5
+ IconRefresh,
6
+ IconThumbDown,
7
+ IconThumbUp,
8
+ type IconProps,
9
+ } from "@tabler/icons-react-native";
10
+ import { cn } from "../cn";
11
+ import { palette } from "../palette";
12
+ import { tokens } from "../tokens";
13
+ import { useScheme } from "../useScheme";
14
+ import { ChatIconButton } from "./icon-button";
15
+ import { ChatTextColorContext, ResponseText } from "./response-text";
16
+
17
+ export type MessageRole = "user" | "assistant" | "system";
18
+
19
+ const MessageContext = createContext<MessageRole>("assistant");
20
+
21
+ export type MessageProps = ViewProps & {
22
+ from: MessageRole;
23
+ className?: string;
24
+ };
25
+
26
+ /**
27
+ * One message: the web `w-full max-w-[95%] flex-col gap-2`. A user message
28
+ * sits at the end of the row with its parts at the end; an assistant message
29
+ * at the start, its parts full width.
30
+ */
31
+ export function Message({ from, className, style, ...props }: MessageProps) {
32
+ const user = from === "user";
33
+ return (
34
+ <MessageContext.Provider value={from}>
35
+ <View
36
+ className={cn("w-full max-w-[95%] gap-2", className)}
37
+ style={[
38
+ { alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
39
+ style,
40
+ ]}
41
+ {...props}
42
+ />
43
+ </MessageContext.Provider>
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The web user bubble: `rounded-2xl rounded-br-md bg-primary` (radius 16 with
49
+ * the bottom-right corner at 14, `tokens.radius.md`).
50
+ */
51
+ export function userBubbleStyle(scheme: "light" | "dark") {
52
+ return {
53
+ backgroundColor: palette.primary[scheme],
54
+ borderRadius: tokens.radius.lg,
55
+ borderBottomRightRadius: tokens.radius.md,
56
+ };
57
+ }
58
+
59
+ export type MessageContentProps = ViewProps & { className?: string };
60
+
61
+ /**
62
+ * The message body. A user message is a filled bubble (`px-4 py-3`, the
63
+ * primary fill and label colour); an assistant message has no fill and the
64
+ * foreground colour. A string child is drawn as `ResponseText`, and
65
+ * `ResponseText` inside takes the colour.
66
+ */
67
+ export function MessageContent({ children, className, style, ...props }: MessageContentProps) {
68
+ const from = useContext(MessageContext);
69
+ const scheme = useScheme();
70
+ const user = from === "user";
71
+ const color = user ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
72
+ return (
73
+ <ChatTextColorContext.Provider value={color}>
74
+ <View
75
+ className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
76
+ style={[user ? userBubbleStyle(scheme) : null, style]}
77
+ {...props}
78
+ >
79
+ {typeof children === "string" ? <ResponseText>{children}</ResponseText> : children}
80
+ </View>
81
+ </ChatTextColorContext.Provider>
82
+ );
83
+ }
84
+
85
+ export type MessageActionsBarProps = ViewProps & { className?: string };
86
+
87
+ /** A row of `MessageAction`s under a message, as the web `flex items-center gap-1`. */
88
+ export function MessageActionsBar({ className, ...props }: MessageActionsBarProps) {
89
+ return <View className={cn("flex-row items-center gap-1", className)} {...props} />;
90
+ }
91
+
92
+ /** The usual actions, with their icons and accessibility labels. */
93
+ export const MESSAGE_ACTIONS = {
94
+ copy: { icon: IconCopy, label: "Copy" },
95
+ retry: { icon: IconRefresh, label: "Retry" },
96
+ like: { icon: IconThumbUp, label: "Good response" },
97
+ dislike: { icon: IconThumbDown, label: "Bad response" },
98
+ } as const;
99
+
100
+ export type MessageActionKind = keyof typeof MESSAGE_ACTIONS;
101
+
102
+ export type MessageActionProps = {
103
+ /** A preset icon and label from `MESSAGE_ACTIONS`. `icon` and `label` override it. */
104
+ kind?: MessageActionKind;
105
+ icon?: ComponentType<IconProps>;
106
+ /** The accessibility label (the web `label` / `tooltip`). Required without `kind`. */
107
+ label?: string;
108
+ onPress?: () => void;
109
+ /**
110
+ * For feedback: the chosen action draws its icon in the foreground and
111
+ * reports `selected` to VoiceOver and TalkBack.
112
+ */
113
+ selected?: boolean;
114
+ disabled?: boolean;
115
+ testID?: string;
116
+ };
117
+
118
+ /**
119
+ * A ghost icon button under a message: the web `icon-sm` (32, 16 icon) with
120
+ * the slop to 44. Copying is up to the app (`onPress`): React Native's own
121
+ * `Clipboard` is deprecated, and native-ui adds no clipboard package.
122
+ */
123
+ export function MessageAction({
124
+ kind,
125
+ icon,
126
+ label,
127
+ onPress,
128
+ selected = false,
129
+ disabled,
130
+ testID,
131
+ }: MessageActionProps) {
132
+ const scheme = useScheme();
133
+ const preset = kind ? MESSAGE_ACTIONS[kind] : undefined;
134
+ const Icon = icon ?? preset?.icon ?? IconCopy;
135
+ return (
136
+ <ChatIconButton
137
+ icon={Icon}
138
+ label={label ?? preset?.label ?? ""}
139
+ iconColor={selected ? tokens.color.foreground[scheme] : undefined}
140
+ accessibilityState={{ selected }}
141
+ onPress={onPress}
142
+ disabled={disabled}
143
+ testID={testID}
144
+ />
145
+ );
146
+ }
@@ -0,0 +1,186 @@
1
+ import type { ReactNode } from "react";
2
+ import { KeyboardAvoidingView, View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
5
+ import { cn } from "../cn";
6
+ import { fieldStyle, fieldTextStyle, typeStyle } from "../controlStyles";
7
+ import { Input as FieldInput } from "../primitives/input";
8
+ import { useControllableState } from "../primitives/use-controllable-state";
9
+ import { useLargerText } from "../theme";
10
+ import { useScheme } from "../useScheme";
11
+ import { ChatIconButton, type ChatIconButtonProps } from "./icon-button";
12
+
13
+ /** The `ai` SDK chat status the web `PromptInputSubmit` takes. */
14
+ export type ChatStatus = "ready" | "submitted" | "streaming" | "error";
15
+
16
+ /** Whether the AI is answering: `submitted` (waiting) or `streaming`. */
17
+ export function isGenerating(status: ChatStatus | undefined) {
18
+ return status === "submitted" || status === "streaming";
19
+ }
20
+
21
+ export type PromptInputSubmitProps = {
22
+ status?: ChatStatus;
23
+ onSubmit?: () => void;
24
+ onStop?: () => void;
25
+ /** Whether there is something to send. Default `true`. */
26
+ canSubmit?: boolean;
27
+ testID?: string;
28
+ };
29
+
30
+ /**
31
+ * Send, or stop while the AI answers: the web `default` `icon-sm` button
32
+ * (32, round, slop to 44). Send is an up arrow on the primary fill, and the
33
+ * `secondary` fill, disabled, with nothing to send. While `status` is
34
+ * `submitted` or `streaming` it is a filled square labelled "Stop" that calls
35
+ * `onStop` (disabled without one).
36
+ */
37
+ export function PromptInputSubmit({
38
+ status,
39
+ onSubmit,
40
+ onStop,
41
+ canSubmit = true,
42
+ testID,
43
+ }: PromptInputSubmitProps) {
44
+ if (isGenerating(status)) {
45
+ return (
46
+ <ChatIconButton
47
+ icon={IconPlayerStopFilled}
48
+ label="Stop"
49
+ variant="primary"
50
+ onPress={onStop}
51
+ disabled={!onStop}
52
+ testID={testID}
53
+ />
54
+ );
55
+ }
56
+ return (
57
+ <ChatIconButton
58
+ icon={IconArrowUp}
59
+ label="Send"
60
+ variant={canSubmit ? "primary" : "secondary"}
61
+ onPress={onSubmit}
62
+ disabled={!canSubmit}
63
+ testID={testID}
64
+ />
65
+ );
66
+ }
67
+
68
+ export type PromptInputButtonProps = ChatIconButtonProps;
69
+
70
+ /** A ghost tool button for the composer `toolbar`, such as attach (web `PromptInputButton`). Give it `icon` and `label`. */
71
+ export function PromptInputButton(props: PromptInputButtonProps) {
72
+ return <ChatIconButton variant="ghost" {...props} />;
73
+ }
74
+
75
+ export type PromptInputProps = {
76
+ value?: string;
77
+ defaultValue?: string;
78
+ onChangeText?: (text: string) => void;
79
+ /** Called with the trimmed text when Send is pressed. The field then clears. */
80
+ onSubmit: (text: string) => void;
81
+ status?: ChatStatus;
82
+ onStop?: () => void;
83
+ /** Default "What would you like to know?", as on the web. */
84
+ placeholder?: string;
85
+ /** Names the field for VoiceOver and TalkBack. Default "Message". */
86
+ accessibilityLabel?: string;
87
+ /** Above the field, such as `Attachments`. */
88
+ header?: ReactNode;
89
+ /** Before the send button, such as `PromptInputButton`s. */
90
+ toolbar?: ReactNode;
91
+ disabled?: boolean;
92
+ /** Pad the bottom safe-area inset (home indicator). Default `true`; turn it off when a tab bar below already does. */
93
+ safeBottom?: boolean;
94
+ /** Height of anything above the screen's own top, such as a navigation header, for the `KeyboardAvoidingView`. Default 0. */
95
+ keyboardVerticalOffset?: number;
96
+ className?: string;
97
+ testID?: string;
98
+ };
99
+
100
+ /** The field grows to this height, then scrolls: about 6 lines of `text-base` (24 each, with `py-2.5`), and about 8 of `text-xl` with larger text. */
101
+ const MAX_FIELD_HEIGHT = { regular: 160, larger: 240 } as const;
102
+
103
+ /**
104
+ * The chat composer: the web `PromptInput` field (`rounded-field border
105
+ * bg-field-background`, `fieldStyle`) with an optional `header` row, a
106
+ * multiline field that grows from one line, and a footer of `toolbar` tools
107
+ * and the send / stop button. It sits in a `KeyboardAvoidingView` (padding,
108
+ * on iOS and Android) and pads the safe-area insets, so put it last in a
109
+ * full-height column, under the `Conversation`.
110
+ */
111
+ export function PromptInput({
112
+ value,
113
+ defaultValue = "",
114
+ onChangeText,
115
+ onSubmit,
116
+ status,
117
+ onStop,
118
+ placeholder = "What would you like to know?",
119
+ accessibilityLabel = "Message",
120
+ header,
121
+ toolbar,
122
+ disabled = false,
123
+ safeBottom = true,
124
+ keyboardVerticalOffset = 0,
125
+ className,
126
+ testID,
127
+ }: PromptInputProps) {
128
+ const scheme = useScheme();
129
+ const largerText = useLargerText();
130
+ const insets = useSafeAreaInsets();
131
+ const [text, setText] = useControllableState({ value, defaultValue, onChange: onChangeText });
132
+ const message = text.trim();
133
+ const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
134
+ const bottom = safeBottom ? insets.bottom : 0;
135
+
136
+ const submit = () => {
137
+ if (!canSubmit) return;
138
+ onSubmit(message);
139
+ setText("");
140
+ };
141
+
142
+ return (
143
+ <KeyboardAvoidingView
144
+ behavior="padding"
145
+ // The keyboard covers the home indicator, so the inset padded below is
146
+ // taken off the lift: the composer sits right on the keyboard.
147
+ keyboardVerticalOffset={keyboardVerticalOffset - bottom}
148
+ >
149
+ <View
150
+ testID={testID}
151
+ className={cn("pt-2", className)}
152
+ style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
153
+ >
154
+ <View className="border" style={fieldStyle(scheme)}>
155
+ {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
156
+ {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
157
+ <FieldInput
158
+ multiline
159
+ value={text}
160
+ onChangeText={setText}
161
+ placeholder={placeholder}
162
+ accessibilityLabel={accessibilityLabel}
163
+ editable={!disabled}
164
+ textAlignVertical="top"
165
+ className={cn(
166
+ "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
167
+ largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
168
+ )}
169
+ style={[
170
+ fieldTextStyle(scheme),
171
+ largerText ? typeStyle("medium") : null,
172
+ {
173
+ backgroundColor: "transparent",
174
+ maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
175
+ },
176
+ ]}
177
+ />
178
+ <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
179
+ <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
180
+ <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
181
+ </View>
182
+ </View>
183
+ </View>
184
+ </KeyboardAvoidingView>
185
+ );
186
+ }
@@ -0,0 +1,200 @@
1
+ import { createContext, useContext, useEffect, useRef, useState } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { IconBrain } from "@tabler/icons-react-native";
4
+ import { cn } from "../cn";
5
+ import { sizeStyle, textStyle } from "../controlStyles";
6
+ import { withAlpha } from "../palette";
7
+ import { useControllableState } from "../primitives/use-controllable-state";
8
+ import { useLargerText } from "../theme";
9
+ import { tokens } from "../tokens";
10
+ import { useScheme } from "../useScheme";
11
+ import { MONO_FONT, chatTextSize, formatElapsed } from "./chat-text";
12
+ import {
13
+ Collapsible,
14
+ CollapsibleChevron,
15
+ CollapsibleContent,
16
+ CollapsibleTrigger,
17
+ type CollapsibleProps,
18
+ type CollapsibleTriggerProps,
19
+ } from "./collapsible";
20
+ import { ResponseText } from "./response-text";
21
+ import { Shimmer } from "./shimmer";
22
+
23
+ type ReasoningContextValue = {
24
+ isStreaming: boolean;
25
+ isOpen: boolean;
26
+ setIsOpen: (open: boolean) => void;
27
+ /** Whole seconds the reasoning streamed for, or `undefined` before it is known. */
28
+ duration: number | undefined;
29
+ };
30
+
31
+ const ReasoningContext = createContext<ReasoningContextValue | null>(null);
32
+
33
+ export function useReasoning() {
34
+ const context = useContext(ReasoningContext);
35
+ if (!context) throw new Error("Reasoning components must be used within Reasoning");
36
+ return context;
37
+ }
38
+
39
+ const AUTO_CLOSE_DELAY_MS = 1000;
40
+
41
+ export type ReasoningProps = Omit<CollapsibleProps, "open" | "defaultOpen" | "onOpenChange"> & {
42
+ isStreaming?: boolean;
43
+ open?: boolean;
44
+ /** Default: open while streaming. `false` keeps it shut when streaming starts, as on the web. */
45
+ defaultOpen?: boolean;
46
+ onOpenChange?: (open: boolean) => void;
47
+ /** Seconds, when the app knows them (a reloaded thread). Default: timed from `isStreaming`. */
48
+ duration?: number;
49
+ };
50
+
51
+ /**
52
+ * The AI's reasoning, folded. As on the web it opens when streaming starts,
53
+ * times the stream, and closes once, a second after it stops. Unlike the web,
54
+ * a reader can close it while it streams (the web reopens it), and a toggle
55
+ * after the stream cancels the pending close, as `useStreamingDisclosure`.
56
+ */
57
+ export function Reasoning({
58
+ isStreaming = false,
59
+ open,
60
+ defaultOpen,
61
+ onOpenChange,
62
+ duration,
63
+ className,
64
+ ...props
65
+ }: ReasoningProps) {
66
+ const [isOpen, setOpen] = useControllableState({
67
+ value: open,
68
+ defaultValue: defaultOpen ?? isStreaming,
69
+ onChange: onOpenChange,
70
+ });
71
+ const [measured, setMeasured] = useState<number | undefined>(undefined);
72
+ const [closeDue, setCloseDue] = useState(false);
73
+ const startedAt = useRef<number | null>(isStreaming ? Date.now() : null);
74
+
75
+ // Runs when the stream starts or stops, not on every open change, so a
76
+ // reader's own close mid-stream holds.
77
+ useEffect(() => {
78
+ if (isStreaming) {
79
+ if (startedAt.current === null) startedAt.current = Date.now();
80
+ if (!isOpen && defaultOpen !== false) setOpen(true);
81
+ setCloseDue(false);
82
+ } else if (startedAt.current !== null) {
83
+ setMeasured(Math.ceil((Date.now() - startedAt.current) / 1000));
84
+ startedAt.current = null;
85
+ if (isOpen) setCloseDue(true);
86
+ }
87
+ }, [isStreaming]);
88
+
89
+ useEffect(() => {
90
+ if (!closeDue) return;
91
+ const timer = setTimeout(() => {
92
+ setOpen(false);
93
+ setCloseDue(false);
94
+ }, AUTO_CLOSE_DELAY_MS);
95
+ return () => clearTimeout(timer);
96
+ }, [closeDue]);
97
+
98
+ const setIsOpen = (next: boolean) => {
99
+ setCloseDue(false);
100
+ setOpen(next);
101
+ };
102
+
103
+ return (
104
+ <ReasoningContext.Provider
105
+ value={{ isStreaming, isOpen, setIsOpen, duration: duration ?? measured }}
106
+ >
107
+ <Collapsible
108
+ open={isOpen}
109
+ onOpenChange={setIsOpen}
110
+ className={cn("mb-4", className)}
111
+ {...props}
112
+ />
113
+ </ReasoningContext.Provider>
114
+ );
115
+ }
116
+
117
+ /** "Thinking…" while streaming, then "Thought for 12s" ("Thought for a few seconds" with no duration). */
118
+ export function reasoningMessage(isStreaming: boolean, duration?: number): string {
119
+ if (isStreaming) return "Thinking…";
120
+ if (duration === undefined) return "Thought for a few seconds";
121
+ return `Thought for ${formatElapsed(duration)}`;
122
+ }
123
+
124
+ export type ReasoningTriggerProps = CollapsibleTriggerProps & {
125
+ /** Replaces `reasoningMessage`. A string, as it is also the accessibility label. */
126
+ getThinkingMessage?: (isStreaming: boolean, duration?: number) => string;
127
+ };
128
+
129
+ /** Brain icon, the shimmering "Thinking…" or "Thought for 12s", and a chevron: the web `ReasoningTrigger`. */
130
+ export function ReasoningTrigger({
131
+ children,
132
+ getThinkingMessage = reasoningMessage,
133
+ className,
134
+ ...props
135
+ }: ReasoningTriggerProps) {
136
+ const { isStreaming, isOpen, duration } = useReasoning();
137
+ const scheme = useScheme();
138
+ const largerText = useLargerText();
139
+ const message = getThinkingMessage(isStreaming, duration);
140
+ const muted = tokens.color.muted[scheme];
141
+ const iconSize = largerText ? 20 : 16;
142
+ return (
143
+ <CollapsibleTrigger
144
+ accessibilityLabel={message}
145
+ className={cn("w-full flex-row items-center gap-2 py-1", className)}
146
+ {...props}
147
+ >
148
+ {children ?? (
149
+ <>
150
+ <IconBrain size={iconSize} color={muted} />
151
+ {isStreaming ? (
152
+ <Shimmer duration={1} numberOfLines={1}>
153
+ {message}
154
+ </Shimmer>
155
+ ) : (
156
+ <ResponseText size="small" tone="muted" selectable={false} numberOfLines={1} className="shrink">
157
+ {message}
158
+ </ResponseText>
159
+ )}
160
+ <CollapsibleChevron open={isOpen} size={iconSize} color={muted} />
161
+ </>
162
+ )}
163
+ </CollapsibleTrigger>
164
+ );
165
+ }
166
+
167
+ export type ReasoningContentProps = {
168
+ children: string;
169
+ className?: string;
170
+ };
171
+
172
+ /**
173
+ * The reasoning text, muted and monospaced, on the web `rounded-lg
174
+ * bg-surface-secondary/40 p-3` panel. Plain text: see `ResponseText` for
175
+ * markdown.
176
+ */
177
+ export function ReasoningContent({ children, className }: ReasoningContentProps) {
178
+ const scheme = useScheme();
179
+ const largerText = useLargerText();
180
+ const size = chatTextSize("small", largerText);
181
+ return (
182
+ <CollapsibleContent className={cn("pb-1 pt-2", className)}>
183
+ <View
184
+ className="p-3"
185
+ style={{
186
+ borderRadius: tokens.radius.lg,
187
+ backgroundColor: withAlpha(tokens.color.surfaceSecondary[scheme], 0.4),
188
+ }}
189
+ >
190
+ <Text
191
+ selectable
192
+ className={size}
193
+ style={[sizeStyle(size), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}
194
+ >
195
+ {children}
196
+ </Text>
197
+ </View>
198
+ </CollapsibleContent>
199
+ );
200
+ }
@@ -0,0 +1,56 @@
1
+ import { createContext, useContext } from "react";
2
+ import { Text, type TextProps } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { sizeStyle, textStyle } from "../controlStyles";
5
+ import { useLargerText } from "../theme";
6
+ import { useScheme } from "../useScheme";
7
+ import { chatTextSize, type ChatTextKind } from "./chat-text";
8
+
9
+ // Markdown stub. The web renders replies with Streamdown; native-ui has no
10
+ // markdown peer, so `ResponseText` draws the text as it is, newlines kept.
11
+ // An app that wants markdown can render one of these in its place:
12
+ // `react-native-markdown-display`, `@ronradtke/react-native-markdown-display`
13
+ // (a maintained fork) or `react-native-marked`.
14
+
15
+ /**
16
+ * The text colour `MessageContent` hands down: React Native text does not
17
+ * inherit a colour from its parent view, as web text does from `color`.
18
+ */
19
+ export const ChatTextColorContext = createContext<string | undefined>(undefined);
20
+
21
+ export type ResponseTextProps = Omit<TextProps, "children"> & {
22
+ children: string;
23
+ /** Default: the enclosing `MessageContent` colour, else the foreground. */
24
+ tone?: "foreground" | "muted";
25
+ /** Default `"body"` (16, `text-xl` with larger text). See `chatTextSize`. */
26
+ size?: ChatTextKind;
27
+ className?: string;
28
+ };
29
+
30
+ /** Plain reply text, selectable, newlines kept. The markdown placeholder. */
31
+ export function ResponseText({
32
+ children,
33
+ tone,
34
+ size = "body",
35
+ selectable = true,
36
+ className,
37
+ style,
38
+ ...props
39
+ }: ResponseTextProps) {
40
+ const scheme = useScheme();
41
+ const largerText = useLargerText();
42
+ const inherited = useContext(ChatTextColorContext);
43
+ const textSize = chatTextSize(size, largerText);
44
+ const color =
45
+ tone || !inherited ? textStyle(tone ?? "foreground", scheme) : { color: inherited };
46
+ return (
47
+ <Text
48
+ selectable={selectable}
49
+ className={cn(textSize, "font-instrument-sans", className)}
50
+ style={[sizeStyle(textSize), color, style]}
51
+ {...props}
52
+ >
53
+ {children}
54
+ </Text>
55
+ );
56
+ }
@@ -0,0 +1,86 @@
1
+ import { useEffect } from "react";
2
+ import { Text, type StyleProp, type TextStyle } from "react-native";
3
+ import Animated, {
4
+ cancelAnimation,
5
+ useAnimatedStyle,
6
+ useSharedValue,
7
+ withRepeat,
8
+ withTiming,
9
+ } from "react-native-reanimated";
10
+ import { cn } from "../cn";
11
+ import { sizeStyle, textStyle } from "../controlStyles";
12
+ import { useLargerText } from "../theme";
13
+ import { useReduceMotion } from "../useReduceMotion";
14
+ import { useScheme } from "../useScheme";
15
+ import { chatTextSize, type ChatTextKind } from "./chat-text";
16
+
17
+ /**
18
+ * Lowest opacity of the pulse. The foreground at 0.45 on the page is close to
19
+ * the muted colour, the far end of the web sweep.
20
+ */
21
+ export const SHIMMER_LOW_OPACITY = 0.45;
22
+
23
+ export type ShimmerProps = {
24
+ children: string;
25
+ /** Seconds for one pass, as on the web. Default 2. */
26
+ duration?: number;
27
+ /** Default `"small"` (14, `text-lg` with larger text). */
28
+ size?: ChatTextKind;
29
+ numberOfLines?: number;
30
+ className?: string;
31
+ style?: StyleProp<TextStyle>;
32
+ };
33
+
34
+ /**
35
+ * "Live" text, such as "Thinking…". The web sweeps a foreground band across
36
+ * muted text (`bg-clip-text`), which React Native cannot clip to the glyphs
37
+ * without a masked view. Here the foreground text fades to
38
+ * `SHIMMER_LOW_OPACITY` and back once per `duration`, on the UI thread with
39
+ * Reanimated. With Reduce Motion it holds still in the muted colour.
40
+ */
41
+ export function Shimmer({
42
+ children,
43
+ duration = 2,
44
+ size = "small",
45
+ numberOfLines,
46
+ className,
47
+ style,
48
+ }: ShimmerProps) {
49
+ const scheme = useScheme();
50
+ const largerText = useLargerText();
51
+ const reduceMotion = useReduceMotion();
52
+ const opacity = useSharedValue(1);
53
+
54
+ useEffect(() => {
55
+ if (reduceMotion) {
56
+ cancelAnimation(opacity);
57
+ opacity.value = 1;
58
+ return;
59
+ }
60
+ opacity.value = withRepeat(
61
+ withTiming(SHIMMER_LOW_OPACITY, { duration: (duration * 1000) / 2 }),
62
+ -1,
63
+ true,
64
+ );
65
+ return () => cancelAnimation(opacity);
66
+ }, [opacity, reduceMotion, duration]);
67
+
68
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
69
+ const textSize = chatTextSize(size, largerText);
70
+
71
+ return (
72
+ <Animated.View className="min-w-0 shrink" style={animatedStyle}>
73
+ <Text
74
+ numberOfLines={numberOfLines}
75
+ className={cn(textSize, "font-instrument-sans", className)}
76
+ style={[
77
+ sizeStyle(textSize),
78
+ textStyle(reduceMotion ? "muted" : "foreground", scheme),
79
+ style,
80
+ ]}
81
+ >
82
+ {children}
83
+ </Text>
84
+ </Animated.View>
85
+ );
86
+ }