@assistant-ui/react-native 0.1.45 → 0.1.47
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.
- package/dist/index.d.ts +13 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/primitives/actionBar/ActionBarCopy.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +2 -1
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +2 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts +2 -2
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +2 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +55 -38
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/message/MessageContent.d.ts.map +1 -1
- package/dist/primitives/message/MessageContent.js +13 -10
- package/dist/primitives/message/MessageContent.js.map +1 -1
- package/dist/primitives/message/MessageParts.d.ts +1 -0
- package/dist/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/primitives/message/MessageParts.js +22 -3
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message.d.ts +1 -0
- package/dist/primitives/message.d.ts.map +1 -1
- package/dist/primitives/message.js +4 -3
- package/dist/primitives/message.js.map +1 -1
- package/dist/primitives/messagePart.js +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts +7 -5
- package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadMessages.js +109 -376
- package/dist/primitives/thread/ThreadMessages.js.map +1 -1
- package/dist/primitives/thread/ThreadRowsFlatList.d.ts +29 -0
- package/dist/primitives/thread/ThreadRowsFlatList.d.ts.map +1 -0
- package/dist/primitives/thread/ThreadRowsFlatList.js +226 -0
- package/dist/primitives/thread/ThreadRowsFlatList.js.map +1 -0
- package/dist/primitives/thread/flatListScroll.d.ts +52 -0
- package/dist/primitives/thread/flatListScroll.d.ts.map +1 -0
- package/dist/primitives/thread/flatListScroll.js +380 -0
- package/dist/primitives/thread/flatListScroll.js.map +1 -0
- package/dist/primitives/thread.d.ts +2 -0
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +5 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts +10 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts.map +1 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.js +47 -0
- package/dist/primitives/threadListItem/ThreadListItemTitle.js.map +1 -0
- package/dist/primitives/threadListItem.d.ts +1 -1
- package/dist/primitives/threadListItem.d.ts.map +1 -1
- package/dist/primitives/threadListItem.js +3 -3
- package/dist/primitives/threadListItem.js.map +1 -1
- package/package.json +8 -7
- package/src/index.ts +22 -1
- package/src/primitives/actionBar/ActionBar.test.tsx +45 -0
- package/src/primitives/actionBar/ActionBarCopy.tsx +12 -3
- package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +13 -3
- package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +13 -3
- package/src/primitives/composer/ComposerInput.test.tsx +64 -4
- package/src/primitives/composer/ComposerInput.tsx +13 -2
- package/src/primitives/message/MessageContent.runtime.test.tsx +35 -0
- package/src/primitives/message/MessageContent.test.tsx +68 -4
- package/src/primitives/message/MessageContent.tsx +19 -9
- package/src/primitives/message/MessageParts.test.tsx +68 -1
- package/src/primitives/message/MessageParts.tsx +17 -1
- package/src/primitives/message.test.tsx +74 -0
- package/src/primitives/message.ts +1 -0
- package/src/primitives/thread/ThreadMessages.test.tsx +28 -1
- package/src/primitives/thread/ThreadMessages.tsx +49 -343
- package/src/primitives/thread/ThreadRowsFlatList.test.tsx +253 -0
- package/src/primitives/thread/ThreadRowsFlatList.tsx +159 -0
- package/src/primitives/thread/flatListScroll.ts +467 -0
- package/src/primitives/thread.ts +5 -0
- package/src/primitives/threadListItem/ThreadListItemTitle.test.tsx +164 -0
- package/src/primitives/threadListItem/ThreadListItemTitle.tsx +23 -0
- package/src/primitives/threadListItem.ts +4 -1
|
@@ -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
|
+
};
|
package/src/primitives/thread.ts
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { act } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { Text, View } from "react-native";
|
|
5
|
+
import type { ThreadListItemPrimitive } from "../../index";
|
|
6
|
+
import { Title as ThreadListItemTitle } from "../threadListItem";
|
|
7
|
+
|
|
8
|
+
const fallbackProps: ThreadListItemPrimitive.Title.Props = {
|
|
9
|
+
fallback: "New chat",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const h = vi.hoisted(() => {
|
|
13
|
+
const listeners = new Set<() => void>();
|
|
14
|
+
let state = { threadListItem: { title: undefined as string | undefined } };
|
|
15
|
+
return {
|
|
16
|
+
getState: () => state,
|
|
17
|
+
subscribe: (listener: () => void) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
setTitle: (title: string | undefined) => {
|
|
22
|
+
state = { threadListItem: { title } };
|
|
23
|
+
listeners.forEach((listener) => listener());
|
|
24
|
+
},
|
|
25
|
+
textChildren: [] as unknown[],
|
|
26
|
+
textProps: null as Record<string, unknown> | null,
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
31
|
+
const actual = await importOriginal<typeof import("@assistant-ui/store")>();
|
|
32
|
+
const { useSyncExternalStore } = await import("react");
|
|
33
|
+
return {
|
|
34
|
+
...actual,
|
|
35
|
+
useAuiState: <T,>(selector: (s: ReturnType<typeof h.getState>) => T) =>
|
|
36
|
+
selector(useSyncExternalStore(h.subscribe, h.getState)),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock("react-native", async (importOriginal) => {
|
|
41
|
+
const actual = await importOriginal<typeof import("react-native")>();
|
|
42
|
+
const React = await import("react");
|
|
43
|
+
const TextMock = (props: Record<string, unknown>) => {
|
|
44
|
+
h.textChildren.push(props.children);
|
|
45
|
+
h.textProps = props;
|
|
46
|
+
return React.createElement(
|
|
47
|
+
actual.Text as unknown as React.ElementType,
|
|
48
|
+
props,
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
return { ...actual, Text: TextMock };
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
55
|
+
|
|
56
|
+
describe("ThreadListItemTitle", () => {
|
|
57
|
+
let container: HTMLDivElement;
|
|
58
|
+
let root: Root;
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
h.setTitle(undefined);
|
|
62
|
+
h.textChildren = [];
|
|
63
|
+
h.textProps = null;
|
|
64
|
+
container = document.createElement("div");
|
|
65
|
+
document.body.appendChild(container);
|
|
66
|
+
root = createRoot(container);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterEach(async () => {
|
|
70
|
+
await act(async () => {
|
|
71
|
+
root.unmount();
|
|
72
|
+
});
|
|
73
|
+
container.remove();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders the title through react-native Text", async () => {
|
|
77
|
+
h.setTitle("My thread");
|
|
78
|
+
|
|
79
|
+
await act(async () => {
|
|
80
|
+
root.render(<ThreadListItemTitle />);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(h.textChildren).toEqual(["My thread"]);
|
|
84
|
+
expect(container.textContent).toBe("My thread");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("forwards host Text props the public type exposes", async () => {
|
|
88
|
+
h.setTitle("My thread");
|
|
89
|
+
|
|
90
|
+
await act(async () => {
|
|
91
|
+
root.render(<ThreadListItemTitle numberOfLines={1} testID="title" />);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(h.textProps).toMatchObject({ numberOfLines: 1, testID: "title" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders a nested Text fallback the ReactNode contract permits", async () => {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
root.render(
|
|
100
|
+
<ThreadListItemTitle fallback={<Text>Untitled chat</Text>} />,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(container.textContent).toBe("Untitled chat");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("renders nothing without a title or fallback", async () => {
|
|
108
|
+
await act(async () => {
|
|
109
|
+
root.render(<ThreadListItemTitle numberOfLines={1} />);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(h.textChildren).toEqual([]);
|
|
113
|
+
expect(container.innerHTML).toBe("");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders an element fallback outside Text", async () => {
|
|
117
|
+
await act(async () => {
|
|
118
|
+
root.render(
|
|
119
|
+
<ThreadListItemTitle fallback={<View testID="fallback-view" />} />,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(h.textChildren).toEqual([]);
|
|
124
|
+
expect(
|
|
125
|
+
container.querySelector('[data-testid="fallback-view"]'),
|
|
126
|
+
).not.toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("renders the fallback through Text when there is no title", async () => {
|
|
130
|
+
await act(async () => {
|
|
131
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(h.textChildren).toEqual(["New chat"]);
|
|
135
|
+
expect(container.textContent).toBe("New chat");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders an empty title with the fallback", async () => {
|
|
139
|
+
h.setTitle("");
|
|
140
|
+
|
|
141
|
+
await act(async () => {
|
|
142
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(container.textContent).toBe("New chat");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("updates the title while mounted", async () => {
|
|
149
|
+
await act(async () => {
|
|
150
|
+
root.render(<ThreadListItemTitle {...fallbackProps} />);
|
|
151
|
+
});
|
|
152
|
+
expect(container.textContent).toBe("New chat");
|
|
153
|
+
|
|
154
|
+
await act(async () => {
|
|
155
|
+
h.setTitle("First title");
|
|
156
|
+
});
|
|
157
|
+
expect(container.textContent).toBe("First title");
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
h.setTitle("Updated title");
|
|
161
|
+
});
|
|
162
|
+
expect(container.textContent).toBe("Updated title");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Text, type TextProps } from "react-native";
|
|
3
|
+
import { useAuiState } from "@assistant-ui/store";
|
|
4
|
+
|
|
5
|
+
export type ThreadListItemTitleProps = Omit<TextProps, "children"> & {
|
|
6
|
+
fallback?: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ThreadListItemTitle({
|
|
10
|
+
fallback,
|
|
11
|
+
...props
|
|
12
|
+
}: ThreadListItemTitleProps) {
|
|
13
|
+
const title = useAuiState((s) => s.threadListItem.title);
|
|
14
|
+
const content = title || fallback;
|
|
15
|
+
if (typeof content === "string" || typeof content === "number") {
|
|
16
|
+
return <Text {...props}>{content}</Text>;
|
|
17
|
+
}
|
|
18
|
+
return <>{content}</>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export namespace ThreadListItemTitle {
|
|
22
|
+
export type Props = ThreadListItemTitleProps;
|
|
23
|
+
}
|
|
@@ -2,7 +2,10 @@ export {
|
|
|
2
2
|
ThreadListItemRoot as Root,
|
|
3
3
|
type ThreadListItemRootProps as RootProps,
|
|
4
4
|
} from "./threadListItem/ThreadListItemRoot";
|
|
5
|
-
export {
|
|
5
|
+
export {
|
|
6
|
+
ThreadListItemTitle as Title,
|
|
7
|
+
type ThreadListItemTitleProps as TitleProps,
|
|
8
|
+
} from "./threadListItem/ThreadListItemTitle";
|
|
6
9
|
export {
|
|
7
10
|
ThreadListItemTrigger as Trigger,
|
|
8
11
|
type ThreadListItemTriggerProps as TriggerProps,
|