@assistant-ui/react-native 0.1.45 → 0.1.46

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 (33) hide show
  1. package/dist/index.d.ts +4 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/primitives/message.d.ts +1 -0
  5. package/dist/primitives/message.d.ts.map +1 -1
  6. package/dist/primitives/message.js +3 -2
  7. package/dist/primitives/message.js.map +1 -1
  8. package/dist/primitives/thread/ThreadMessages.d.ts +7 -5
  9. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  10. package/dist/primitives/thread/ThreadMessages.js +109 -376
  11. package/dist/primitives/thread/ThreadMessages.js.map +1 -1
  12. package/dist/primitives/thread/ThreadRowsFlatList.d.ts +29 -0
  13. package/dist/primitives/thread/ThreadRowsFlatList.d.ts.map +1 -0
  14. package/dist/primitives/thread/ThreadRowsFlatList.js +226 -0
  15. package/dist/primitives/thread/ThreadRowsFlatList.js.map +1 -0
  16. package/dist/primitives/thread/flatListScroll.d.ts +52 -0
  17. package/dist/primitives/thread/flatListScroll.d.ts.map +1 -0
  18. package/dist/primitives/thread/flatListScroll.js +380 -0
  19. package/dist/primitives/thread/flatListScroll.js.map +1 -0
  20. package/dist/primitives/thread.d.ts +2 -0
  21. package/dist/primitives/thread.d.ts.map +1 -1
  22. package/dist/primitives/thread.js +5 -2
  23. package/dist/primitives/thread.js.map +1 -1
  24. package/package.json +4 -3
  25. package/src/index.ts +18 -1
  26. package/src/primitives/message.test.tsx +74 -0
  27. package/src/primitives/message.ts +1 -0
  28. package/src/primitives/thread/ThreadMessages.test.tsx +28 -1
  29. package/src/primitives/thread/ThreadMessages.tsx +49 -343
  30. package/src/primitives/thread/ThreadRowsFlatList.test.tsx +253 -0
  31. package/src/primitives/thread/ThreadRowsFlatList.tsx +159 -0
  32. package/src/primitives/thread/flatListScroll.ts +467 -0
  33. package/src/primitives/thread.ts +5 -0
