@assistant-ui/react-native 0.1.30 → 0.1.31

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.
@@ -1,14 +1,28 @@
1
1
  import {
2
2
  type ComponentType,
3
3
  type FC,
4
+ type ForwardedRef,
4
5
  type ReactNode,
6
+ type RefObject,
5
7
  forwardRef,
6
8
  memo,
7
9
  useCallback,
10
+ useEffect,
11
+ useRef,
8
12
  } from "react";
9
- import { FlatList, type FlatListProps } from "react-native";
13
+ import {
14
+ FlatList,
15
+ type FlatListProps,
16
+ type LayoutChangeEvent,
17
+ type NativeScrollEvent,
18
+ type NativeSyntheticEvent,
19
+ } from "react-native";
10
20
  import type { MessageState, ThreadMessage } from "@assistant-ui/core";
11
- import { RenderChildrenWithAccessor, useAuiState } from "@assistant-ui/store";
21
+ import {
22
+ RenderChildrenWithAccessor,
23
+ useAuiEvent,
24
+ useAuiState,
25
+ } from "@assistant-ui/store";
12
26
  import { MessageByIndexProvider } from "@assistant-ui/core/react";
13
27
 
14
28
  type MessageComponents =
@@ -44,13 +58,22 @@ type MessagesContent =
44
58
  components?: never;
45
59
  };
46
60
 
47
- export type ThreadMessagesProps = Omit<
61
+ export type ThreadMessagesFlatListProps = Omit<
48
62
  FlatListProps<ThreadMessage>,
49
63
  "data" | "renderItem" | "children"
50
64
  > &
51
- MessagesContent;
65
+ MessagesContent & {
66
+ autoScroll?: boolean | undefined;
67
+ scrollToBottomOnRunStart?: boolean | undefined;
68
+ scrollToBottomOnInitialize?: boolean | undefined;
69
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
70
+ };
71
+
72
+ /** @deprecated Use ThreadMessagesFlatListProps instead. */
73
+ export type ThreadMessagesProps = ThreadMessagesFlatListProps;
52
74
 
53
75
  const DEFAULT_SYSTEM_MESSAGE = () => null;
76
+ const AT_BOTTOM_THRESHOLD = 4;
54
77
 
