@vvedantb/native-ui 0.12.1 → 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 +82 -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/card-button.tsx +49 -0
- 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 +52 -1
- package/src/ai/message-action-sheet.tsx +168 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +226 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +308 -73
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- 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/message.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
-
import { Text, View, type ViewProps } from "react-native";
|
|
1
|
+
import { createContext, useContext, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
3
4
|
import {
|
|
5
|
+
IconArrowBackUp,
|
|
4
6
|
IconCopy,
|
|
5
7
|
IconRefresh,
|
|
6
8
|
IconThumbDown,
|
|
@@ -8,13 +10,15 @@ import {
|
|
|
8
10
|
type IconProps,
|
|
9
11
|
} from "@tabler/icons-react-native";
|
|
10
12
|
import { cn } from "../cn";
|
|
11
|
-
import { sizeStyle } from "../controlStyles";
|
|
13
|
+
import { sizeStyle, typeStyle } from "../controlStyles";
|
|
12
14
|
import { palette, withAlpha } from "../palette";
|
|
13
15
|
import { useLargerText } from "../theme";
|
|
14
16
|
import { tokens } from "../tokens";
|
|
17
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
15
18
|
import { useScheme } from "../useScheme";
|
|
16
|
-
import { chatTextSize } from "./chat-text";
|
|
17
|
-
import { ChatIconButton } from "./icon-button";
|
|
19
|
+
import { META_TEXT_STYLE, chatTextSize } from "./chat-text";
|
|
20
|
+
import { ChatIconButton, type ChatIconButtonSize } from "./icon-button";
|
|
21
|
+
import { MESSAGE_ACTIONS_TITLE, MessageActionSheet, type MessageSheetAction } from "./message-action-sheet";
|
|
18
22
|
import { ChatTextColorContext, ResponseText } from "./response-text";
|
|
19
23
|
|
|
20
24
|
export type MessageRole = "user" | "assistant" | "system";
|
|
@@ -24,25 +28,146 @@ const MessageContext = createContext<MessageRole>("assistant");
|
|
|
24
28
|
export type MessageProps = ViewProps & {
|
|
25
29
|
from: MessageRole;
|
|
26
30
|
className?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Called on a long press of the message. Without it (and without `actions`)
|
|
33
|
+
* the message is a plain `View`, as before.
|
|
34
|
+
*/
|
|
35
|
+
onLongPress?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Actions for a `MessageActionSheet` that opens on a long press, such as
|
|
38
|
+
* Reply and Copy. `onLongPress` also runs, before the sheet opens.
|
|
39
|
+
*/
|
|
40
|
+
actions?: MessageSheetAction[];
|
|
41
|
+
/** Title of the action sheet. Default "Message actions". */
|
|
42
|
+
actionSheetTitle?: string;
|
|
43
|
+
/**
|
|
44
|
+
* True while the message is the target of a quote tap: a primary wash that
|
|
45
|
+
* fades over about 1.4 seconds, and a ring while it stays true. Set it back
|
|
46
|
+
* to `false` after the scroll, about 1.5 seconds later.
|
|
47
|
+
*/
|
|
48
|
+
highlighted?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* For group chats: which bot sent this message. Draws a `MessageBotLabel`
|
|
51
|
+
* (avatar and name) above the content. Only when set; assistant messages only.
|
|
52
|
+
*/
|
|
53
|
+
bot?: MessageBot;
|
|
27
54
|
};
|
|
28
55
|
|
|
56
|
+
/** The bot behind an incoming message in a group chat. */
|
|
57
|
+
export type MessageBot = { name: string; avatar?: ReactNode };
|
|
58
|
+
|
|
59
|
+
/** How long the highlight wash takes to fade, in milliseconds. */
|
|
60
|
+
export const MESSAGE_HIGHLIGHT_MS = 1400;
|
|
61
|
+
|
|
62
|
+
/** Peak opacity of the highlight wash (the primary colour). */
|
|
63
|
+
const HIGHLIGHT_WASH = 0.18;
|
|
64
|
+
|
|
65
|
+
function MessageHighlight() {
|
|
66
|
+
const scheme = useScheme();
|
|
67
|
+
const reduceMotion = useReduceMotion();
|
|
68
|
+
const wash = useSharedValue(reduceMotion ? 0.5 : 1);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (reduceMotion) {
|
|
71
|
+
wash.set(0.5);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
wash.set(1);
|
|
75
|
+
wash.set(withTiming(0, { duration: MESSAGE_HIGHLIGHT_MS }));
|
|
76
|
+
}, [wash, reduceMotion]);
|
|
77
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: wash.get() }));
|
|
78
|
+
return (
|
|
79
|
+
<Animated.View
|
|
80
|
+
testID="message-highlight"
|
|
81
|
+
pointerEvents="none"
|
|
82
|
+
style={[
|
|
83
|
+
StyleSheet.absoluteFill,
|
|
84
|
+
{
|
|
85
|
+
borderRadius: tokens.radius.lg,
|
|
86
|
+
borderWidth: 1.5,
|
|
87
|
+
borderColor: withAlpha(palette.primary[scheme], 0.5),
|
|
88
|
+
backgroundColor: withAlpha(palette.primary[scheme], HIGHLIGHT_WASH),
|
|
89
|
+
},
|
|
90
|
+
animatedStyle,
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
29
96
|
/**
|
|
30
97
|
* One message: the web `w-full max-w-[95%] flex-col gap-2`. A user message
|
|
31
98
|
* sits at the end of the row with its parts at the end; an assistant message
|
|
32
99
|
* at the start, its parts full width.
|
|
100
|
+
*
|
|
101
|
+
* With `onLongPress` or `actions` the message is a `Pressable` (same layout,
|
|
102
|
+
* `className`, `style` and view props) and a long press opens the
|
|
103
|
+
* `MessageActionSheet`; TalkBack reaches it as the `longpress` action. It stays
|
|
104
|
+
* a non-accessible container so its buttons and links keep their own focus:
|
|
105
|
+
* also offer the actions in a `MessageActionsBar` for screen reader users. `highlighted` draws the quote-jump highlight.
|
|
33
106
|
*/
|
|
34
|
-
export function Message({
|
|
107
|
+
export function Message({
|
|
108
|
+
from,
|
|
109
|
+
className,
|
|
110
|
+
style,
|
|
111
|
+
onLongPress,
|
|
112
|
+
actions,
|
|
113
|
+
actionSheetTitle,
|
|
114
|
+
highlighted = false,
|
|
115
|
+
bot,
|
|
116
|
+
children,
|
|
117
|
+
...props
|
|
118
|
+
}: MessageProps) {
|
|
35
119
|
const user = from === "user";
|
|
120
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
121
|
+
const hasActions = actions != null && actions.length > 0;
|
|
122
|
+
const longPressable = onLongPress != null || hasActions;
|
|
123
|
+
const longPress = () => {
|
|
124
|
+
onLongPress?.();
|
|
125
|
+
if (hasActions) setSheetOpen(true);
|
|
126
|
+
};
|
|
127
|
+
const layout: ViewProps["style"] = [
|
|
128
|
+
{ alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
|
|
129
|
+
style,
|
|
130
|
+
];
|
|
131
|
+
const merged = cn("w-full max-w-[95%] gap-2", className);
|
|
132
|
+
const parts = (
|
|
133
|
+
<>
|
|
134
|
+
{bot && !user ? <MessageBotLabel name={bot.name} avatar={bot.avatar} /> : null}
|
|
135
|
+
{children}
|
|
136
|
+
{highlighted ? <MessageHighlight /> : null}
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
36
139
|
return (
|
|
37
140
|
<MessageContext.Provider value={from}>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
141
|
+
{longPressable ? (
|
|
142
|
+
<>
|
|
143
|
+
<Pressable
|
|
144
|
+
accessible={false}
|
|
145
|
+
className={merged}
|
|
146
|
+
style={layout}
|
|
147
|
+
onLongPress={longPress}
|
|
148
|
+
// TalkBack's actions menu reaches the same sheet as a long press.
|
|
149
|
+
accessibilityActions={[{ name: "longpress", label: actionSheetTitle ?? MESSAGE_ACTIONS_TITLE }]}
|
|
150
|
+
onAccessibilityAction={(event) => {
|
|
151
|
+
if (event.nativeEvent.actionName === "longpress") longPress();
|
|
152
|
+
}}
|
|
153
|
+
{...props}
|
|
154
|
+
>
|
|
155
|
+
{parts}
|
|
156
|
+
</Pressable>
|
|
157
|
+
{hasActions ? (
|
|
158
|
+
<MessageActionSheet
|
|
159
|
+
open={sheetOpen}
|
|
160
|
+
onClose={() => setSheetOpen(false)}
|
|
161
|
+
actions={actions}
|
|
162
|
+
title={actionSheetTitle}
|
|
163
|
+
/>
|
|
164
|
+
) : null}
|
|
165
|
+
</>
|
|
166
|
+
) : (
|
|
167
|
+
<View className={merged} style={layout} {...props}>
|
|
168
|
+
{parts}
|
|
169
|
+
</View>
|
|
170
|
+
)}
|
|
46
171
|
</MessageContext.Provider>
|
|
47
172
|
);
|
|
48
173
|
}
|
|
@@ -62,8 +187,8 @@ export function userBubbleStyle(scheme: "light" | "dark") {
|
|
|
62
187
|
/**
|
|
63
188
|
* A user bubble's look. `primary` is the web default (`bg-primary` with the
|
|
64
189
|
* label colour). `tint` is the chat override Eva uses (`rounded-surface
|
|
65
|
-
* bg-primary/10 px-
|
|
66
|
-
* with foreground text and every corner at 16.
|
|
190
|
+
* bg-primary/10 text-foreground`, still `px-4 py-3`): a 10% wash of the
|
|
191
|
+
* primary colour with foreground text and every corner at 16.
|
|
67
192
|
*/
|
|
68
193
|
export type MessageBubble = "primary" | "tint";
|
|
69
194
|
|
|
@@ -75,54 +200,112 @@ export function tintBubbleStyle(scheme: "light" | "dark") {
|
|
|
75
200
|
};
|
|
76
201
|
}
|
|
77
202
|
|
|
203
|
+
export type MessageBotLabelProps = {
|
|
204
|
+
name: string;
|
|
205
|
+
/** An avatar node, such as `<Avatar size="xs" />`. Drawn before the name. */
|
|
206
|
+
avatar?: ReactNode;
|
|
207
|
+
className?: string;
|
|
208
|
+
testID?: string;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Which bot sent a message, for group chats: an optional avatar and the name
|
|
213
|
+
* in the caption size. `Message` draws it for its `bot` prop. To compose it
|
|
214
|
+
* yourself, render it above `MessageContent` (for example in `renderMessage`).
|
|
215
|
+
*/
|
|
216
|
+
export function MessageBotLabel({ name, avatar, className, testID = "message-bot-label" }: MessageBotLabelProps) {
|
|
217
|
+
const scheme = useScheme();
|
|
218
|
+
const largerText = useLargerText();
|
|
219
|
+
const size = chatTextSize("caption", largerText);
|
|
220
|
+
return (
|
|
221
|
+
<View
|
|
222
|
+
testID={testID}
|
|
223
|
+
accessible
|
|
224
|
+
accessibilityLabel={`From ${name}`}
|
|
225
|
+
className={cn("flex-row items-center gap-1.5", className)}
|
|
226
|
+
>
|
|
227
|
+
{avatar}
|
|
228
|
+
<Text
|
|
229
|
+
numberOfLines={1}
|
|
230
|
+
className={cn(size, "font-instrument-sans-semibold")}
|
|
231
|
+
style={[sizeStyle(size), typeStyle("semibold"), { color: tokens.color.muted[scheme] }]}
|
|
232
|
+
>
|
|
233
|
+
{name}
|
|
234
|
+
</Text>
|
|
235
|
+
</View>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
78
239
|
export type MessageContentProps = ViewProps & {
|
|
79
240
|
/** User messages only. Default `"primary"`. */
|
|
80
241
|
bubble?: MessageBubble;
|
|
242
|
+
/** A `MessageQuote` drawn above the content, inside the bubble. */
|
|
243
|
+
quote?: ReactNode;
|
|
81
244
|
className?: string;
|
|
82
245
|
};
|
|
83
246
|
|
|
84
247
|
/**
|
|
85
|
-
* The message body. A user message is a filled bubble
|
|
86
|
-
* with the primary fill and label colour, or with `bubble="tint"`
|
|
87
|
-
*
|
|
248
|
+
* The message body. A user message is a filled bubble, `px-4 py-3`: by
|
|
249
|
+
* default with the primary fill and label colour, or with `bubble="tint"` on
|
|
250
|
+
* a 10% primary wash with the foreground colour. An assistant message has
|
|
88
251
|
* no fill and the foreground colour. A string child is drawn as
|
|
89
252
|
* `ResponseText`, and `ResponseText` inside takes the colour.
|
|
90
253
|
*/
|
|
91
|
-
export function MessageContent({ children, bubble = "primary", className, style, ...props }: MessageContentProps) {
|
|
254
|
+
export function MessageContent({ children, bubble = "primary", quote, className, style, ...props }: MessageContentProps) {
|
|
92
255
|
const from = useContext(MessageContext);
|
|
93
256
|
const scheme = useScheme();
|
|
94
257
|
const user = from === "user";
|
|
95
258
|
const tint = user && bubble === "tint";
|
|
96
259
|
const color = user && !tint ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
|
|
97
260
|
const bubbleStyle = !user ? null : tint ? tintBubbleStyle(scheme) : userBubbleStyle(scheme);
|
|
261
|
+
const body = typeof children === "string" ? <ResponseText>{children}</ResponseText> : children;
|
|
98
262
|
return (
|
|
99
263
|
<ChatTextColorContext.Provider value={color}>
|
|
100
264
|
<View
|
|
101
|
-
className={cn("min-w-0 max-w-full gap-2", user &&
|
|
265
|
+
className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
|
|
102
266
|
style={[bubbleStyle, style]}
|
|
103
267
|
{...props}
|
|
104
268
|
>
|
|
105
|
-
{
|
|
269
|
+
{quote ? (
|
|
270
|
+
<>
|
|
271
|
+
{quote}
|
|
272
|
+
{body}
|
|
273
|
+
</>
|
|
274
|
+
) : (
|
|
275
|
+
body
|
|
276
|
+
)}
|
|
106
277
|
</View>
|
|
107
278
|
</ChatTextColorContext.Provider>
|
|
108
279
|
);
|
|
109
280
|
}
|
|
110
281
|
|
|
111
|
-
export type MessageMetaProps = {
|
|
282
|
+
export type MessageMetaProps = {
|
|
283
|
+
children: ReactNode;
|
|
284
|
+
/**
|
|
285
|
+
* Default `caption` (`text-xs`, 12 / 16). `2xs` is the web `text-2xs` token
|
|
286
|
+
* (11 / 16), as Eva's web chat. Larger text draws both at the caption step.
|
|
287
|
+
*/
|
|
288
|
+
size?: "caption" | "2xs";
|
|
289
|
+
className?: string;
|
|
290
|
+
};
|
|
112
291
|
|
|
113
292
|
/**
|
|
114
293
|
* The muted clock and duration after a message's actions, as Eva's web chat
|
|
115
|
-
* (`
|
|
116
|
-
*
|
|
294
|
+
* (`tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the caption
|
|
295
|
+
* size (or `size="2xs"`), the muted colour at 60%, tabular figures.
|
|
117
296
|
*/
|
|
118
|
-
export function MessageMeta({ children, className }: MessageMetaProps) {
|
|
297
|
+
export function MessageMeta({ children, size = "caption", className }: MessageMetaProps) {
|
|
119
298
|
const scheme = useScheme();
|
|
120
299
|
const largerText = useLargerText();
|
|
121
|
-
const
|
|
300
|
+
const caption = chatTextSize("caption", largerText);
|
|
301
|
+
const tiny = size === "2xs" && !largerText;
|
|
122
302
|
return (
|
|
123
303
|
<Text
|
|
124
|
-
className={cn(
|
|
125
|
-
style={[
|
|
304
|
+
className={cn(!tiny && caption, "font-instrument-sans", className)}
|
|
305
|
+
style={[
|
|
306
|
+
tiny ? META_TEXT_STYLE : sizeStyle(caption),
|
|
307
|
+
{ color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] },
|
|
308
|
+
]}
|
|
126
309
|
>
|
|
127
310
|
{children}
|
|
128
311
|
</Text>
|
|
@@ -138,6 +321,7 @@ export function MessageActionsBar({ className, ...props }: MessageActionsBarProp
|
|
|
138
321
|
|
|
139
322
|
/** The usual actions, with their icons and accessibility labels. */
|
|
140
323
|
export const MESSAGE_ACTIONS = {
|
|
324
|
+
reply: { icon: IconArrowBackUp, label: "Reply" },
|
|
141
325
|
copy: { icon: IconCopy, label: "Copy" },
|
|
142
326
|
retry: { icon: IconRefresh, label: "Retry" },
|
|
143
327
|
like: { icon: IconThumbUp, label: "Good response" },
|
|
@@ -159,12 +343,20 @@ export type MessageActionProps = {
|
|
|
159
343
|
*/
|
|
160
344
|
selected?: boolean;
|
|
161
345
|
disabled?: boolean;
|
|
346
|
+
/**
|
|
347
|
+
* Default `sm`, the web `icon-sm` (32, 16 icon). `xs` is the web `icon-xs`
|
|
348
|
+
* (28, 14 icon), as Eva's web chat; `touch` is a whole 44 square (16 icon)
|
|
349
|
+
* for a phone, so the row needs no slop.
|
|
350
|
+
*/
|
|
351
|
+
size?: ChatIconButtonSize;
|
|
162
352
|
testID?: string;
|
|
163
353
|
};
|
|
164
354
|
|
|
165
355
|
/**
|
|
166
|
-
* A ghost icon button under a message: the web `icon-sm` (32, 16 icon)
|
|
167
|
-
*
|
|
356
|
+
* A ghost icon button under a message: the web `icon-sm` (32, 16 icon), or
|
|
357
|
+
* `icon-xs` with `size="xs"`, with the slop to 44 high. It has no sideways
|
|
358
|
+
* slop: the actions sit 2 to 4 apart, and slop there would overlap the next
|
|
359
|
+
* one, so a tap on Copy could fork or rate instead. Copying is up to the app (`onPress`): React Native's own
|
|
168
360
|
* `Clipboard` is deprecated, and native-ui adds no clipboard package.
|
|
169
361
|
*/
|
|
170
362
|
export function MessageAction({
|
|
@@ -174,6 +366,7 @@ export function MessageAction({
|
|
|
174
366
|
onPress,
|
|
175
367
|
selected = false,
|
|
176
368
|
disabled,
|
|
369
|
+
size,
|
|
177
370
|
testID,
|
|
178
371
|
}: MessageActionProps) {
|
|
179
372
|
const scheme = useScheme();
|
|
@@ -187,6 +380,8 @@ export function MessageAction({
|
|
|
187
380
|
accessibilityState={{ selected }}
|
|
188
381
|
onPress={onPress}
|
|
189
382
|
disabled={disabled}
|
|
383
|
+
size={size}
|
|
384
|
+
slop="vertical"
|
|
190
385
|
testID={testID}
|
|
191
386
|
/>
|
|
192
387
|
);
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Children, useEffect, type ReactNode } from "react";
|
|
2
|
+
import { BackHandler, Pressable, ScrollView, Text, View, useWindowDimensions } from "react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
|
|
5
|
+
import { palette, withAlpha } from "../palette";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The web panel picker height (`computePanelPopupPlacement`): at most 340 and
|
|
12
|
+
* at least 160, here a share of the window, as a phone has the keyboard up.
|
|
13
|
+
*/
|
|
14
|
+
export const PROMPT_INPUT_MENU = { maxHeight: 340, minHeight: 160, windowShare: 0.35 } as const;
|
|
15
|
+
|
|
16
|
+
/** Menu height for a window height: the share, kept inside the web's 160 to 340. */
|
|
17
|
+
export function promptInputMenuHeight(windowHeight: number): number {
|
|
18
|
+
return Math.round(
|
|
19
|
+
Math.max(PROMPT_INPUT_MENU.minHeight, Math.min(PROMPT_INPUT_MENU.maxHeight, windowHeight * PROMPT_INPUT_MENU.windowShare)),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PromptInputMenuProps = {
|
|
24
|
+
/** Names the list for VoiceOver and TalkBack, as the web listbox `aria-label` (such as "Mentions" or "Skills"). */
|
|
25
|
+
title: string;
|
|
26
|
+
/** `PromptInputMenuItem`s. With none, `empty` shows instead. */
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
/** Shown when there are no rows. Default "No matches.", as the web. */
|
|
29
|
+
empty?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* Closes the picker, as Escape does on the web. Android back calls it while
|
|
32
|
+
* the picker is mounted, before the screen behind gets the press.
|
|
33
|
+
*/
|
|
34
|
+
onDismiss?: () => void;
|
|
35
|
+
testID?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The web `@` / `/` picker (`MentionPickerPopup`, `panel` layout) for touch:
|
|
40
|
+
* the composer's full width, docked right above the field, so it rides the
|
|
41
|
+
* keyboard with it. Give it to `PromptInput` as `menu`. The popover surface
|
|
42
|
+
* (`rounded-surface bg-popover`, the field border in place of the web shadow
|
|
43
|
+
* ring, as the AI parts draw no shadow) holds a `py-1` list that scrolls past
|
|
44
|
+
* `promptInputMenuHeight`. Taps reach a row while the keyboard
|
|
45
|
+
* is up, so picking never drops the keyboard.
|
|
46
|
+
*/
|
|
47
|
+
export function PromptInputMenu({ title, children, empty = "No matches.", onDismiss, testID }: PromptInputMenuProps) {
|
|
48
|
+
const scheme = useScheme();
|
|
49
|
+
// Back closes the picker first. An effect: the subscription is a native one.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!onDismiss) return;
|
|
52
|
+
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
|
|
53
|
+
onDismiss();
|
|
54
|
+
return true;
|
|
55
|
+
});
|
|
56
|
+
return () => subscription.remove();
|
|
57
|
+
}, [onDismiss]);
|
|
58
|
+
const { height } = useWindowDimensions();
|
|
59
|
+
const hasRows = Children.toArray(children).length > 0;
|
|
60
|
+
return (
|
|
61
|
+
<View
|
|
62
|
+
testID={testID}
|
|
63
|
+
className="overflow-hidden border"
|
|
64
|
+
style={{
|
|
65
|
+
backgroundColor: palette.overlay[scheme],
|
|
66
|
+
borderColor: tokens.color.border[scheme],
|
|
67
|
+
borderRadius: tokens.radius.lg,
|
|
68
|
+
maxHeight: promptInputMenuHeight(height),
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{hasRows ? (
|
|
72
|
+
<ScrollView
|
|
73
|
+
accessibilityRole="list"
|
|
74
|
+
accessibilityLabel={title}
|
|
75
|
+
keyboardShouldPersistTaps="always"
|
|
76
|
+
contentContainerClassName="py-1"
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</ScrollView>
|
|
80
|
+
) : (
|
|
81
|
+
// Web: `px-3 py-2.5 text-xs leading-relaxed text-muted-foreground`.
|
|
82
|
+
<Text
|
|
83
|
+
className="px-3 py-2.5 font-instrument-sans text-xs"
|
|
84
|
+
style={[sizeStyle("text-xs", "relaxed"), textStyle("muted", scheme)]}
|
|
85
|
+
>
|
|
86
|
+
{empty}
|
|
87
|
+
</Text>
|
|
88
|
+
)}
|
|
89
|
+
</View>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Web badge chip: `rounded-md bg-muted px-1.5 py-0.5 text-3xs font-medium` (10). */
|
|
94
|
+
const BADGE_TEXT = { fontSize: 10, lineHeight: 12 } as const;
|
|
95
|
+
|
|
96
|
+
export type PromptInputMenuItemProps = {
|
|
97
|
+
/** The character that inserts this row, muted before the label: `@` or `/`. Left out when `leading` names a person. */
|
|
98
|
+
prefix?: string;
|
|
99
|
+
label: string;
|
|
100
|
+
/** One muted line under the label, such as a skill's description. */
|
|
101
|
+
description?: string;
|
|
102
|
+
/** Before the text, such as a teammate's `Avatar` (24). */
|
|
103
|
+
leading?: ReactNode;
|
|
104
|
+
/** At the end in place of `badge`, such as a 16 kind icon. */
|
|
105
|
+
trailing?: ReactNode;
|
|
106
|
+
/** A small source chip at the end (such as "Eva" or "Claude"), with an optional 11 `badgeIcon`. */
|
|
107
|
+
badge?: string;
|
|
108
|
+
badgeIcon?: ReactNode;
|
|
109
|
+
/** The row Return picks, tinted as the web's keyboard-selected row. */
|
|
110
|
+
selected?: boolean;
|
|
111
|
+
onPress?: () => void;
|
|
112
|
+
testID?: string;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** A description longer than this is cut with "…", as the web `previewOneLine`. */
|
|
116
|
+
const DESCRIPTION_MAX = 72;
|
|
117
|
+
|
|
118
|
+
function oneLine(text: string): string {
|
|
119
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
120
|
+
return line.length <= DESCRIPTION_MAX ? line : `${line.slice(0, DESCRIPTION_MAX - 1)}…`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One picker row, the web `MentionRow` in its option button
|
|
125
|
+
* (`mx-1 rounded-lg px-2 text-sm`), at least 44 high. `selected` takes the
|
|
126
|
+
* web `bg-primary/15` (`/25` dark) and a medium label; a press fades to 70%,
|
|
127
|
+
* as the composer chips, in place of the web hover fill.
|
|
128
|
+
*/
|
|
129
|
+
export function PromptInputMenuItem({
|
|
130
|
+
prefix,
|
|
131
|
+
label,
|
|
132
|
+
description,
|
|
133
|
+
leading,
|
|
134
|
+
trailing,
|
|
135
|
+
badge,
|
|
136
|
+
badgeIcon,
|
|
137
|
+
selected = false,
|
|
138
|
+
onPress,
|
|
139
|
+
testID,
|
|
140
|
+
}: PromptInputMenuItemProps) {
|
|
141
|
+
const scheme = useScheme();
|
|
142
|
+
const largerText = useLargerText();
|
|
143
|
+
const muted = textStyle("muted", scheme);
|
|
144
|
+
const labelSize = sizeStyle(largerText ? "text-lg" : "text-sm");
|
|
145
|
+
const detail = description ? oneLine(description) : undefined;
|
|
146
|
+
const end =
|
|
147
|
+
trailing ??
|
|
148
|
+
(badge ? (
|
|
149
|
+
<View
|
|
150
|
+
className="shrink-0 flex-row items-center gap-1 px-1.5 py-0.5"
|
|
151
|
+
style={{ backgroundColor: tokens.color.surfaceSecondary[scheme], borderRadius: 6 }}
|
|
152
|
+
>
|
|
153
|
+
{badgeIcon}
|
|
154
|
+
<Text className="font-instrument-sans-medium" style={[BADGE_TEXT, muted, typeStyle("medium")]}>
|
|
155
|
+
{badge}
|
|
156
|
+
</Text>
|
|
157
|
+
</View>
|
|
158
|
+
) : null);
|
|
159
|
+
return (
|
|
160
|
+
<Pressable
|
|
161
|
+
testID={testID}
|
|
162
|
+
accessibilityRole="button"
|
|
163
|
+
accessibilityState={{ selected }}
|
|
164
|
+
accessibilityLabel={[`${prefix ?? ""}${label}`, detail, badge].filter(Boolean).join(", ")}
|
|
165
|
+
onPress={onPress}
|
|
166
|
+
// Already 44 high and the list's width: no slop past the row.
|
|
167
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET)}
|
|
168
|
+
// A plain style: NativeWind drops a function `style` beside `className`.
|
|
169
|
+
className={cn("mx-1 flex-row items-center gap-2 px-2 py-1.5 active:opacity-70", largerText && "py-2")}
|
|
170
|
+
style={{
|
|
171
|
+
minHeight: MIN_TOUCH_TARGET,
|
|
172
|
+
borderRadius: 8,
|
|
173
|
+
backgroundColor: selected ? withAlpha(palette.primary[scheme], scheme === "dark" ? 0.25 : 0.15) : "transparent",
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{leading}
|
|
177
|
+
<View className="min-w-0 flex-1 gap-0.5">
|
|
178
|
+
<View className="min-w-0 flex-row items-center gap-1.5">
|
|
179
|
+
<Text
|
|
180
|
+
numberOfLines={1}
|
|
181
|
+
className={cn("min-w-0 flex-1", selected ? "font-instrument-sans-medium" : "font-instrument-sans")}
|
|
182
|
+
style={[labelSize, textStyle("foreground", scheme), selected ? typeStyle("medium") : null]}
|
|
183
|
+
>
|
|
184
|
+
{prefix ? <Text style={muted}>{prefix}</Text> : null}
|
|
185
|
+
{label}
|
|
186
|
+
</Text>
|
|
187
|
+
{end}
|
|
188
|
+
</View>
|
|
189
|
+
{detail ? (
|
|
190
|
+
<Text numberOfLines={1} className="font-instrument-sans text-xs" style={[sizeStyle("text-xs"), muted]}>
|
|
191
|
+
{detail}
|
|
192
|
+
</Text>
|
|
193
|
+
) : null}
|
|
194
|
+
</View>
|
|
195
|
+
</Pressable>
|
|
196
|
+
);
|
|
197
|
+
}
|