panelui-native 0.6.0 → 0.9.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 (181) hide show
  1. package/README.md +1 -1
  2. package/lib/module/components/accordion/index.js +7 -5
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +3 -6
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/attachment/index.js +219 -0
  7. package/lib/module/components/attachment/index.js.map +1 -0
  8. package/lib/module/components/bottom-sheet/index.js +82 -4
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +36 -1
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/dialog/index.js +6 -2
  13. package/lib/module/components/dialog/index.js.map +1 -1
  14. package/lib/module/components/empty-state/index.js +98 -22
  15. package/lib/module/components/empty-state/index.js.map +1 -1
  16. package/lib/module/components/inline-select/index.js +171 -46
  17. package/lib/module/components/inline-select/index.js.map +1 -1
  18. package/lib/module/components/input/index.js +28 -5
  19. package/lib/module/components/input/index.js.map +1 -1
  20. package/lib/module/components/input-group/index.js +0 -1
  21. package/lib/module/components/input-group/index.js.map +1 -1
  22. package/lib/module/components/item/index.js +359 -0
  23. package/lib/module/components/item/index.js.map +1 -0
  24. package/lib/module/components/label/index.js +5 -3
  25. package/lib/module/components/label/index.js.map +1 -1
  26. package/lib/module/components/line-chart/index.js +1024 -0
  27. package/lib/module/components/line-chart/index.js.map +1 -0
  28. package/lib/module/components/marker/index.js +190 -0
  29. package/lib/module/components/marker/index.js.map +1 -0
  30. package/lib/module/components/message/index.js +334 -0
  31. package/lib/module/components/message/index.js.map +1 -0
  32. package/lib/module/components/message-scroller/index.js +451 -0
  33. package/lib/module/components/message-scroller/index.js.map +1 -0
  34. package/lib/module/components/popover/index.js +500 -0
  35. package/lib/module/components/popover/index.js.map +1 -0
  36. package/lib/module/components/radio-group/index.js +104 -16
  37. package/lib/module/components/radio-group/index.js.map +1 -1
  38. package/lib/module/components/scroll-fade/index.js +119 -71
  39. package/lib/module/components/scroll-fade/index.js.map +1 -1
  40. package/lib/module/components/select/index.js +35 -6
  41. package/lib/module/components/select/index.js.map +1 -1
  42. package/lib/module/components/separator/index.js +91 -0
  43. package/lib/module/components/separator/index.js.map +1 -0
  44. package/lib/module/components/shimmer/index.js +111 -64
  45. package/lib/module/components/shimmer/index.js.map +1 -1
  46. package/lib/module/components/surface/index.js +24 -4
  47. package/lib/module/components/surface/index.js.map +1 -1
  48. package/lib/module/components/switch/index.js +20 -1
  49. package/lib/module/components/switch/index.js.map +1 -1
  50. package/lib/module/components/toast/index.js +7 -8
  51. package/lib/module/components/toast/index.js.map +1 -1
  52. package/lib/module/components/typography/index.js +3 -3
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-breakpoint.js +2 -2
  56. package/lib/module/hooks/use-copy-to-clipboard.js +0 -2
  57. package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -1
  58. package/lib/module/hooks/use-keyboard-avoidance.js +120 -0
  59. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -0
  60. package/lib/module/icons/index.js +73 -4
  61. package/lib/module/icons/index.js.map +1 -1
  62. package/lib/module/index.js +13 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/native/index.js +53 -0
  65. package/lib/module/native/index.js.map +1 -0
  66. package/lib/module/primitives/keyboard-avoider.js +40 -0
  67. package/lib/module/primitives/keyboard-avoider.js.map +1 -0
  68. package/lib/module/primitives/scrim.js +72 -0
  69. package/lib/module/primitives/scrim.js.map +1 -0
  70. package/lib/module/providers/panel-ui-provider.js +24 -4
  71. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  72. package/lib/module/theme/use-theme.js +1 -1
  73. package/lib/typescript/src/components/accordion/index.d.ts +7 -5
  74. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  75. package/lib/typescript/src/components/alert/index.d.ts +3 -6
  76. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/attachment/index.d.ts +93 -0
  78. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -0
  79. package/lib/typescript/src/components/bottom-sheet/index.d.ts +27 -2
  80. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/button/index.d.ts +11 -0
  82. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/dialog/index.d.ts +7 -1
  84. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/empty-state/index.d.ts +156 -7
  86. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/inline-select/index.d.ts +29 -6
  88. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/input/index.d.ts +13 -0
  90. package/lib/typescript/src/components/input/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/input-group/index.d.ts +0 -1
  92. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/item/index.d.ts +286 -0
  94. package/lib/typescript/src/components/item/index.d.ts.map +1 -0
  95. package/lib/typescript/src/components/label/index.d.ts +5 -3
  96. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/line-chart/index.d.ts +247 -0
  98. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -0
  99. package/lib/typescript/src/components/marker/index.d.ts +137 -0
  100. package/lib/typescript/src/components/marker/index.d.ts.map +1 -0
  101. package/lib/typescript/src/components/message/index.d.ts +177 -0
  102. package/lib/typescript/src/components/message/index.d.ts.map +1 -0
  103. package/lib/typescript/src/components/message-scroller/index.d.ts +150 -0
  104. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -0
  105. package/lib/typescript/src/components/popover/index.d.ts +130 -0
  106. package/lib/typescript/src/components/popover/index.d.ts.map +1 -0
  107. package/lib/typescript/src/components/radio-group/index.d.ts +32 -0
  108. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  109. package/lib/typescript/src/components/scroll-fade/index.d.ts +18 -9
  110. package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/select/index.d.ts +16 -1
  112. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/separator/index.d.ts +47 -0
  114. package/lib/typescript/src/components/separator/index.d.ts.map +1 -0
  115. package/lib/typescript/src/components/shimmer/index.d.ts +38 -20
  116. package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/surface/index.d.ts +51 -2
  118. package/lib/typescript/src/components/surface/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/switch/index.d.ts +10 -0
  120. package/lib/typescript/src/components/switch/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/toast/index.d.ts +4 -4
  122. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  123. package/lib/typescript/src/hooks/index.d.ts +1 -0
  124. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  125. package/lib/typescript/src/hooks/use-breakpoint.d.ts +2 -2
  126. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +0 -2
  127. package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -1
  128. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +20 -0
  129. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -0
  130. package/lib/typescript/src/icons/index.d.ts +10 -4
  131. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  132. package/lib/typescript/src/index.d.ts +11 -1
  133. package/lib/typescript/src/index.d.ts.map +1 -1
  134. package/lib/typescript/src/native/index.d.ts +35 -0
  135. package/lib/typescript/src/native/index.d.ts.map +1 -0
  136. package/lib/typescript/src/primitives/keyboard-avoider.d.ts +22 -0
  137. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -0
  138. package/lib/typescript/src/primitives/scrim.d.ts +21 -0
  139. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -0
  140. package/lib/typescript/src/providers/panel-ui-provider.d.ts +1 -1
  141. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  142. package/lib/typescript/src/theme/use-theme.d.ts +1 -1
  143. package/package.json +16 -3
  144. package/src/components/accordion/index.tsx +7 -5
  145. package/src/components/alert/index.tsx +3 -6
  146. package/src/components/attachment/index.tsx +279 -0
  147. package/src/components/bottom-sheet/index.tsx +102 -2
  148. package/src/components/button/index.tsx +52 -0
  149. package/src/components/dialog/index.tsx +10 -1
  150. package/src/components/empty-state/index.tsx +64 -19
  151. package/src/components/inline-select/index.tsx +191 -44
  152. package/src/components/input/index.tsx +39 -3
  153. package/src/components/input-group/index.tsx +0 -1
  154. package/src/components/item/index.tsx +387 -0
  155. package/src/components/label/index.tsx +5 -3
  156. package/src/components/line-chart/index.tsx +1224 -0
  157. package/src/components/marker/index.tsx +194 -0
  158. package/src/components/message/index.tsx +336 -0
  159. package/src/components/message-scroller/index.tsx +574 -0
  160. package/src/components/popover/index.tsx +599 -0
  161. package/src/components/radio-group/index.tsx +108 -17
  162. package/src/components/scroll-fade/index.tsx +163 -51
  163. package/src/components/select/index.tsx +54 -9
  164. package/src/components/separator/index.tsx +97 -0
  165. package/src/components/shimmer/index.tsx +151 -58
  166. package/src/components/surface/index.tsx +32 -5
  167. package/src/components/switch/index.tsx +27 -1
  168. package/src/components/toast/index.tsx +7 -8
  169. package/src/components/typography/index.tsx +3 -3
  170. package/src/hooks/index.ts +5 -0
  171. package/src/hooks/use-breakpoint.ts +2 -2
  172. package/src/hooks/use-copy-to-clipboard.ts +0 -2
  173. package/src/hooks/use-keyboard-avoidance.ts +140 -0
  174. package/src/icons/index.tsx +52 -4
  175. package/src/index.ts +92 -0
  176. package/src/native/index.ts +79 -0
  177. package/src/primitives/keyboard-avoider.tsx +49 -0
  178. package/src/primitives/scrim.tsx +95 -0
  179. package/src/providers/panel-ui-provider.tsx +30 -9
  180. package/src/theme/use-theme.ts +1 -1
  181. package/theme.css +63 -3
