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