@@ -0,0 +1,467 @@
1
+ import {
2
+ type ForwardedRef,
3
+ type RefObject,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ } from "react";
9
+ import {
10
+ type FlatList,
11
+ type FlatListProps,
12
+ type LayoutChangeEvent,
13
+ type NativeScrollEvent,
14
+ type NativeSyntheticEvent,
15
+ } from "react-native";
16
+ import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
17
+
18
+ export type FlatListHistory = {
19
+ hasMore: boolean;
20
+ isLoadingMore: boolean;
21
+ loadMore: () => void;
22
+ };
23
+
24
+ const AT_BOTTOM_THRESHOLD = 4;
25
+
26
+ /** `history` when given, else the runtime's earlier-message paging. */
27
+ export const useThreadHistory = (history: FlatListHistory | undefined) => {
28
+ const aui = useAui();
29
+ const hasEarlier = useAuiState((s) => s.thread.hasEarlier);
30
+ const isLoadingEarlier = useAuiState((s) => s.thread.isLoadingEarlier);
31
+ const runtimeHistory = useMemo<FlatListHistory | undefined>(
32
+ () =>
33
+ hasEarlier || isLoadingEarlier
34
+ ? {
35
+ hasMore: hasEarlier,
36
+ isLoadingMore: isLoadingEarlier,
37
+ loadMore: () => {
38
+ void aui.thread.loadEarlier();
39
+ },
40
+ }
41
+ : undefined,
42
+ [aui, hasEarlier, isLoadingEarlier],
43
+ );
44
+ return history ?? runtimeHistory;
45
+ };
46
+
47
+ export const useHistoryLoad = <TInfo>(
48
+ history: FlatListHistory | undefined,
49
+ onReached: ((info: TInfo) => void) | undefined,
50
+ ) => {
51
+ const loadRequestedRef = useRef(false);
52
+ const hasMore = history?.hasMore ?? false;
53
+ const isLoadingMore = history?.isLoadingMore ?? false;
54
+
55
+ // The latch is scoped to one commit so a no-op load cannot disable paging.
56
+ useEffect(() => {
57
+ loadRequestedRef.current = isLoadingMore;
58
+ });
59
+
60
+ const handleReached = useCallback(
61
+ (info: TInfo) => {
62
+ onReached?.(info);
63
+ if (loadRequestedRef.current) return;
64
+ loadRequestedRef.current = true;
65
+ try {
66
+ history?.loadMore();
67
+ } catch (error) {
68
+ loadRequestedRef.current = false;
69
+ throw error;
70
+ }
71
+ },
72
+ [history, onReached],
73
+ );
74
+
75
+ return {
76
+ canLoadMore: hasMore && !isLoadingMore,
77
+ handleReached,
78
+ };
79
+ };
80
+
81
+ type ScrollProps = Pick<
82
+ FlatListProps<unknown>,
83
+ "onLayout" | "onScroll" | "onContentSizeChange" | "scrollEventThrottle"
84
+ >;
85
+
86
+ export const useFlatListScrollProps = ({
87
+ autoScroll,
88
+ scrollToBottomOnInitialize,
89
+ scrollToBottomOnRunStart,
90
+ scrollToBottomOnThreadSwitch,
91
+ onLayout,
92
+ onScroll,
93
+ onContentSizeChange,
94
+ scrollEventThrottle,
95
+ handleAutoScrollLayout,
96
+ handleAutoScrollScroll,
97
+ handleAutoScrollContentSizeChange,
98
+ }: ScrollProps & {
99
+ autoScroll: boolean | undefined;
100
+ scrollToBottomOnInitialize: boolean | undefined;
101
+ scrollToBottomOnRunStart: boolean | undefined;
102
+ scrollToBottomOnThreadSwitch: boolean | undefined;
103
+ handleAutoScrollLayout: NonNullable<ScrollProps["onLayout"]>;
104
+ handleAutoScrollScroll: NonNullable<ScrollProps["onScroll"]>;
105
+ handleAutoScrollContentSizeChange: NonNullable<
106
+ ScrollProps["onContentSizeChange"]
107
+ >;
108
+ }) => {
109
+ const handleLayout = useCallback(
110
+ (event: LayoutChangeEvent) => {
111
+ handleAutoScrollLayout(event);
112
+ onLayout?.(event);
113
+ },
114
+ [handleAutoScrollLayout, onLayout],
115
+ );
116
+ const handleScroll = useCallback(
117
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
118
+ handleAutoScrollScroll(event);
119
+ onScroll?.(event);
120
+ },
121
+ [handleAutoScrollScroll, onScroll],
122
+ );
123
+ const handleContentSizeChange = useCallback(
124
+ (width: number, height: number) => {
125
+ handleAutoScrollContentSizeChange(width, height);
126
+ onContentSizeChange?.(width, height);
127
+ },
128
+ [handleAutoScrollContentSizeChange, onContentSizeChange],
129
+ );
130
+
131
+ const scrollTracking =
132
+ (autoScroll ?? true) ||
133
+ (scrollToBottomOnInitialize ?? true) ||
134
+ (scrollToBottomOnRunStart ?? true) ||
135
+ (scrollToBottomOnThreadSwitch ?? true);
136
+
137
+ return scrollTracking
138
+ ? {
139
+ onContentSizeChange: handleContentSizeChange,
140
+ onLayout: handleLayout,
141
+ onScroll: handleScroll,
142
+ scrollEventThrottle: scrollEventThrottle ?? 16,
143
+ }
144
+ : {
145
+ ...(onContentSizeChange && { onContentSizeChange }),
146
+ ...(onLayout && { onLayout }),
147
+ ...(onScroll && { onScroll }),
148
+ ...(scrollEventThrottle !== undefined && { scrollEventThrottle }),
149
+ };
150
+ };
151
+
152
+ type PagingProps<T> = Pick<
153
+ FlatListProps<T>,
154
+ | "onStartReached"
155
+ | "onStartReachedThreshold"
156
+ | "onEndReached"
157
+ | "onEndReachedThreshold"
158
+ >;
159
+
160
+ type StartReached = NonNullable<FlatListProps<unknown>["onStartReached"]>;
161
+ type EndReached = NonNullable<FlatListProps<unknown>["onEndReached"]>;
162
+
163
+ export function getFlatListPagingProps<T>(
164
+ edge: "start",
165
+ history: FlatListHistory | undefined,
166
+ canLoadMore: boolean,
167
+ handleReached: StartReached,
168
+ userProps: PagingProps<T>,
169
+ ): PagingProps<T>;
170
+ export function getFlatListPagingProps<T>(
171
+ edge: "end",
172
+ history: FlatListHistory | undefined,
173
+ canLoadMore: boolean,
174
+ handleReached: EndReached,
175
+ userProps: PagingProps<T>,
176
+ ): PagingProps<T>;
177
+ export function getFlatListPagingProps<T>(
178
+ edge: "start" | "end",
179
+ history: FlatListHistory | undefined,
180
+ canLoadMore: boolean,
181
+ handleReached: StartReached & EndReached,
182
+ userProps: PagingProps<T>,
183
+ ): PagingProps<T>;
184
+ export function getFlatListPagingProps<T>(
185
+ edge: "start" | "end",
186
+ history: FlatListHistory | undefined,
187
+ canLoadMore: boolean,
188
+ handleReached: StartReached | EndReached,
189
+ {
190
+ onStartReached,
191
+ onStartReachedThreshold,
192
+ onEndReached,
193
+ onEndReachedThreshold,
194
+ }: PagingProps<T>,
195
+ ): PagingProps<T> {
196
+ const startProps = history
197
+ ? {
198
+ ...(canLoadMore
199
+ ? { onStartReached: handleReached as StartReached }
200
+ : onStartReached
201
+ ? { onStartReached }
202
+ : {}),
203
+ onStartReachedThreshold: onStartReachedThreshold ?? 1,
204
+ }
205
+ : {
206
+ ...(onStartReached && { onStartReached }),
207
+ ...(onStartReachedThreshold !== undefined && {
208
+ onStartReachedThreshold,
209
+ }),
210
+ };
211
+ const endProps = history
212
+ ? {
213
+ ...(canLoadMore
214
+ ? { onEndReached: handleReached as EndReached }
215
+ : onEndReached
216
+ ? { onEndReached }
217
+ : {}),
218
+ onEndReachedThreshold: onEndReachedThreshold ?? 1,
219
+ }
220
+ : {
221
+ ...(onEndReached && { onEndReached }),
222
+ ...(onEndReachedThreshold !== undefined && { onEndReachedThreshold }),
223
+ };
224
+
225
+ return edge === "start"
226
+ ? {
227
+ ...startProps,
228
+ ...(onEndReached && { onEndReached }),
229
+ ...(onEndReachedThreshold !== undefined && { onEndReachedThreshold }),
230
+ }
231
+ : {
232
+ ...(onStartReached && { onStartReached }),
233
+ ...(onStartReachedThreshold !== undefined && {
234
+ onStartReachedThreshold,
235
+ }),
236
+ ...endProps,
237
+ };
238
+ }
239
+
240
+ export const setForwardedRef = <T>(ref: ForwardedRef<T>, value: T | null) => {
241
+ if (typeof ref === "function") {
242
+ ref(value);
243
+ } else if (ref) {
244
+ ref.current = value;
245
+ }
246
+ };
247
+
248
+ export const useComposedFlatListRef = <T>(
249
+ forwardedRef: ForwardedRef<FlatList<T>>,
250
+ ) => {
251
+ const flatListRef = useRef<FlatList<T> | null>(null);
252
+
253
+ const setFlatListRef = useCallback(
254
+ (node: FlatList<T> | null) => {
255
+ flatListRef.current = node;
256
+ setForwardedRef(forwardedRef, node);
257
+ },
258
+ [forwardedRef],
259
+ );
260
+
261
+ return [flatListRef, setFlatListRef] as const;
262
+ };
263
+
264
+ export const useFlatListAutoScroll = <T>({
265
+ flatListRef,
266
+ hasMessages,
267
+ inverted = false,
268
+ horizontal = false,
269
+ autoScroll = true,
270
+ scrollToBottomOnRunStart = true,
271
+ scrollToBottomOnInitialize = true,
272
+ scrollToBottomOnThreadSwitch = true,
273
+ }: {
274
+ flatListRef: RefObject<FlatList<T> | null>;
275
+ hasMessages: boolean;
276
+ inverted?: boolean | undefined;
277
+ horizontal?: boolean | null | undefined;
278
+ autoScroll?: boolean | undefined;
279
+ scrollToBottomOnRunStart?: boolean | undefined;
280
+ scrollToBottomOnInitialize?: boolean | undefined;
281
+ scrollToBottomOnThreadSwitch?: boolean | undefined;
282
+ }) => {
283
+ const metricsRef = useRef({
284
+ contentHeight: 0,
285
+ viewportHeight: 0,
286
+ scrollY: 0,
287
+ });
288
+ const isAtBottomRef = useRef(true);
289
+ const lastScrollEventOffsetRef = useRef(0);
290
+ const initializeScrollRequestedRef = useRef(false);
291
+ const contentSizeVersionRef = useRef(0);
292
+ const pendingScrollToBottomRef = useRef<
293
+ | false
294
+ | {
295
+ animated: boolean;
296
+ minimumContentSizeVersion: number;
297
+ }
298
+ >(false);
299
+
300
+ const updateIsAtBottom = useCallback(() => {
301
+ const { contentHeight, scrollY, viewportHeight } = metricsRef.current;
302
+ isAtBottomRef.current = inverted
303
+ ? scrollY <= AT_BOTTOM_THRESHOLD
304
+ : contentHeight <= viewportHeight ||
305
+ contentHeight - scrollY - viewportHeight <= AT_BOTTOM_THRESHOLD;
306
+ }, [inverted]);
307
+
308
+ // Commanding a scroll records the intended position immediately; the
309
+ // native scroll echo is bridged and throttled, so waiting for it lets a
310
+ // fast stream observe stale metrics and drop out of following.
311
+ const scrollToBottom = useCallback(
312
+ (animated: boolean) => {
313
+ const { contentHeight, viewportHeight } = metricsRef.current;
314
+ const offset = inverted ? 0 : Math.max(0, contentHeight - viewportHeight);
315
+ metricsRef.current.scrollY = offset;
316
+ isAtBottomRef.current = true;
317
+ flatListRef.current?.scrollToOffset({ offset, animated });
318
+ },
319
+ [flatListRef, inverted],
320
+ );
321
+
322
+ const handleLayout = useCallback(
323
+ (event: LayoutChangeEvent) => {
324
+ const wasAtBottom = isAtBottomRef.current;
325
+ const previousViewportHeight = metricsRef.current.viewportHeight;
326
+ const viewportHeight = horizontal
327
+ ? event.nativeEvent.layout.width
328
+ : event.nativeEvent.layout.height;
329
+ metricsRef.current.viewportHeight = viewportHeight;
330
+ updateIsAtBottom();
331
+ const pending = pendingScrollToBottomRef.current;
332
+ if (
333
+ pending &&
334
+ contentSizeVersionRef.current >= pending.minimumContentSizeVersion &&
335
+ viewportHeight > 0
336
+ ) {
337
+ pendingScrollToBottomRef.current = false;
338
+ scrollToBottom(pending.animated);
339
+ return;
340
+ }
341
+ if (!wasAtBottom) return;
342
+ // Layout changes are never user gestures, so they must not unpin. Past
343
+ // the first measurement, a viewport change while pinned re-commands the
344
+ // bottom position, since no content-size event follows a bare keyboard
345
+ // open or close.
346
+ if (
347
+ autoScroll &&
348
+ previousViewportHeight !== 0 &&
349
+ viewportHeight !== previousViewportHeight
350
+ ) {
351
+ scrollToBottom(pending ? pending.animated : false);
352
+ } else {
353
+ isAtBottomRef.current = true;
354
+ }
355
+ },
356
+ [autoScroll, horizontal, scrollToBottom, updateIsAtBottom],
357
+ );
358
+
359
+ const handleScroll = useCallback(
360
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
361
+ const { contentOffset, contentSize, layoutMeasurement } =
362
+ event.nativeEvent;
363
+ const scrollOffset = horizontal ? contentOffset.x : contentOffset.y;
364
+ const previousEventOffset = lastScrollEventOffsetRef.current;
365
+ const wasPinnedToBottom = isAtBottomRef.current;
366
+ lastScrollEventOffsetRef.current = scrollOffset;
367
+ metricsRef.current = {
368
+ contentHeight: horizontal ? contentSize.width : contentSize.height,
369
+ viewportHeight: horizontal
370
+ ? layoutMeasurement.width
371
+ : layoutMeasurement.height,
372
+ scrollY: scrollOffset,
373
+ };
374
+ updateIsAtBottom();
375
+ const upwardMove = inverted
376
+ ? scrollOffset > previousEventOffset
377
+ : scrollOffset < previousEventOffset;
378
+ // Only a deliberate move away from the bottom unpins or cancels a pending scroll.
379
+ // Gestures are detected echo-to-echo because a commanded scroll
380
+ // optimistically moves the tracked position ahead of its ascending
381
+ // animation echoes, and those echoes must not unpin mid-flight.
382
+ if (wasPinnedToBottom && !upwardMove) {
383
+ isAtBottomRef.current = true;
384
+ }
385
+ if (!isAtBottomRef.current && upwardMove) {
386
+ pendingScrollToBottomRef.current = false;
387
+ }
388
+ },
389
+ [horizontal, inverted, updateIsAtBottom],
390
+ );
391
+
392
+ const handleContentSizeChange = useCallback(
393
+ (width: number, height: number) => {
394
+ const metrics = metricsRef.current;
395
+ const contentHeight = horizontal ? width : height;
396
+ const previousContentHeight = metrics.contentHeight;
397
+ const wasAtBottom = isAtBottomRef.current;
398
+ if (contentHeight > 0) contentSizeVersionRef.current += 1;
399
+ metrics.contentHeight = contentHeight;
400
+ updateIsAtBottom();
401
+
402
+ // Initialize and thread-switch requests are repeated after the list has
403
+ // measured so the explicit bottom offset uses real content metrics.
404
+ const pendingScroll = pendingScrollToBottomRef.current;
405
+ if (
406
+ pendingScroll &&
407
+ contentSizeVersionRef.current >=
408
+ pendingScroll.minimumContentSizeVersion &&
409
+ metrics.viewportHeight > 0
410
+ ) {
411
+ pendingScrollToBottomRef.current = false;
412
+ scrollToBottom(pendingScroll.animated);
413
+ return;
414
+ }
415
+
416
+ if (!autoScroll) return;
417
+ if (!wasAtBottom) return;
418
+ if (previousContentHeight === 0) return;
419
+ if (contentHeight <= previousContentHeight) return;
420
+
421
+ scrollToBottom(false);
422
+ },
423
+ [autoScroll, horizontal, scrollToBottom, updateIsAtBottom],
424
+ );
425
+
426
+ useEffect(() => {
427
+ if (!scrollToBottomOnInitialize) return;
428
+ if (!hasMessages) {
429
+ initializeScrollRequestedRef.current = false;
430
+ return;
431
+ }
432
+ if (initializeScrollRequestedRef.current) return;
433
+
434
+ initializeScrollRequestedRef.current = true;
435
+ pendingScrollToBottomRef.current = {
436
+ animated: false,
437
+ minimumContentSizeVersion: Math.max(1, contentSizeVersionRef.current),
438
+ };
439
+ scrollToBottom(false);
440
+ }, [hasMessages, scrollToBottom, scrollToBottomOnInitialize]);
441
+
442
+ useAuiEvent("thread.runStart", () => {
443
+ if (!scrollToBottomOnRunStart) return;
444
+ pendingScrollToBottomRef.current = {
445
+ animated: true,
446
+ minimumContentSizeVersion: contentSizeVersionRef.current + 1,
447
+ };
448
+ scrollToBottom(true);
449
+ });
450
+
451
+ useAuiEvent("threads.selectionChanged", () => {
452
+ if (!scrollToBottomOnThreadSwitch) return;
453
+ initializeScrollRequestedRef.current = false;
454
+ lastScrollEventOffsetRef.current = 0;
455
+ pendingScrollToBottomRef.current = {
456
+ animated: false,
457
+ minimumContentSizeVersion: contentSizeVersionRef.current + 1,
458
+ };
459
+ scrollToBottom(false);
460
+ });
461
+
462
+ return {
463
+ handleLayout,
464
+ handleScroll,
465
+ handleContentSizeChange,
466
+ };
467
+ };
@@ -9,6 +9,11 @@ export {
9
9
  type ThreadMessagesProps as MessagesProps,
10
10
  } from "./thread/ThreadMessages";
11
11
  export { ThreadPrimitiveMessageByIndex as MessageByIndex } from "@assistant-ui/core/react";
12
+ export { ThreadPrimitiveRow as Row } from "@assistant-ui/core/react";
13
+ export {
14
+ ThreadRowsFlatList as RowsFlatList,
15
+ type ThreadRowsFlatListProps as RowsFlatListProps,
16
+ } from "./thread/ThreadRowsFlatList";
12
17
  export { ThreadPrimitiveUnstable_MessageById as Unstable_MessageById } from "@assistant-ui/core/react";
13
18
  export {
14
19
  ThreadEmpty as Empty,