@@ -0,0 +1,574 @@
1
+ /**
2
+ * MessageScroller — the scroll behaviour a chat transcript needs.
3
+ *
4
+ * A transcript is the one list where the interesting end is the bottom, the
5
+ * content grows while you are reading it, and history is added to the top. A
6
+ * plain scroll view gets all three wrong: it opens at the top, it stays put
7
+ * while a reply streams in below the fold, and it jumps a screen when older
8
+ * messages load. This owns those three behaviours so a screen does not have to
9
+ * rebuild them.
10
+ *
11
+ * ```tsx
12
+ * <MessageScroller autoScroll className="flex-1">
13
+ * <MessageScroller.Viewport>
14
+ * <MessageScroller.Content>
15
+ * {messages.map((m) => (
16
+ * <MessageScroller.Item key={m.id} messageId={m.id} scrollAnchor={m.role === 'user'}>
17
+ * <Message>…</Message>
18
+ * </MessageScroller.Item>
19
+ * ))}
20
+ * </MessageScroller.Content>
21
+ * </MessageScroller.Viewport>
22
+ * <MessageScroller.Button />
23
+ * </MessageScroller>
24
+ * ```
25
+ *
26
+ * The rule behind every part of it: **the reader's position is theirs**. New
27
+ * content follows the bottom only while they are already at the bottom, and
28
+ * scrolling away hands control back to them until they ask for it again.
29
+ * Content added above them never moves what they are looking at.
30
+ *
31
+ * Needs a bounded height — from `flex-1` in a column, or an explicit one.
32
+ * Given an unbounded parent it grows to fit its content and never scrolls.
33
+ */
34
+ import {
35
+ createContext,
36
+ useCallback,
37
+ useContext,
38
+ useEffect,
39
+ useMemo,
40
+ useRef,
41
+ useState,
42
+ type ReactNode,
43
+ } from 'react';
44
+ import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
45
+ import Animated, {
46
+ runOnJS,
47
+ useAnimatedRef,
48
+ useAnimatedScrollHandler,
49
+ useAnimatedStyle,
50
+ useDerivedValue,
51
+ useSharedValue,
52
+ withTiming,
53
+ type AnimatedScrollViewProps,
54
+ } from 'react-native-reanimated';
55
+ import { useCSSVariable } from 'uniwind';
56
+ import { ChevronDownIcon } from '../../icons';
57
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
58
+ import { cn } from '../../utils/cn';
59
+
60
+ /** Within this many pixels of the bottom still counts as being at the bottom. */
61
+ const END_THRESHOLD = 24;
62
+ /**
63
+ * How much of the previous turn is left showing above an anchored one. Scrolling
64
+ * a turn flush to the top reads as content having been cut off; a sliver of the
65
+ * message before it says "this is where you are", not "this is the beginning".
66
+ */
67
+ const ANCHOR_PEEK = 24;
68
+
69
+ export type MessageScrollerPosition = 'start' | 'end' | 'last-anchor';
70
+
71
+ interface MessageScrollerContextValue {
72
+ scrollRef: ReturnType<typeof useAnimatedRef<Animated.ScrollView>>;
73
+ /** 1 while the reader is at the live edge, 0 otherwise. Drives the button. */
74
+ atEnd: ReturnType<typeof useSharedValue<number>>;
75
+ atEndJS: boolean;
76
+ setAtEndJS: (atEnd: boolean) => void;
77
+ autoScroll: boolean;
78
+ preserveScrollOnPrepend: boolean;
79
+ defaultScrollPosition: MessageScrollerPosition;
80
+ /** id → y offset inside the content, written by each Item on layout. */
81
+ itemY: React.RefObject<Map<string, number>>;
82
+ anchors: React.RefObject<string[]>;
83
+ /** Whether new content should pull the viewport down with it. */
84
+ following: React.RefObject<boolean>;
85
+ registerItem: (id: string, y: number, anchor: boolean) => void;
86
+ unregisterItem: (id: string) => void;
87
+ scrollToEnd: (animated?: boolean) => void;
88
+ scrollToStart: (animated?: boolean) => void;
89
+ scrollToMessage: (id: string, animated?: boolean) => void;
90
+ currentAnchorId: string | null;
91
+ setCurrentAnchorId: (id: string | null) => void;
92
+ }
93
+
94
+ const MessageScrollerContext = createContext<MessageScrollerContextValue | null>(null);
95
+
96
+ function useScroller(component: string): MessageScrollerContextValue {
97
+ const context = useContext(MessageScrollerContext);
98
+ if (!context) {
99
+ throw new Error(`${component} must be used within a <MessageScroller>`);
100
+ }
101
+ return context;
102
+ }
103
+
104
+ /** Scroll commands, for a control that lives outside the viewport. */
105
+ export function useMessageScroller() {
106
+ const { scrollToEnd, scrollToStart, scrollToMessage } = useScroller('useMessageScroller');
107
+ return { scrollToEnd, scrollToStart, scrollToMessage };
108
+ }
109
+
110
+ /**
111
+ * Where the reader is: the turn they are inside, and whether they are at the
112
+ * live edge. Both settle after a scroll rather than updating per frame — this
113
+ * is for a header that names the current turn, not for anything animated.
114
+ */
115
+ export function useMessageScrollerVisibility() {
116
+ const { currentAnchorId, atEndJS } = useScroller('useMessageScrollerVisibility');
117
+ return { currentAnchorId, atEnd: atEndJS };
118
+ }
119
+
120
+ export interface MessageScrollerProps extends ViewProps {
121
+ className?: string;
122
+ /**
123
+ * Follow new content down as it arrives — but only while the reader is
124
+ * already at the bottom. Scrolling up disengages it until they come back or
125
+ * press the button.
126
+ */
127
+ autoScroll?: boolean;
128
+ /**
129
+ * Keep the reader on the same message when older ones are added above. Without
130
+ * it, loading history throws them a screen backwards.
131
+ */
132
+ preserveScrollOnPrepend?: boolean;
133
+ /**
134
+ * Where a freshly mounted transcript opens. `last-anchor` is the one to want
135
+ * for a saved thread: it lands on the last turn that started something,
136
+ * rather than at the very bottom of whatever the reply happened to be.
137
+ */
138
+ defaultScrollPosition?: MessageScrollerPosition;
139
+ children?: ReactNode;
140
+ }
141
+
142
+ function MessageScrollerRoot({
143
+ className,
144
+ autoScroll = false,
145
+ preserveScrollOnPrepend = true,
146
+ defaultScrollPosition = 'end',
147
+ children,
148
+ ...props
149
+ }: MessageScrollerProps) {
150
+ const scrollRef = useAnimatedRef<Animated.ScrollView>();
151
+ const atEnd = useSharedValue(1);
152
+ const [atEndJS, setAtEndJS] = useState(true);
153
+ const [currentAnchorId, setCurrentAnchorId] = useState<string | null>(null);
154
+
155
+ const itemY = useRef(new Map<string, number>());
156
+ const anchors = useRef<string[]>([]);
157
+ const following = useRef(autoScroll);
158
+
159
+ const registerItem = useCallback((id: string, y: number, anchor: boolean) => {
160
+ itemY.current.set(id, y);
161
+ const list = anchors.current;
162
+ if (anchor && !list.includes(id)) list.push(id);
163
+ if (!anchor && list.includes(id)) anchors.current = list.filter((a) => a !== id);
164
+ }, []);
165
+
166
+ const unregisterItem = useCallback((id: string) => {
167
+ itemY.current.delete(id);
168
+ anchors.current = anchors.current.filter((a) => a !== id);
169
+ }, []);
170
+
171
+ const scrollToEnd = useCallback(
172
+ (animated = true) => {
173
+ following.current = true;
174
+ scrollRef.current?.scrollToEnd({ animated });
175
+ },
176
+ [scrollRef]
177
+ );
178
+
179
+ const scrollToStart = useCallback(
180
+ (animated = true) => {
181
+ following.current = false;
182
+ scrollRef.current?.scrollTo({ y: 0, animated });
183
+ },
184
+ [scrollRef]
185
+ );
186
+
187
+ const scrollToMessage = useCallback(
188
+ (id: string, animated = true) => {
189
+ const y = itemY.current.get(id);
190
+ if (y === undefined) return;
191
+ following.current = false;
192
+ scrollRef.current?.scrollTo({ y: Math.max(0, y - ANCHOR_PEEK), animated });
193
+ },
194
+ [scrollRef]
195
+ );
196
+
197
+ const context = useMemo<MessageScrollerContextValue>(
198
+ () => ({
199
+ scrollRef,
200
+ atEnd,
201
+ atEndJS,
202
+ setAtEndJS,
203
+ autoScroll,
204
+ preserveScrollOnPrepend,
205
+ defaultScrollPosition,
206
+ itemY,
207
+ anchors,
208
+ following,
209
+ registerItem,
210
+ unregisterItem,
211
+ scrollToEnd,
212
+ scrollToStart,
213
+ scrollToMessage,
214
+ currentAnchorId,
215
+ setCurrentAnchorId,
216
+ }),
217
+ [
218
+ scrollRef,
219
+ atEnd,
220
+ atEndJS,
221
+ autoScroll,
222
+ preserveScrollOnPrepend,
223
+ defaultScrollPosition,
224
+ registerItem,
225
+ unregisterItem,
226
+ scrollToEnd,
227
+ scrollToStart,
228
+ scrollToMessage,
229
+ currentAnchorId,
230
+ ]
231
+ );
232
+
233
+ return (
234
+ <MessageScrollerContext.Provider value={context}>
235
+ <View className={cn('relative overflow-hidden', className)} {...props}>
236
+ {children}
237
+ </View>
238
+ </MessageScrollerContext.Provider>
239
+ );
240
+ }
241
+ MessageScrollerRoot.displayName = 'MessageScroller';
242
+
243
+ /**
244
+ * The four scroll events are owned by the component and taken off the props
245
+ * type rather than merged with a consumer's. They are not decoration on top of
246
+ * a scroll view — they *are* the behaviour, and a call site that quietly
247
+ * replaced one would look like a bug in the scrolling.
248
+ */
249
+ export interface MessageScrollerViewportProps
250
+ extends Omit<
251
+ AnimatedScrollViewProps,
252
+ 'onScroll' | 'onScrollEndDrag' | 'onMomentumScrollEnd' | 'onContentSizeChange'
253
+ > {
254
+ className?: string;
255
+ children?: ReactNode;
256
+ }
257
+
258
+ /**
259
+ * The scrollable itself. Owns the three behaviours, because all three are
260
+ * reactions to scroll and content-size events and this is where they arrive.
261
+ */
262
+ function MessageScrollerViewport({
263
+ className,
264
+ children,
265
+ ...props
266
+ }: MessageScrollerViewportProps) {
267
+ const {
268
+ scrollRef,
269
+ atEnd,
270
+ setAtEndJS,
271
+ autoScroll,
272
+ preserveScrollOnPrepend,
273
+ defaultScrollPosition,
274
+ itemY,
275
+ anchors,
276
+ following,
277
+ scrollToEnd,
278
+ scrollToMessage,
279
+ setCurrentAnchorId,
280
+ } = useScroller('MessageScroller.Viewport');
281
+
282
+ const contentHeight = useRef(0);
283
+ const previousY = useRef(new Map<string, number>());
284
+ const opened = useRef(false);
285
+ /*
286
+ * The live scroll offset, written on the UI thread and read straight from JS
287
+ * when the prepend correction needs it. A shared value rather than a ref
288
+ * updated through `runOnJS`: this is read a few times a second and written
289
+ * sixty, so the cheap side should be the write.
290
+ */
291
+ const offsetY = useSharedValue(0);
292
+
293
+ /*
294
+ * The at-end test runs per frame on the UI thread, but it only ever produces
295
+ * a boolean — so JS is told about it when the boolean flips, not when the
296
+ * offset changes. A transcript scrolls constantly; re-rendering on every
297
+ * frame of it would be the most expensive thing on the screen.
298
+ */
299
+ const publishAtEnd = useCallback(
300
+ (next: boolean) => {
301
+ following.current = autoScroll && next;
302
+ setAtEndJS(next);
303
+ },
304
+ [autoScroll, following, setAtEndJS]
305
+ );
306
+
307
+ const scrollHandler = useAnimatedScrollHandler({
308
+ onScroll: (event) => {
309
+ const { contentOffset, contentSize, layoutMeasurement } = event;
310
+ offsetY.value = contentOffset.y;
311
+
312
+ const distance = contentSize.height - layoutMeasurement.height - contentOffset.y;
313
+ const next = distance <= END_THRESHOLD ? 1 : 0;
314
+ if (next !== atEnd.value) {
315
+ atEnd.value = next;
316
+ runOnJS(publishAtEnd)(next === 1);
317
+ }
318
+ },
319
+ });
320
+
321
+ /**
322
+ * Which turn the reader is inside: the last anchor that has passed the top of
323
+ * the viewport. Settled after a scroll rather than tracked per frame — the
324
+ * answer is for a header, and a header that changes mid-flick is worse than
325
+ * one that changes when the flick lands.
326
+ */
327
+ const settleAnchor = () => {
328
+ let current: string | null = null;
329
+ let bestY = -Infinity;
330
+ for (const id of anchors.current) {
331
+ const y = itemY.current.get(id);
332
+ if (y === undefined) continue;
333
+ if (y <= offsetY.value + ANCHOR_PEEK && y > bestY) {
334
+ bestY = y;
335
+ current = id;
336
+ }
337
+ }
338
+ setCurrentAnchorId(current);
339
+ };
340
+
341
+ const onContentSizeChange = (_width: number, height: number) => {
342
+ const previousHeight = contentHeight.current;
343
+ contentHeight.current = height;
344
+
345
+ // The very first measurement is the transcript opening, not new content.
346
+ if (!opened.current) {
347
+ opened.current = true;
348
+ previousY.current = new Map(itemY.current);
349
+ openAt(defaultScrollPosition);
350
+ return;
351
+ }
352
+
353
+ const grew = height - previousHeight;
354
+
355
+ if (grew > 0) {
356
+ if (following.current) {
357
+ scrollToEnd(true);
358
+ } else if (preserveScrollOnPrepend) {
359
+ // Content that appeared *above* the reader pushes everything down by
360
+ // the same amount. Measuring that shift on a message they can already
361
+ // see — rather than trusting the height delta — is what makes this
362
+ // correct when a message is prepended and another edits itself in the
363
+ // same commit.
364
+ const shift = topmostShift(previousY.current, itemY.current);
365
+ if (shift > 0.5) {
366
+ scrollRef.current?.scrollTo({ y: offsetY.value + shift, animated: false });
367
+ }
368
+ }
369
+ }
370
+
371
+ previousY.current = new Map(itemY.current);
372
+ settleAnchor();
373
+ };
374
+
375
+ const openAt = (position: MessageScrollerPosition) => {
376
+ if (position === 'start') return;
377
+ if (position === 'end') {
378
+ scrollToEnd(false);
379
+ return;
380
+ }
381
+ const last = anchors.current[anchors.current.length - 1];
382
+ if (last) scrollToMessage(last, false);
383
+ else scrollToEnd(false);
384
+ };
385
+
386
+ useEffect(() => {
387
+ following.current = autoScroll && atEnd.value === 1;
388
+ }, [autoScroll, following, atEnd]);
389
+
390
+ return (
391
+ <Animated.ScrollView
392
+ ref={scrollRef}
393
+ accessibilityRole="list"
394
+ accessibilityLabel="Messages"
395
+ scrollEventThrottle={16}
396
+ showsVerticalScrollIndicator={false}
397
+ className={cn('flex-1', className)}
398
+ {...props}
399
+ // After the spread: these four are the component's reason to exist, and
400
+ // silently losing one of them to a passed-through prop would look like a
401
+ // bug in the scrolling rather than in the call site.
402
+ onScroll={scrollHandler}
403
+ onScrollEndDrag={settleAnchor}
404
+ onMomentumScrollEnd={settleAnchor}
405
+ onContentSizeChange={onContentSizeChange}
406
+ >
407
+ {children}
408
+ </Animated.ScrollView>
409
+ );
410
+ }
411
+ MessageScrollerViewport.displayName = 'MessageScroller.Viewport';
412
+
413
+ /**
414
+ * How far the transcript shifted, measured on the message closest to the top
415
+ * that was there before and is still there.
416
+ */
417
+ function topmostShift(before: Map<string, number>, after: Map<string, number>): number {
418
+ let bestY = Infinity;
419
+ let shift = 0;
420
+ for (const [id, y] of before) {
421
+ const now = after.get(id);
422
+ if (now === undefined || y >= bestY) continue;
423
+ bestY = y;
424
+ shift = now - y;
425
+ }
426
+ return shift;
427
+ }
428
+
429
+ export interface MessageScrollerContentProps extends ViewProps {
430
+ className?: string;
431
+ children?: ReactNode;
432
+ }
433
+
434
+ /** The transcript column. Announces additions rather than the whole list. */
435
+ function MessageScrollerContent({
436
+ className,
437
+ children,
438
+ ...props
439
+ }: MessageScrollerContentProps) {
440
+ return (
441
+ <View
442
+ accessibilityLiveRegion="polite"
443
+ // Extra room at the foot so the last turn clears the floating jump
444
+ // button instead of scrolling under it.
445
+ className={cn('gap-3 p-4 pb-16', className)}
446
+ {...props}
447
+ >
448
+ {children}
449
+ </View>
450
+ );
451
+ }
452
+ MessageScrollerContent.displayName = 'MessageScroller.Content';
453
+
454
+ export interface MessageScrollerItemProps extends ViewProps {
455
+ className?: string;
456
+ /** Stable id for this turn. `scrollToMessage` takes the same value. */
457
+ messageId: string;
458
+ /**
459
+ * Marks this row as the start of a turn. `defaultScrollPosition="last-anchor"`
460
+ * opens on the last one, and it is what a saved thread should land on — the
461
+ * question, not the tail of the answer to it.
462
+ */
463
+ scrollAnchor?: boolean;
464
+ children?: ReactNode;
465
+ }
466
+
467
+ /**
468
+ * One row of the transcript. It exists to be measured: without a boundary per
469
+ * turn there is nothing to scroll *to*, and nothing to measure the prepend
470
+ * shift against.
471
+ */
472
+ function MessageScrollerItem({
473
+ className,
474
+ messageId,
475
+ scrollAnchor = false,
476
+ children,
477
+ ...props
478
+ }: MessageScrollerItemProps) {
479
+ const { registerItem, unregisterItem } = useScroller('MessageScroller.Item');
480
+
481
+ const onLayout = (event: LayoutChangeEvent) => {
482
+ registerItem(messageId, event.nativeEvent.layout.y, scrollAnchor);
483
+ props.onLayout?.(event);
484
+ };
485
+
486
+ useEffect(() => () => unregisterItem(messageId), [messageId, unregisterItem]);
487
+
488
+ return (
489
+ <View className={className} {...props} onLayout={onLayout}>
490
+ {children}
491
+ </View>
492
+ );
493
+ }
494
+ MessageScrollerItem.displayName = 'MessageScroller.Item';
495
+
496
+ export interface MessageScrollerButtonProps extends ViewProps {
497
+ className?: string;
498
+ /** `end` jumps to the newest message, `start` to the beginning of the thread. */
499
+ target?: 'start' | 'end';
500
+ accessibilityLabel?: string;
501
+ children?: ReactNode;
502
+ }
503
+
504
+ /**
505
+ * The way back to the live edge, shown only when there is one to go back to.
506
+ *
507
+ * Its visibility is driven from the same shared value the scroll handler
508
+ * writes, so it fades on the UI thread while the transcript is still moving.
509
+ */
510
+ function MessageScrollerButton({
511
+ className,
512
+ target = 'end',
513
+ accessibilityLabel,
514
+ children,
515
+ ...props
516
+ }: MessageScrollerButtonProps) {
517
+ const { atEnd, scrollToEnd, scrollToStart } = useScroller('MessageScroller.Button');
518
+ const tint = useCSSVariable('--color-foreground');
519
+
520
+ // Shown when away from the edge the button points at.
521
+ const shown = useDerivedValue(() =>
522
+ withTiming(target === 'end' ? 1 - atEnd.value : atEnd.value, { duration: 160 })
523
+ );
524
+
525
+ const style = useAnimatedStyle(() => ({
526
+ opacity: shown.value,
527
+ transform: [{ translateY: (1 - shown.value) * 8 }, { scale: 0.92 + 0.08 * shown.value }],
528
+ }));
529
+
530
+ const [interactive, setInteractive] = useState(false);
531
+ useDerivedValue(() => {
532
+ const next = shown.value > 0.5;
533
+ if (next !== interactive) runOnJS(setInteractive)(next);
534
+ });
535
+
536
+ return (
537
+ <Animated.View
538
+ // Never a focus stop while it is invisible — a screen reader landing on
539
+ // a button nobody can see is worse than not having one.
540
+ pointerEvents={interactive ? 'auto' : 'none'}
541
+ accessibilityElementsHidden={!interactive}
542
+ importantForAccessibility={interactive ? 'auto' : 'no-hide-descendants'}
543
+ style={style}
544
+ className={cn('absolute bottom-6 self-center', className)}
545
+ {...props}
546
+ >
547
+ <AnimatedPressable
548
+ accessibilityRole="button"
549
+ accessibilityLabel={
550
+ accessibilityLabel ??
551
+ (target === 'end' ? 'Jump to the latest message' : 'Jump to the start')
552
+ }
553
+ onPress={() => (target === 'end' ? scrollToEnd(true) : scrollToStart(true))}
554
+ className="h-10 w-10 items-center justify-center rounded-full border border-border bg-surface shadow-lg"
555
+ >
556
+ {children ?? (
557
+ <ChevronDownIcon
558
+ size={18}
559
+ color={typeof tint === 'string' ? tint : undefined}
560
+ style={target === 'start' ? { transform: [{ rotate: '180deg' }] } : undefined}
561
+ />
562
+ )}
563
+ </AnimatedPressable>
564
+ </Animated.View>
565
+ );
566
+ }
567
+ MessageScrollerButton.displayName = 'MessageScroller.Button';
568
+
569
+ export const MessageScroller = Object.assign(MessageScrollerRoot, {
570
+ Viewport: MessageScrollerViewport,
571
+ Content: MessageScrollerContent,
572
+ Item: MessageScrollerItem,
573
+ Button: MessageScrollerButton,
574
+ });