@vvedantb/native-ui 0.12.1 → 0.17.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.
Files changed (113) hide show
  1. package/README.md +82 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. package/src/webview/htmlView.ts +45 -0
@@ -1,15 +1,24 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import { Pressable, View, type PressableProps, type ViewProps } from "react-native";
3
3
  import { IconChevronDown, IconChevronRight } from "@tabler/icons-react-native";
4
- import { FadeIn } from "react-native-reanimated";
4
+ import Animated, { cubicBezier } from "react-native-reanimated";
5
5
  import { cn } from "../cn";
6
6
  import { MIN_TOUCH_TARGET, touchSlop } from "../controlStyles";
7
- import { NativeOnlyAnimatedView } from "../primitives/native-only-animated-view";
8
7
  import { useControllableState } from "../primitives/use-controllable-state";
8
+ import { useReduceMotion } from "../useReduceMotion";
9
9
 
10
10
  // A small fold for the AI parts: native-ui has no `Collapsible`. A `Pressable`
11
- // trigger and content that mounts only while open. The content fades in on
12
- // iOS and Android, and appears in place with Reduce Motion.
11
+ // trigger and content that mounts only while open. The content appears at
12
+ // once: a fade would also run each time a list mounts an open fold (a chat
13
+ // opening, a virtualised row scrolled back). The chevron turns in 150 ms; with
14
+ // Reduce Motion it changes in place.
15
+
16
+ /** The web chevron: `transition-transform duration-150`, on the strong ease-out. */
17
+ const CHEVRON_TURN = {
18
+ transitionProperty: "transform",
19
+ transitionDuration: 150,
20
+ transitionTimingFunction: cubicBezier(0.23, 1, 0.32, 1),
21
+ } as const;
13
22
 
14
23
  type CollapsibleContextValue = { open: boolean; setOpen: (open: boolean) => void };
15
24
 