55
78
  const getComponent = (
56
79
  components: MessageComponents,
@@ -155,36 +178,303 @@ const ThreadMessageByChildren = memo(
155
178
  );
156
179
  ThreadMessageByChildren.displayName = "ThreadPrimitive.MessageByChildren";
157
180
 
158
- export const ThreadMessages = forwardRef<
159
- FlatList<ThreadMessage>,
160
- ThreadMessagesProps
161
- >(({ components, children, ...flatListProps }, ref) => {
162
- const messages = useAuiState((s) => s.thread.messages);
181
+ const setForwardedRef = <T,>(ref: ForwardedRef<T>, value: T | null) => {
182
+ if (typeof ref === "function") {
183
+ ref(value);
184
+ } else if (ref) {
185
+ ref.current = value;
186
+ }
187
+ };
163
188
 
164
- const renderItem = useCallback(
165
- ({ index }: { item: ThreadMessage; index: number }) => {
166
- if (children) {
167
- return (
168
- <ThreadMessageByChildren index={index}>
169
- {children}
170
- </ThreadMessageByChildren>
171
- );
189
+ const useComposedFlatListRef = (
190
+ forwardedRef: ForwardedRef<FlatList<ThreadMessage>>,
191
+ ) => {
192
+ const flatListRef = useRef<FlatList<ThreadMessage> | null>(null);
193
+
194
+ const setFlatListRef = useCallback(
195
+ (node: FlatList<ThreadMessage> | null) => {
196
+ flatListRef.current = node;
197
+ setForwardedRef(forwardedRef, node);
198
+ },
199
+ [forwardedRef],
200
+ );
201
+
202
+ return [flatListRef, setFlatListRef] as const;
203
+ };
204
+
205
+ const useThreadMessagesFlatListAutoScroll = ({
206
+ flatListRef,
207
+ hasMessages,
208
+ autoScroll = true,
209
+ scrollToBottomOnRunStart = true,
210
+ scrollToBottomOnInitialize = true,
211
+ scrollToBottomOnThreadSwitch = true,
212
+ }: {
213
+ flatListRef: RefObject<FlatList<ThreadMessage> | null>;
214
+ hasMessages: boolean;
215
+ autoScroll?: boolean | undefined;
216
+ scrollToBottomOnRunStart?: boolean | undefined;
217
+ scrollToBottomOnInitialize?: boolean | undefined;
218
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
219
+ }) => {
220
+ const metricsRef = useRef({
221
+ contentHeight: 0,
222
+ viewportHeight: 0,
223
+ scrollY: 0,
224
+ });
225
+ const isAtBottomRef = useRef(true);
226
+ const lastScrollEventYRef = useRef(0);
227
+ const initializeScrollRequestedRef = useRef(false);
228
+ const pendingScrollToBottomRef = useRef<false | { animated: boolean }>(false);
229
+
230
+ const updateIsAtBottom = useCallback(() => {
231
+ const { contentHeight, scrollY, viewportHeight } = metricsRef.current;
232
+ isAtBottomRef.current =
233
+ contentHeight <= viewportHeight ||
234
+ contentHeight - scrollY - viewportHeight <= AT_BOTTOM_THRESHOLD;
235
+ }, []);
236
+
237
+ // Commanding a scroll records the intended position immediately; the
238
+ // native scroll echo is bridged and throttled, so waiting for it lets a
239
+ // fast stream observe stale metrics and drop out of following.
240
+ const scrollToBottom = useCallback(
241
+ (animated: boolean) => {
242
+ const { contentHeight, viewportHeight } = metricsRef.current;
243
+ metricsRef.current.scrollY = Math.max(0, contentHeight - viewportHeight);
244
+ isAtBottomRef.current = true;
245
+ flatListRef.current?.scrollToEnd({ animated });
246
+ },
247
+ [flatListRef],
248
+ );
249
+
250
+ const handleLayout = useCallback(
251
+ (event: LayoutChangeEvent) => {
252
+ metricsRef.current.viewportHeight = event.nativeEvent.layout.height;
253
+ updateIsAtBottom();
254
+ },
255
+ [updateIsAtBottom],
256
+ );
257
+
258
+ const handleScroll = useCallback(
259
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
260
+ const { contentOffset, contentSize, layoutMeasurement } =
261
+ event.nativeEvent;
262
+ const previousEventY = lastScrollEventYRef.current;
263
+ const wasPinnedToBottom = isAtBottomRef.current;
264
+ lastScrollEventYRef.current = contentOffset.y;
265
+ metricsRef.current = {
266
+ contentHeight: contentSize.height,
267
+ viewportHeight: layoutMeasurement.height,
268
+ scrollY: contentOffset.y,
269
+ };
270
+ updateIsAtBottom();
271
+ const upwardMove = contentOffset.y < previousEventY;
272
+ // Only a deliberate upward move unpins or cancels a pending scroll.
273
+ // Gestures are detected echo-to-echo because a commanded scroll
274
+ // optimistically moves the tracked position ahead of its ascending
275
+ // animation echoes, and those echoes must not unpin mid-flight.
276
+ if (wasPinnedToBottom && !upwardMove) {
277
+ isAtBottomRef.current = true;
278
+ }
279
+ if (!isAtBottomRef.current && upwardMove) {
280
+ pendingScrollToBottomRef.current = false;
281
+ }
282
+ },
283
+ [updateIsAtBottom],
284
+ );
285
+
286
+ const handleContentSizeChange = useCallback(
287
+ (_width: number, height: number) => {
288
+ const metrics = metricsRef.current;
289
+ const previousContentHeight = metrics.contentHeight;
290
+ const wasAtBottom = isAtBottomRef.current;
291
+ metrics.contentHeight = height;
292
+ updateIsAtBottom();
293
+
294
+ // FlatList.scrollToEnd is a no-op before the list has measured, so the
295
+ // initialize and thread-switch scrolls land on the next content-size
296
+ // event, once real metrics exist.
297
+ const pendingScroll = pendingScrollToBottomRef.current;
298
+ if (pendingScroll) {
299
+ pendingScrollToBottomRef.current = false;
300
+ scrollToBottom(pendingScroll.animated);
301
+ return;
172
302
  }
173
- return <ThreadMessageByIndex index={index} components={components!} />;
303
+
304
+ if (!autoScroll) return;
305
+ if (!wasAtBottom) return;
306
+ if (previousContentHeight === 0) return;
307
+ if (height <= previousContentHeight) return;
308
+
309
+ scrollToBottom(false);
174
310
  },
175
- [components, children],
311
+ [autoScroll, scrollToBottom, updateIsAtBottom],
176
312
  );
177
313
 
178
- const keyExtractor = useCallback((item: ThreadMessage) => item.id, []);
314
+ useEffect(() => {
315
+ if (!scrollToBottomOnInitialize) return;
316
+ if (!hasMessages) {
317
+ initializeScrollRequestedRef.current = false;
318
+ return;
319
+ }
320
+ if (initializeScrollRequestedRef.current) return;
321
+
322
+ initializeScrollRequestedRef.current = true;
323
+ pendingScrollToBottomRef.current = { animated: false };
324
+ scrollToBottom(false);
325
+ }, [hasMessages, scrollToBottom, scrollToBottomOnInitialize]);
326
+
327
+ useAuiEvent("thread.runStart", () => {
328
+ if (!scrollToBottomOnRunStart) return;
329
+ pendingScrollToBottomRef.current = { animated: true };
330
+ scrollToBottom(true);
331
+ });
332
+
333
+ useAuiEvent("threadListItem.switchedTo", () => {
334
+ if (!scrollToBottomOnThreadSwitch) return;
335
+ initializeScrollRequestedRef.current = false;
336
+ lastScrollEventYRef.current = 0;
337
+ pendingScrollToBottomRef.current = { animated: false };
338
+ scrollToBottom(false);
339
+ });
340
+
341
+ return {
342
+ handleLayout,
343
+ handleScroll,
344
+ handleContentSizeChange,
345
+ };
346
+ };
347
+
348
+ export const ThreadMessagesFlatList = forwardRef<
349
+ FlatList<ThreadMessage>,
350
+ ThreadMessagesFlatListProps
351
+ >(
352
+ (
353
+ {
354
+ autoScroll,
355
+ components,
356
+ children,
357
+ onContentSizeChange,
358
+ onLayout,
359
+ onScroll,
360
+ scrollEventThrottle,
361
+ scrollToBottomOnInitialize,
362
+ scrollToBottomOnRunStart,
363
+ scrollToBottomOnThreadSwitch,
364
+ ...flatListProps
365
+ },
366
+ forwardedRef,
367
+ ) => {
368
+ const messages = useAuiState((s) => s.thread.messages);
369
+ const [flatListRef, setFlatListRef] = useComposedFlatListRef(forwardedRef);
370
+ const {
371
+ handleContentSizeChange: handleAutoScrollContentSizeChange,
372
+ handleLayout: handleAutoScrollLayout,
373
+ handleScroll: handleAutoScrollScroll,
374
+ } = useThreadMessagesFlatListAutoScroll({
375
+ flatListRef,
376
+ hasMessages: messages.length > 0,
377
+ autoScroll,
378
+ scrollToBottomOnInitialize,
379
+ scrollToBottomOnRunStart,
380
+ scrollToBottomOnThreadSwitch,
381
+ });
382
+
383
+ const renderItem = useCallback(
384
+ ({ index }: { item: ThreadMessage; index: number }) => {
385
+ if (children) {
386
+ return (
387
+ <ThreadMessageByChildren index={index}>
388
+ {children}
389
+ </ThreadMessageByChildren>
390
+ );
391
+ }
392
+ return <ThreadMessageByIndex index={index} components={components!} />;
393
+ },
394
+ [components, children],
395
+ );
396
+
397
+ const keyExtractor = useCallback((item: ThreadMessage) => item.id, []);
398
+
399
+ const scrollTracking =
400
+ (autoScroll ?? true) ||
401
+ (scrollToBottomOnInitialize ?? true) ||
402
+ (scrollToBottomOnRunStart ?? true) ||
403
+ (scrollToBottomOnThreadSwitch ?? true);
404
+
405
+ const handleLayout = useCallback(
406
+ (event: LayoutChangeEvent) => {
407
+ handleAutoScrollLayout(event);
408
+ onLayout?.(event);
409
+ },
410
+ [handleAutoScrollLayout, onLayout],
411
+ );
412
+
413
+ const handleScroll = useCallback(
414
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
415
+ handleAutoScrollScroll(event);
416
+ onScroll?.(event);
417
+ },
418
+ [handleAutoScrollScroll, onScroll],
419
+ );
420
+
421
+ const handleContentSizeChange = useCallback(
422
+ (width: number, height: number) => {
423
+ handleAutoScrollContentSizeChange(width, height);
424
+ onContentSizeChange?.(width, height);
425
+ },
426
+ [handleAutoScrollContentSizeChange, onContentSizeChange],
427
+ );
428
+
429
+ return (
430
+ <FlatList
431
+ ref={setFlatListRef}
432
+ data={messages as unknown as ThreadMessage[]}
433
+ renderItem={renderItem}
434
+ keyExtractor={keyExtractor}
435
+ {...(scrollTracking
436
+ ? {
437
+ onContentSizeChange: handleContentSizeChange,
438
+ onLayout: handleLayout,
439
+ onScroll: handleScroll,
440
+ scrollEventThrottle: scrollEventThrottle ?? 16,
441
+ }
442
+ : {
443
+ ...(onContentSizeChange && { onContentSizeChange }),
444
+ ...(onLayout && { onLayout }),
445
+ ...(onScroll && { onScroll }),
446
+ ...(scrollEventThrottle !== undefined && { scrollEventThrottle }),
447
+ })}
448
+ {...flatListProps}
449
+ />
450
+ );
451
+ },
452
+ );
453
+ ThreadMessagesFlatList.displayName = "ThreadPrimitive.MessagesFlatList";
179
454
 
180
- return (
181
- <FlatList
455
+ /** @deprecated Use ThreadPrimitive.MessagesFlatList instead. */
456
+ export const ThreadMessages = forwardRef<
457
+ FlatList<ThreadMessage>,
458
+ ThreadMessagesProps
459
+ >(
460
+ (
461
+ {
462
+ autoScroll = false,
463
+ scrollToBottomOnInitialize = false,
464
+ scrollToBottomOnRunStart = false,
465
+ scrollToBottomOnThreadSwitch = false,
466
+ ...props
467
+ },
468
+ ref,
469
+ ) => (
470
+ <ThreadMessagesFlatList
182
471
  ref={ref}
183
- data={messages as unknown as ThreadMessage[]}
184
- renderItem={renderItem}
185
- keyExtractor={keyExtractor}
186
- {...flatListProps}
472
+ autoScroll={autoScroll}
473
+ scrollToBottomOnInitialize={scrollToBottomOnInitialize}
474
+ scrollToBottomOnRunStart={scrollToBottomOnRunStart}
475
+ scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
476
+ {...props}
187
477
  />
188
- );
189
- });
478
+ ),
479
+ );
190
480
  ThreadMessages.displayName = "ThreadPrimitive.Messages";
@@ -3,6 +3,8 @@ export {
3
3
  type ThreadRootProps as RootProps,
4
4
  } from "./thread/ThreadRoot";
5
5
  export {
6
+ ThreadMessagesFlatList as MessagesFlatList,
7
+ type ThreadMessagesFlatListProps as MessagesFlatListProps,
6
8
  ThreadMessages as Messages,
7
9
  type ThreadMessagesProps as MessagesProps,
8
10
  } from "./thread/ThreadMessages";