@vvedantb/native-ui 0.13.0 → 0.17.1

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