@@ -75,20 +84,16 @@ export function CollapsibleTrigger({
75
84
  }
76
85
 
77
86
  /** Mounted only while the fold is open. */
78
- export function CollapsibleContent({ children, ...props }: ViewProps & { className?: string }) {
87
+ export function CollapsibleContent(props: ViewProps & { className?: string }) {
79
88
  const { open } = useCollapsible();
80
89
  if (!open) return null;
81
- return (
82
- <NativeOnlyAnimatedView entering={FadeIn.duration(150)} {...props}>
83
- {children}
84
- </NativeOnlyAnimatedView>
85
- );
90
+ return <View {...props} />;
86
91
  }
87
92
 
88
93
  /**
89
94
  * The fold chevron: `down` turns up when open (reasoning, sources, chain of
90
95
  * thought), `right` turns down (trace lines), as the web `rotate-180` and
91
- * `rotate-90`. It turns at once; the web eases it over 150 ms.
96
+ * `rotate-90`, easing over 150 ms as the web does (at once with Reduce Motion).
92
97
  */
93
98
  export function CollapsibleChevron({
94
99
  open,
@@ -102,10 +107,11 @@ export function CollapsibleChevron({
102
107
  color: string;
103
108
  }) {
104
109
  const Icon = variant === "down" ? IconChevronDown : IconChevronRight;
110
+ const reduceMotion = useReduceMotion();
105
111
  const turn = variant === "down" ? "180deg" : "90deg";
106
112
  return (
107
- <View style={{ transform: [{ rotate: open ? turn : "0deg" }] }}>
113
+ <Animated.View style={[{ transform: [{ rotate: open ? turn : "0deg" }] }, reduceMotion ? null : CHEVRON_TURN]}>
108
114
  <Icon size={size} color={color} />
109
- </View>
115
+ </Animated.View>
110
116
  );
111
117
  }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createContext,
3
+ isValidElement,
3
4
  useContext,
4
5
  useRef,
5
6
  useState,
@@ -8,19 +9,29 @@ import {
8
9
  type RefObject,
9
10
  } from "react";
10
11
  import {
12
+ FlatList,
11
13
  Platform,
14
+ Pressable,
12
15
  ScrollView,
16
+ StyleSheet,
13
17
  Text,
14
18
  View,
19
+ type FlatListProps,
20
+ type LayoutChangeEvent,
15
21
  type NativeScrollEvent,
22
+ type NativeSyntheticEvent,
16
23
  type ScrollViewProps,
17
24
  type ViewProps,
18
25
  } from "react-native";
19
26
  import { IconArrowDown, type IconProps } from "@tabler/icons-react-native";
20
27
  import { FadeIn, FadeOut } from "react-native-reanimated";
28
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
29
+ import { EASE_OUT } from "../motion";
21
30
  import { Button } from "../Button";
22
31
  import { cn } from "../cn";
23
- import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
32
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
33
+ import { withAlpha } from "../palette";
34
+ import { KeyboardLiftContext, useKeyboardParts } from "../primitives/keyboard-parts";
24
35
  import { NativeOnlyAnimatedView } from "../primitives/native-only-animated-view";
25
36
  import { useLargerText } from "../theme";
26
37
  import { tokens } from "../tokens";
@@ -31,18 +42,41 @@ import { chatTextSize } from "./chat-text";
31
42
  /** Within this many points of the end, the thread counts as at the bottom and keeps following new content. */
32
43
  export const AT_BOTTOM_THRESHOLD = 70;
33
44
 
34
- /** Whether a scroll position is within `AT_BOTTOM_THRESHOLD` of the end. Content shorter than the view is at the bottom. */
35
- export function isScrolledToBottom({ contentOffset, contentSize, layoutMeasurement }: NativeScrollEvent) {
36
- return contentSize.height - layoutMeasurement.height - contentOffset.y <= AT_BOTTOM_THRESHOLD;
45
+ /**
46
+ * Whether a scroll position is within `AT_BOTTOM_THRESHOLD` of the end.
47
+ * Content shorter than the view is at the bottom. `bottomInset` is space
48
+ * added past the content (the keyboard's, with `KeyboardControllerProvider`).
49
+ * An `inverted` list (newest first) has its end at offset 0, and there
50
+ * `bottomInset` is space added before offset 0 (the keyboard's on iOS), so
51
+ * the end is at minus `bottomInset`.
52
+ */
53
+ export function isScrolledToBottom(
54
+ { contentOffset, contentSize, layoutMeasurement }: NativeScrollEvent,
55
+ bottomInset = 0,
56
+ inverted = false,
57
+ ) {
58
+ if (inverted) return contentOffset.y + bottomInset <= AT_BOTTOM_THRESHOLD;
59
+ return contentSize.height + bottomInset - layoutMeasurement.height - contentOffset.y <= AT_BOTTOM_THRESHOLD;
37
60
  }
38
61
 
62
+ /**
63
+ * What the thread scrolls with: the `ScrollView` of `ConversationContent` or
64
+ * the `FlatList` of `ConversationList`. `scrollToEnd` goes to the newest
65
+ * message, which is offset 0 in an inverted list.
66
+ */
67
+ export type ConversationScroller = { scrollToEnd: (options?: { animated?: boolean }) => void };
68
+
39
69
  type ConversationContextValue = {
40
70
  isAtBottom: boolean;
41
71
  scrollToBottom: () => void;
42
- scrollRef: RefObject<ScrollView | null>;
43
- onScroll: (event: NativeScrollEvent) => void;
72
+ scrollRef: RefObject<ConversationScroller | null>;
73
+ /** `bottomInset` and `inverted`, as `isScrolledToBottom`. */
74
+ onScroll: (event: NativeScrollEvent, bottomInset?: number, inverted?: boolean) => void;
44
75
  onUserScroll: () => void;
45
76
  followContent: () => void;
77
+ /** The height of the `ConversationDock` under the thread, 0 without one. */
78
+ dockHeight: number;
79
+ setDockHeight: (height: number) => void;
46
80
  };
47
81
 
48
82
  const ConversationContext = createContext<ConversationContextValue | null>(null);
@@ -63,12 +97,13 @@ export type ConversationProps = ViewProps & { className?: string };
63
97
  */
64
98
  export function Conversation({ className, ...props }: ConversationProps) {
65
99
  const reduceMotion = useReduceMotion();
66
- const scrollRef = useRef<ScrollView | null>(null);
100
+ const scrollRef = useRef<ConversationScroller | null>(null);
67
101
  const atBottom = useRef(true);
68
102
  // Set while our own animated scroll heads down, so its first frames (not
69
103
  // yet at the bottom) do not count as the reader leaving.
70
104
  const autoScrolling = useRef(false);
71
105
  const [isAtBottom, setIsAtBottom] = useState(true);
106
+ const [dockHeight, setDockHeight] = useState(0);
72
107
 
73
108
  const update = (next: boolean) => {
74
109
  atBottom.current = next;
@@ -83,8 +118,8 @@ export function Conversation({ className, ...props }: ConversationProps) {
83
118
  update(true);
84
119
  scrollRef.current?.scrollToEnd({ animated: !reduceMotion });
85
120
  },
86
- onScroll: (event) => {
87
- const bottom = isScrolledToBottom(event);
121
+ onScroll: (event, bottomInset, inverted) => {
122
+ const bottom = isScrolledToBottom(event, bottomInset, inverted);
88
123
  if (bottom) autoScrolling.current = false;
89
124
  else if (autoScrolling.current) return;
90
125
  update(bottom);
@@ -96,6 +131,8 @@ export function Conversation({ className, ...props }: ConversationProps) {
96
131
  followContent: () => {
97
132
  if (atBottom.current) scrollRef.current?.scrollToEnd({ animated: false });
98
133
  },
134
+ dockHeight,
135
+ setDockHeight,
99
136
  };
100
137
 
101
138
  return (
@@ -105,53 +142,237 @@ export function Conversation({ className, ...props }: ConversationProps) {
105
142
  );
106
143
  }
107
144
 
145
+ /** The scroll handlers a thread's scroller passes on, as `ScrollView` and `FlatList` both take them. */
146
+ type ConversationScrollHandlers = {
147
+ onScroll?: ((event: NativeSyntheticEvent<NativeScrollEvent>) => void) | null;
148
+ onScrollBeginDrag?: ((event: NativeSyntheticEvent<NativeScrollEvent>) => void) | null;
149
+ onContentSizeChange?: ((width: number, height: number) => void) | null;
150
+ onLayout?: ((event: LayoutChangeEvent) => void) | null;
151
+ };
152
+
153
+ /**
154
+ * Taps reach rows while the keyboard is up; a drag lowers it (interactively
155
+ * on iOS, and on Android 11+ with `KeyboardControllerProvider`, in
156
+ * `ConversationList`); scroll events at 60 Hz.
157
+ */
158
+ const SCROLL_DEFAULTS: Pick<ScrollViewProps, "keyboardShouldPersistTaps" | "keyboardDismissMode" | "scrollEventThrottle"> = {
159
+ keyboardShouldPersistTaps: "handled",
160
+ keyboardDismissMode: Platform.OS === "ios" ? "interactive" : "on-drag",
161
+ scrollEventThrottle: 16,
162
+ };
163
+
164
+ /**
165
+ * The wiring `ConversationContent` and `ConversationList` share: the ref the
166
+ * scroll button and the follow use, taps that reach rows while the keyboard is
167
+ * up, and the handlers that track the bottom and follow new content. The
168
+ * defaults come first so `props` can override them; the handlers come last
169
+ * and call the app's own after the thread's. An inverted list needs no
170
+ * follow: its newest message is at offset 0, so it stays in view as the
171
+ * content and the view change. While `paused` is true the bottom is not
172
+ * tracked: something other than the reader scrolls the list.
173
+ */
174
+ function useConversationScroll<P extends ConversationScrollHandlers>(
175
+ { onScroll, onScrollBeginDrag, onContentSizeChange, onLayout, ...props }: P,
176
+ {
177
+ bottomInset,
178
+ inverted = false,
179
+ paused,
180
+ }: {
181
+ /** Space past the content that `onScroll` does not report, read at each event. */
182
+ bottomInset?: () => number;
183
+ inverted?: boolean;
184
+ paused?: () => boolean;
185
+ } = {},
186
+ ) {
187
+ const conversation = useConversation();
188
+ return {
189
+ ref: (scroller: ConversationScroller | null) => {
190
+ conversation.scrollRef.current = scroller;
191
+ },
192
+ ...SCROLL_DEFAULTS,
193
+ ...props,
194
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => {
195
+ if (!paused?.()) conversation.onScroll(event.nativeEvent, bottomInset?.(), inverted);
196
+ onScroll?.(event);
197
+ },
198
+ onScrollBeginDrag: (event: NativeSyntheticEvent<NativeScrollEvent>) => {
199
+ conversation.onUserScroll();
200
+ onScrollBeginDrag?.(event);
201
+ },
202
+ // The app's handler first, so a scroller that measures itself (`ConversationList`) has the new size to follow.
203
+ onContentSizeChange: (width: number, height: number) => {
204
+ onContentSizeChange?.(width, height);
205
+ if (!inverted) conversation.followContent();
206
+ },
207
+ // The keyboard or composer resizing the view keeps the end in view too.
208
+ onLayout: (event: LayoutChangeEvent) => {
209
+ onLayout?.(event);
210
+ if (!inverted) conversation.followContent();
211
+ },
212
+ };
213
+ }
214
+
108
215
  export type ConversationContentProps = ScrollViewProps & { className?: string };
109
216
 
110
217
  /**
111
218
  * The scrolling message column: the web `flex flex-col gap-4 px-3 py-3`. A
112
- * `ScrollView`; for very long threads an app can drive its own `FlatList`
113
- * with `useConversation`.
219
+ * `ScrollView`, which draws every message; a thread that can grow long
220
+ * should use `ConversationList`.
221
+ */
222
+ export function ConversationContent({ className, contentContainerClassName, ...props }: ConversationContentProps) {
223
+ return (
224
+ <ScrollView
225
+ className={cn("flex-1", className)}
226
+ contentContainerClassName={cn("gap-4 px-3 py-3", contentContainerClassName)}
227
+ {...useConversationScroll(props)}
228
+ />
229
+ );
230
+ }
231
+
232
+ export type ConversationListProps<T> = FlatListProps<T> & {
233
+ className?: string;
234
+ contentContainerClassName?: string;
235
+ };
236
+
237
+ /**
238
+ * `ConversationContent` as a virtualised `FlatList`, for a thread that can
239
+ * grow long: only the messages near the view are drawn. The same sticking to
240
+ * the bottom, scroll button and keyboard behaviour. The column has the
241
+ * `px-3 py-3` padding but no gap, as a `FlatList` gap would also space its
242
+ * hidden-row placeholders: give `ItemSeparatorComponent` for the space
243
+ * between messages, and a stable `keyExtractor`.
244
+ *
245
+ * With `inverted` (give `data` newest first) the newest message is the first
246
+ * row, drawn at the bottom, so a thread opens at its end in its first frame,
247
+ * with no scroll and no measuring. A streamed reply (row 0) grows upwards and
248
+ * stays in view. Scrolled up, new content keeps what the reader sees in place
249
+ * (`maintainVisibleContentPosition`, iOS and Android). A thread shorter than
250
+ * the view starts at its top, as without `inverted`. `ListHeaderComponent`
251
+ * draws at the bottom, under the newest message, and `ListFooterComponent` at
252
+ * the top. An empty list draws `ListEmptyComponent` in place of the list, the
253
+ * right way up. On Android React Native flips an inverted list on both axes,
254
+ * so the column's left and right padding swap: keep it even, and put uneven
255
+ * side padding in the rows.
256
+ *
257
+ * Without `inverted`, it scrolls to the end it has measured, not with
258
+ * `FlatList.scrollToEnd`, which places the end from estimated row heights. A
259
+ * FlatList draws from its start, so a thread opened with messages would show
260
+ * its first rows and then jump to the end: it stays hidden until its first
261
+ * jump to the end (the next frame, once both sizes are known). Rows that are
262
+ * drawn or measured later can still move it once it shows; prefer `inverted`.
263
+ *
264
+ * With `KeyboardControllerProvider` the list keeps its size as the keyboard
265
+ * opens: its content gains the keyboard's height at the end and rises with
266
+ * the keyboard frame by frame, so the newest message stays on the composer.
267
+ * A drag down on it moves the keyboard with the finger once the finger
268
+ * reaches the composer, and lets it go at the finger's speed (iOS, Android
269
+ * 11+; Android 10 and below lower it at once, as without the provider).
114
270
  */
115
- export function ConversationContent({
271
+ export function ConversationList<T>({
116
272
  className,
117
273
  contentContainerClassName,
118
- onScroll,
119
- onScrollBeginDrag,
120
274
  onContentSizeChange,
121
275
  onLayout,
122
276
  ...props
123
- }: ConversationContentProps) {
277
+ }: ConversationListProps<T>) {
124
278
  const conversation = useConversation();
125
- return (
126
- <ScrollView
127
- ref={conversation.scrollRef}
128
- className={cn("flex-1", className)}
129
- contentContainerClassName={cn("gap-4 px-3 py-3", contentContainerClassName)}
130
- keyboardShouldPersistTaps="handled"
131
- keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
132
- scrollEventThrottle={16}
133
- {...props}
134
- onScroll={(event) => {
135
- conversation.onScroll(event.nativeEvent);
136
- onScroll?.(event);
137
- }}
138
- onScrollBeginDrag={(event) => {
139
- conversation.onUserScroll();
140
- onScrollBeginDrag?.(event);
141
- }}
142
- onContentSizeChange={(width, height) => {
143
- conversation.followContent();
279
+ const keyboard = useKeyboardParts();
280
+ const insets = useSafeAreaInsets();
281
+ const inverted = props.inverted === true;
282
+ // Sizes from the list's own events, for the scroll offset; no render reads
283
+ // them. `inset` is the keyboard's space past the content (before offset 0
284
+ // when inverted); `keyboardMoving` is true while the keyboard scrolls the
285
+ // list, which is not the reader leaving the bottom.
286
+ const size = useRef({ content: 0, view: 0, inset: 0, keyboardMoving: false });
287
+ // Not inverted: hidden until the first jump to the end (see above). An
288
+ // empty list (a spinner, an empty state) shows at once.
289
+ const hasRows = (props.data?.length ?? 0) > 0;
290
+ const [placed, setPlaced] = useState(false);
291
+ const placeOnce = () => {
292
+ if (!inverted && !placed && hasRows && size.current.content > 0 && size.current.view > 0) {
293
+ requestAnimationFrame(() => setPlaced(true));
294
+ }
295
+ };
296
+ const wired = useConversationScroll(
297
+ {
298
+ ...props,
299
+ onContentSizeChange: (width: number, height: number) => {
300
+ size.current.content = height;
144
301
  onContentSizeChange?.(width, height);
145
- }}
146
- // The keyboard or composer resizing the view keeps the end in view too.
147
- onLayout={(event) => {
148
- conversation.followContent();
302
+ placeOnce();
303
+ },
304
+ onLayout: (event: LayoutChangeEvent) => {
305
+ size.current.view = event.nativeEvent.layout.height;
149
306
  onLayout?.(event);
307
+ placeOnce();
308
+ },
309
+ },
310
+ { bottomInset: () => size.current.inset, inverted, paused: () => size.current.keyboardMoving },
311
+ );
312
+ // An inverted list would draw its empty state at the bottom, flipped back
313
+ // only if the element takes a `style`: draw it the right way up instead.
314
+ if (inverted && !hasRows) {
315
+ const Empty = props.ListEmptyComponent;
316
+ return <View style={styles.fill}>{isValidElement(Empty) ? Empty : Empty ? <Empty /> : null}</View>;
317
+ }
318
+ // The newest message's offset: inverted, 0 less the space before it.
319
+ const end = () =>
320
+ inverted
321
+ ? size.current.inset > 0
322
+ ? -size.current.inset
323
+ : 0
324
+ : Math.max(0, size.current.content + size.current.inset - size.current.view);
325
+ const list = (
326
+ <FlatList
327
+ className={cn("flex-1", className)}
328
+ // Inverted, the column's end is the top: a short thread sits there.
329
+ contentContainerClassName={cn("px-3 py-3", inverted && "grow justify-end", contentContainerClassName)}
330
+ {...wired}
331
+ style={[wired.style, !inverted && hasRows && !placed ? styles.hidden : null]}
332
+ // At the bottom, a new message shows at offset 0; scrolled up, it must not move what the reader sees.
333
+ maintainVisibleContentPosition={
334
+ props.maintainVisibleContentPosition ?? (inverted && !conversation.isAtBottom ? KEEP_VISIBLE : undefined)
335
+ }
336
+ keyboardDismissMode={
337
+ props.keyboardDismissMode ?? (keyboard?.interactive ? "interactive" : SCROLL_DEFAULTS.keyboardDismissMode)
338
+ }
339
+ renderScrollComponent={
340
+ keyboard
341
+ ? (scrollProps: ScrollViewProps) => (
342
+ <keyboard.ChatScrollView
343
+ {...scrollProps}
344
+ inverted={inverted}
345
+ // The composer pads the bottom inset, which the keyboard covers: the content rises by the rest.
346
+ bottomOffset={insets.bottom}
347
+ onInsetChange={(inset) => {
348
+ size.current.inset = inset;
349
+ }}
350
+ onKeyboardMove={(moving) => {
351
+ size.current.keyboardMoving = moving;
352
+ }}
353
+ />
354
+ )
355
+ : props.renderScrollComponent
356
+ }
357
+ ref={(list: FlatList<T> | null) => {
358
+ conversation.scrollRef.current = list && {
359
+ scrollToEnd: (options) => list.scrollToOffset({ offset: end(), animated: options?.animated }),
360
+ };
150
361
  }}
151
362
  />
152
363
  );
364
+ if (!keyboard) return list;
365
+ // The keyboard follows the finger from the composer's top: the dock less the inset it pads.
366
+ return (
367
+ <keyboard.GestureArea offset={Math.max(0, conversation.dockHeight - insets.bottom)} style={styles.fill}>
368
+ {list}
369
+ </keyboard.GestureArea>
370
+ );
153
371
  }
154
372
 
373
+ /** Scrolled up in an inverted list: the first drawn row in view keeps its place as rows are added or grow. */
374
+ const KEEP_VISIBLE = { minIndexForVisible: 0 };
375
+
155
376
  export type ConversationEmptyStateProps = ViewProps & {
156
377
  title?: string;
157
378
  description?: string;
@@ -200,46 +421,158 @@ export function ConversationEmptyState({
200
421
  );
201
422
  }
202
423
 
424
+ export type ConversationDockProps = ViewProps & { className?: string };
425
+
426
+ /**
427
+ * The group under the thread, inside `Conversation` after the list: the
428
+ * composer, or a docked question card in its place. `ConversationScrollButton`
429
+ * sits centred 16 above its top edge (the web button's `bottom-4` over the
430
+ * dock), so the button stays with the dock as it grows (a reply quote, a
431
+ * taller field) and as the keyboard lifts it, instead of a fixed height over
432
+ * the messages.
433
+ *
434
+ * With `KeyboardControllerProvider` the dock moves with the keyboard frame by
435
+ * frame (`translateY`, no layout), less the bottom inset: its content pads
436
+ * that inset, as `PromptInput` does, and the keyboard covers it. A
437
+ * `PromptInput` in it then adds no lift of its own.
438
+ */
439
+ export function ConversationDock({ onLayout, ...props }: ConversationDockProps) {
440
+ const { setDockHeight } = useConversation();
441
+ const keyboard = useKeyboardParts();
442
+ const insets = useSafeAreaInsets();
443
+ const dock = (
444
+ <View
445
+ {...props}
446
+ onLayout={(event) => {
447
+ setDockHeight(event.nativeEvent.layout.height);
448
+ onLayout?.(event);
449
+ }}
450
+ />
451
+ );
452
+ if (!keyboard) return dock;
453
+ return (
454
+ <keyboard.StickyView bottomOffset={insets.bottom}>
455
+ <KeyboardLiftContext.Provider value>{dock}</KeyboardLiftContext.Provider>
456
+ </keyboard.StickyView>
457
+ );
458
+ }
459
+
203
460
  export type ConversationScrollButtonProps = {
204
- /** The accessibility label. Default "Scroll to bottom". */
461
+ /** The accessibility label, and the text with `showLabel`. Default "Scroll to bottom". */
205
462
  label?: string;
206
463
  /** Replaces the down arrow. */
207
464
  icon?: ComponentType<IconProps>;
465
+ /**
466
+ * Draw `label` after the icon in a compact pill, as the web button with a
467
+ * `label` in Eva's chat ("Scroll to end": 28 high, `px-3`, muted, a 60%
468
+ * border on the surface), with a `text-sm` label, the phone floor for
469
+ * tappable text (the web is `text-xs`). Default `false`: the round arrow.
470
+ */
471
+ showLabel?: boolean;
472
+ /**
473
+ * The row the button centres in, full width by default. Inset a side (for
474
+ * example `{ right: 10 }`) to centre it on a thread that sits off-centre.
475
+ */
476
+ insets?: { left?: number; right?: number };
208
477
  testID?: string;
209
478
  };
210
479
 
480
+ /** The scroll button fades in and out on the strong ease-out. */
481
+ const SCROLL_BUTTON_ENTER = FadeIn.duration(150).easing(EASE_OUT);
482
+ const SCROLL_BUTTON_EXIT = FadeOut.duration(120).easing(EASE_OUT);
483
+
484
+ /** The labelled pill's height (web `h-7`). */
485
+ const SCROLL_PILL_HEIGHT = 28;
486
+
211
487
  /**
212
- * Round outline arrow, centred 16 above the bottom of the `Conversation`,
213
- * shown while the reader is away from the end (the web `absolute bottom-4`).
488
+ * Round outline arrow, centred 16 above the bottom of the `Conversation`, or
489
+ * above its `ConversationDock` when it has one, shown while the reader is
490
+ * away from the end (the web `absolute bottom-4`).
214
491
  * The web `Button` `icon` size (40, slop to 48). It has an opaque surface
215
- * fill and no shadow, so it reads over the messages under it.
492
+ * fill and no shadow, so it reads over the messages under it. With
493
+ * `showLabel`, a compact pill with the icon and `label`.
216
494
  */
217
495
  export function ConversationScrollButton({
218
496
  label = "Scroll to bottom",
219
497
  icon: Icon = IconArrowDown,
498
+ showLabel = false,
499
+ insets,
220
500
  testID,
221
501
  }: ConversationScrollButtonProps) {
222
- const { isAtBottom, scrollToBottom } = useConversation();
502
+ const { isAtBottom, scrollToBottom, dockHeight } = useConversation();
503
+ const keyboard = useKeyboardParts();
504
+ const safeArea = useSafeAreaInsets();
223
505
  const scheme = useScheme();
224
506
  const largerText = useLargerText();
225
507
  if (isAtBottom) return null;
226
- return (
508
+ const labelSize = chatTextSize("small", largerText);
509
+ const button = (
227
510
  <NativeOnlyAnimatedView
228
- entering={FadeIn.duration(150)}
229
- exiting={FadeOut.duration(150)}
511
+ entering={SCROLL_BUTTON_ENTER}
512
+ exiting={SCROLL_BUTTON_EXIT}
230
513
  pointerEvents="box-none"
231
- style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}
514
+ style={{
515
+ position: "absolute",
516
+ left: insets?.left ?? 0,
517
+ right: insets?.right ?? 0,
518
+ bottom: dockHeight + 16,
519
+ alignItems: "center",
520
+ }}
232
521
  >
233
- <Button
234
- variant="outline"
235
- size="icon"
236
- accessibilityLabel={label}
237
- onPress={scrollToBottom}
238
- testID={testID}
239
- style={{ borderRadius: tokens.radius.pill, backgroundColor: tokens.color.surface[scheme] }}
240
- >
241
- <Icon size={largerText ? 20 : 16} color={tokens.color.foreground[scheme]} />
242
- </Button>
522
+ {showLabel ? (
523
+ <Pressable
524
+ accessibilityRole="button"
525
+ accessibilityLabel={label}
526
+ onPress={scrollToBottom}
527
+ testID={testID}
528
+ // Wider than 44 with its label; only the height needs slop.
529
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, SCROLL_PILL_HEIGHT)}
530
+ style={({ pressed }) => ({
531
+ minHeight: SCROLL_PILL_HEIGHT,
532
+ flexDirection: "row",
533
+ alignItems: "center",
534
+ gap: 6,
535
+ paddingHorizontal: 12,
536
+ borderRadius: tokens.radius.pill,
537
+ borderWidth: 1,
538
+ borderColor: withAlpha(tokens.color.border[scheme], 0.6),
539
+ backgroundColor: tokens.color.surface[scheme],
540
+ opacity: pressed ? 0.7 : 1,
541
+ })}
542
+ >
543
+ <Icon size={largerText ? 20 : 16} color={tokens.color.muted[scheme]} />
544
+ <Text className={cn(labelSize, "font-instrument-sans")} style={[sizeStyle(labelSize), textStyle("muted", scheme)]}>
545
+ {label}
546
+ </Text>
547
+ </Pressable>
548
+ ) : (
549
+ <Button
550
+ variant="outline"
551
+ size="icon"
552
+ accessibilityLabel={label}
553
+ onPress={scrollToBottom}
554
+ testID={testID}
555
+ style={{ borderRadius: tokens.radius.pill, backgroundColor: tokens.color.surface[scheme] }}
556
+ >
557
+ <Icon size={largerText ? 20 : 16} color={tokens.color.foreground[scheme]} />
558
+ </Button>
559
+ )}
243
560
  </NativeOnlyAnimatedView>
244
561
  );
562
+ if (!keyboard) return button;
563
+ // It rides with the dock: the same move with the keyboard.
564
+ return (
565
+ <keyboard.StickyView bottomOffset={safeArea.bottom} pointerEvents="box-none" style={StyleSheet.absoluteFill}>
566
+ {button}
567
+ </keyboard.StickyView>
568
+ );
245
569
  }
570
+
571
+ const styles = StyleSheet.create({
572
+ fill: {
573
+ flex: 1,
574
+ },
575
+ hidden: {
576
+ opacity: 0,
577
+ },
578
+ });