@vvedantb/native-ui 0.12.0 → 0.13.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 CHANGED
@@ -21,6 +21,7 @@ Peers the app must already have (Expo SDK 57 / RN 0.76+):
21
21
 
22
22
  Optional, only if you import those components:
23
23
 
24
+ - `@rn-primitives/portal` — `Select`. Mount one `<PortalHost />` from this package at the app root (for example in the root layout), outside any `Modal`. The package does not re-export it, so install it with `@rn-primitives/select`.
24
25
  - `@gorhom/bottom-sheet` — `BottomSheetContainer`
25
26
  - `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
26
27
  - `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and `expo-router` — `@vvedantb/native-ui/auth` (0.7.0). `expo-router` also for `@vvedantb/native-ui/expo-web-root`.
@@ -513,13 +514,17 @@ import {
513
514
  | Part | Notes |
514
515
  | --- | --- |
515
516
  | `Conversation`, `ConversationContent`, `ConversationEmptyState`, `ConversationScrollButton`, `useConversation` | A `ScrollView` that sticks to the bottom: while the reader is within 70 of the end, new and growing messages stay in view. Once they drag up it stops following, and the scroll button shows: a round outline arrow (40, slop to 48), 16 above the bottom, with an opaque fill and no shadow. Pressing it scrolls down (a jump with Reduce Motion). For very long threads, drive a `FlatList` with `useConversation`. |
516
- | `Message`, `MessageContent`, `MessageActionsBar`, `MessageAction` | A user message is the web primary bubble (radius 16, bottom-right 14, `px-4 py-3`) at the end of the row; an assistant message has no fill. `MessageAction` is a ghost icon button at the web `icon-sm` size (32, slop to 44). `kind` gives the icon and label: "Copy", "Retry", "Good response", "Bad response". `selected` marks chosen feedback. |
517
+ | `Message`, `MessageContent`, `MessageActionsBar`, `MessageAction` | (0.13.0: `Message` `onLongPress`, `actions`, `highlighted`; `MessageContent` `quote`; the `reply` kind.) A user message is the web primary bubble (radius 16, bottom-right 14, `px-4 py-3`) at the end of the row; an assistant message has no fill. `MessageAction` is a ghost icon button at the web `icon-sm` size (32, slop to 44). `kind` gives the icon and label: "Copy", "Retry", "Good response", "Bad response". `selected` marks chosen feedback. |
517
518
  | `Reasoning`, `ReasoningTrigger`, `ReasoningContent`, `useReasoning` | Opens when streaming starts, times the stream and closes once, a second after it stops. The trigger shows a brain, the shimmering "Thinking…", then "Thought for 12s", and a chevron. The content is muted monospace text on the web `bg-surface-secondary/40` panel. Unlike the web, a reader can close it while it streams. |
518
519
  | `AgentTrace`, `AgentTraceLine`, `AgentTraceIcon`, `AgentTraceTitle`, `AgentTraceDescription`, `AgentTraceCollapsible*` | One step per line, with an optional fold behind a rail. `AgentTraceTitle shimmer` pulses a running step. Spread `useStreamingDisclosure(isStreaming)` on `AgentTraceCollapsible` to open it while a step streams. |
519
520
  | `ActivityStep` | One tool step as a trace line: icon, label, a spinner while `running`, a check when `complete`, a warning on `error`, and a description such as `` `Worked for ${formatElapsed(12)}` ``. |
520
521
  | `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
521
- | `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls. Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
522
- | `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
522
+ | `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls (auto-grow in both layouts from 0.12.1; send resets the height). Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
523
+ | `MessageActionSheet`, `ReplyQuoteChip`, `MessageQuote` (0.13.0) | Quoted replies. Long-press a `Message` with `actions` to open the sheet; Reply first, then Copy. Pass `<ReplyQuoteChip author text onDismiss />` as `PromptInput` `replyTo`; send the reply with a `MessageQuote` in `MessageContent` `quote`. See [Quoted replies](#quoted-replies-0130). |
524
+ | `ToolApprovalCard`, `ToolDraftCard` (0.13.0) | A pending tool call: name, cut args summary, Deny and Approve (`ToolDraftCard` adds Edit). `onDecide("approve" \| "deny" \| "edit")`; `pending` or `disabled` turns the buttons off. |
525
+ | `SubagentRunCard`, `SubagentResultCard` (0.13.0) | A subagent run (title, status pill, elapsed, Cancel while `running`) and its result (summary, detail in a fold). |
526
+ | `MessageBotLabel`, `Message` `bot` (0.13.0) | Which bot sent an assistant message, for group chats: `bot={{ name, avatar }}`, or render `MessageBotLabel` above `MessageContent`. |
527
+ | `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows from 0.12.1), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
523
528
  | `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
524
529
  | `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
525
530
  | `FileChip`, `Attachments`, `Attachment` | A monospace file chip (a button with `onOpenFile`), and attachment chips with a media icon or image thumbnail and a labelled remove button. No hover cards and no grid previews. |
@@ -541,9 +546,47 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
541
546
 
542
547
  `@vvedantb/native-ui/ai-gallery` exports `AiChatGallery`, a full chat screen with a sample thread: a user message, a finished assistant message with folded reasoning and trace, code, sources and actions, and a second answer that streams with its reasoning open until you press Stop. It also has suggestions and the composer with sample attachments. Put it in a dev-only route with a `SafeAreaProvider` above it. `ParityGallery` shows the same thread (`AiChatSamples`) in its "AI chat" section.
543
548
 
549
+
550
+ ### Quoted replies (0.13.0)
551
+
552
+ Building blocks only: the app owns the reply state and the scroll. No new dependency. `expo-haptics` (optional peer) gives a selection haptic when the sheet opens.
553
+
554
+ ```tsx
555
+ import {
556
+ Message, MessageContent, MessageQuote, PromptInput, ReplyQuoteChip, MESSAGE_ACTIONS,
557
+ } from "@vvedantb/native-ui/ai";
558
+
559
+ <Message
560
+ from="user"
561
+ highlighted={highlightedId === item.id}
562
+ actions={[
563
+ { key: "reply", label: "Reply", icon: MESSAGE_ACTIONS.reply.icon, onPress: () => setReplyTo(item) },
564
+ { key: "copy", label: "Copy", icon: MESSAGE_ACTIONS.copy.icon, onPress: () => copy(item.text) },
565
+ { key: "delete", label: "Delete", destructive: true, onPress: () => remove(item) },
566
+ ]}
567
+ >
568
+ <MessageContent
569
+ quote={item.quoted ? <MessageQuote author={item.quoted.author} text={item.quoted.text} onPress={() => jumpTo(item.quoted.id)} /> : undefined}
570
+ >
571
+ {item.text}
572
+ </MessageContent>
573
+ </Message>
574
+
575
+ <PromptInput
576
+ onSubmit={send}
577
+ replyTo={replyTo ? <ReplyQuoteChip author={replyTo.author} text={replyTo.text} onDismiss={() => setReplyTo(null)} /> : undefined}
578
+ />
579
+ ```
580
+
581
+ - `Message`: `onLongPress`, `actions` (`{ key, label, icon?, destructive?, onPress }[]`), `actionSheetTitle`, `highlighted`. With neither `onLongPress` nor `actions` it is the same `View` as before. With either it is a `Pressable` with the same layout and view props, and it stays a non-accessible container so inner buttons keep focus: also offer Reply in a `MessageActionsBar` for screen reader users. `highlighted` draws a primary wash that fades over 1.4 seconds (`MESSAGE_HIGHLIGHT_MS`) and a ring while true; with Reduce Motion it holds still. Set it back to `false` after the scroll.
582
+ - `MessageActionSheet` (`open`, `onClose`, `actions`, `title`, `cancelLabel`, `testID`): an RN `Modal` bottom sheet, no `@gorhom/bottom-sheet`. Closes on the backdrop, Cancel, Android back and after an action. Destructive rows use the danger colour. Rows are 48 high (56 with larger text).
583
+ - `ReplyQuoteChip` (`author`, `text`, `onDismiss`, `dismissLabel`, `accessibilityLabel`, `testID`): author and up to two lines of text, with an X (44 point target, "Dismiss reply quote"). Labelled "Replying to {author}". `PromptInput` `replyTo` draws it above `header`; passing it as `header` works too.
584
+ - `MessageQuote` (`author`, `text`, `onPress`, `accessibilityLabel`, `testID`): a left border, the author and up to two lines. With `onPress` it is a button.
585
+ - Bot label in a group chat (0.13.0): `<Message from="assistant" bot={{ name: "Planner", avatar: <Avatar size="xs" /> }}>`. Or leave `Message` alone and render `<MessageBotLabel name="Planner" />` above `MessageContent` in `ChatThread` `renderMessage`. `ChatThread` needs no change.
586
+
544
587
  ## DropdownMenu (Unreleased)
545
588
 
546
- A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root.
589
+ A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root. From 0.12.1 the backdrop and menu sit in a full-size `View` inside the `Modal`, so the menu stays above tab bars and glass layers.
547
590
 
548
591
  ```tsx
549
592
  <DropdownMenu>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
6
6
  "type": "module",
@@ -217,36 +217,41 @@ export function DropdownMenuContent({
217
217
  statusBarTranslucent
218
218
  onRequestClose={close}
219
219
  >
220
- <Pressable
221
- testID="DropdownMenu-backdrop"
222
- accessibilityRole="button"
223
- accessibilityLabel="Close menu"
224
- onPress={close}
225
- style={StyleSheet.absoluteFill}
226
- />
227
- <View
228
- accessibilityRole="menu"
229
- accessibilityViewIsModal
230
- onAccessibilityEscape={close}
231
- style={{
232
- position: "absolute",
233
- ...position,
234
- minWidth: DROPDOWN_MENU.minWidth,
235
- maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
236
- }}
237
- >
238
- <FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
239
- <ScrollView
240
- bounces={false}
241
- showsVerticalScrollIndicator={false}
242
- onContentSizeChange={(_width, height) => setContentHeight(height)}
243
- contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
244
- >
245
- <View className={cn(className)} style={style} {...props}>
246
- {children}
247
- </View>
248
- </ScrollView>
249
- </FloatingSurface>
220
+ {/* A full-size host, as `OverlayModal` has: without it the backdrop and the
221
+ menu are bare Modal children with no box to size or hit-test against,
222
+ and the menu can stack under tab bars and glass layers. */}
223
+ <View style={{ flex: 1 }} pointerEvents="box-none">
224
+ <Pressable
225
+ testID="DropdownMenu-backdrop"
226
+ accessibilityRole="button"
227
+ accessibilityLabel="Close menu"
228
+ onPress={close}
229
+ style={StyleSheet.absoluteFill}
230
+ />
231
+ <View
232
+ accessibilityRole="menu"
233
+ accessibilityViewIsModal
234
+ onAccessibilityEscape={close}
235
+ style={{
236
+ position: "absolute",
237
+ ...position,
238
+ minWidth: DROPDOWN_MENU.minWidth,
239
+ maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
240
+ }}
241
+ >
242
+ <FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
243
+ <ScrollView
244
+ bounces={false}
245
+ showsVerticalScrollIndicator={false}
246
+ onContentSizeChange={(_width, height) => setContentHeight(height)}
247
+ contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
248
+ >
249
+ <View className={cn(className)} style={style} {...props}>
250
+ {children}
251
+ </View>
252
+ </ScrollView>
253
+ </FloatingSurface>
254
+ </View>
250
255
  </View>
251
256
  </Modal>
252
257
  );
@@ -2,6 +2,7 @@ import { Platform, StyleSheet, View, type ViewProps } from "react-native";
2
2
  import { useColorScheme } from "nativewind";
3
3
  import { glassMode, loadBlur } from "./blur";
4
4
  import { cn } from "./cn";
5
+ import { hasDefiniteHeight } from "./definiteSize";
5
6
  import {
6
7
  floatingFill,
7
8
  floatingHairline,
@@ -65,7 +66,7 @@ export function FloatingSurface({
65
66
  backgroundColor: mode === "blur" ? "transparent" : fill,
66
67
  },
67
68
  Platform.OS === "ios" ? styles.continuous : null,
68
- style != null ? styles.fill : null,
69
+ hasDefiniteHeight(style) ? styles.fill : null,
69
70
  ]}
70
71
  >
71
72
  {mode === "blur" && BlurView ? (
@@ -0,0 +1,49 @@
1
+ import { Pressable, Text } from "react-native";
2
+ import { cn } from "../cn";
3
+ import { buttonColors, MIN_TOUCH_TARGET, sizeStyle, touchSlop, typeStyle, type ButtonVariant } from "../controlStyles";
4
+ import { useLargerText } from "../theme";
5
+ import { useScheme } from "../useScheme";
6
+ import { chatTextSize } from "./chat-text";
7
+
8
+ export type CardButtonProps = {
9
+ label: string;
10
+ variant: Extract<ButtonVariant, "primary" | "outline" | "secondary" | "destructive" | "ghost">;
11
+ onPress?: () => void;
12
+ disabled?: boolean;
13
+ accessibilityLabel?: string;
14
+ testID?: string;
15
+ };
16
+
17
+ /**
18
+ * The compact pill button of the tool and subagent cards: at least 44 points
19
+ * high, label in the `small` chat size (steps up with larger text). Internal.
20
+ */
21
+ export function CardButton({ label, variant, onPress, disabled, accessibilityLabel, testID }: CardButtonProps) {
22
+ const scheme = useScheme();
23
+ const largerText = useLargerText();
24
+ const size = chatTextSize("small", largerText);
25
+ const colors = buttonColors(variant, scheme)!;
26
+ return (
27
+ <Pressable
28
+ testID={testID}
29
+ accessibilityRole="button"
30
+ accessibilityLabel={accessibilityLabel ?? label}
31
+ accessibilityState={{ disabled: !!disabled }}
32
+ disabled={disabled}
33
+ onPress={onPress}
34
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
35
+ className={cn("items-center justify-center rounded-full border px-4 active:opacity-70")}
36
+ style={{
37
+ minHeight: MIN_TOUCH_TARGET,
38
+ minWidth: MIN_TOUCH_TARGET,
39
+ backgroundColor: colors.backgroundColor,
40
+ borderColor: colors.borderColor,
41
+ opacity: disabled ? 0.5 : 1,
42
+ }}
43
+ >
44
+ <Text className={cn(size, "font-instrument-sans-semibold")} style={[sizeStyle(size), typeStyle("semibold"), { color: colors.color }]}>
45
+ {label}
46
+ </Text>
47
+ </Pressable>
48
+ );
49
+ }
package/src/ai/index.ts CHANGED
@@ -35,7 +35,9 @@ export {
35
35
  MessageActionsBar,
36
36
  MessageAction,
37
37
  MessageMeta,
38
+ MessageBotLabel,
38
39
  MESSAGE_ACTIONS,
40
+ MESSAGE_HIGHLIGHT_MS,
39
41
  userBubbleStyle,
40
42
  tintBubbleStyle,
41
43
  } from "./message";
@@ -46,9 +48,17 @@ export type {
46
48
  MessageBubble,
47
49
  MessageActionsBarProps,
48
50
  MessageMetaProps,
51
+ MessageBot,
52
+ MessageBotLabelProps,
49
53
  MessageActionKind,
50
54
  MessageActionProps,
51
55
  } from "./message";
56
+ export { MessageActionSheet } from "./message-action-sheet";
57
+ export type { MessageActionSheetProps, MessageSheetAction } from "./message-action-sheet";
58
+ export { ReplyQuoteChip } from "./reply-quote-chip";
59
+ export type { ReplyQuoteChipProps } from "./reply-quote-chip";
60
+ export { MessageQuote, QUOTE_MAX_LINES } from "./message-quote";
61
+ export type { MessageQuoteProps } from "./message-quote";
52
62
  export {
53
63
  Reasoning,
54
64
  ReasoningTrigger,
@@ -136,3 +146,7 @@ export { ChoiceQuestion, choiceAnswers } from "./choice-question";
136
146
  export type { ChoiceQuestionProps, ChoiceQuestionItem } from "./choice-question";
137
147
  export { ActivityStepDetail } from "./activity-step-detail";
138
148
  export type { ActivityStepDetailProps, ActivityStepDetailStep } from "./activity-step-detail";
149
+ export { ToolApprovalCard, ToolDraftCard, summarizeArgs, ARGS_MAX_CHARS } from "./tool-approval-card";
150
+ export type { ToolApprovalCardProps, ToolDraftCardProps, ToolDecision } from "./tool-approval-card";
151
+ export { SubagentRunCard, SubagentResultCard, subagentStatusColors } from "./subagent-cards";
152
+ export type { SubagentRunCardProps, SubagentResultCardProps, SubagentStatus } from "./subagent-cards";
@@ -0,0 +1,165 @@
1
+ import { useEffect, type ComponentType } from "react";
2
+ import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import type { IconProps } from "@tabler/icons-react-native";
5
+ import { cn } from "../cn";
6
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
7
+ import { selectionHaptic } from "../haptics";
8
+ import { palette } from "../palette";
9
+ import { useLargerText } from "../theme";
10
+ import { tokens } from "../tokens";
11
+ import { useReduceMotion } from "../useReduceMotion";
12
+ import { useScheme } from "../useScheme";
13
+ import { chatTextSize } from "./chat-text";
14
+
15
+ /** One row of `MessageActionSheet`. */
16
+ export type MessageSheetAction = {
17
+ key: string;
18
+ label: string;
19
+ icon?: ComponentType<IconProps>;
20
+ /** Draws the row in the danger colour, such as Delete. */
21
+ destructive?: boolean;
22
+ onPress: () => void;
23
+ };
24
+
25
+ export type MessageActionSheetProps = {
26
+ open: boolean;
27
+ /** Called on a backdrop tap, Cancel, Android back and after an action runs. */
28
+ onClose: () => void;
29
+ actions: MessageSheetAction[];
30
+ /** Shown above the rows, and names the sheet for VoiceOver and TalkBack. Default "Message actions". */
31
+ title?: string;
32
+ /** Default "Cancel". */
33
+ cancelLabel?: string;
34
+ testID?: string;
35
+ };
36
+
37
+ /**
38
+ * The sheet that opens on a long press of a chat message: a bottom sheet in
39
+ * an RN `Modal` (no `@gorhom/bottom-sheet`) with one pressable row per action
40
+ * and a Cancel row. It closes on the backdrop, Cancel, Android back and after
41
+ * an action. A `selectionHaptic()` fires when it opens (`expo-haptics` is an
42
+ * optional peer). Row testIDs are `{testID}-action-{key}`.
43
+ */
44
+ export function MessageActionSheet({
45
+ open,
46
+ onClose,
47
+ actions,
48
+ title = "Message actions",
49
+ cancelLabel = "Cancel",
50
+ testID = "message-action-sheet",
51
+ }: MessageActionSheetProps) {
52
+ const scheme = useScheme();
53
+ const largerText = useLargerText();
54
+ const insets = useSafeAreaInsets();
55
+ const reduceMotion = useReduceMotion();
56
+
57
+ useEffect(() => {
58
+ if (open) selectionHaptic();
59
+ }, [open]);
60
+
61
+ const caption = chatTextSize("caption", largerText);
62
+ const body = chatTextSize("body", largerText);
63
+ const minHeight = largerText ? 56 : MIN_TOUCH_TARGET + 4;
64
+
65
+ return (
66
+ <Modal
67
+ visible={open}
68
+ transparent
69
+ animationType={reduceMotion ? "none" : "fade"}
70
+ statusBarTranslucent
71
+ onRequestClose={onClose}
72
+ >
73
+ <Pressable
74
+ testID={`${testID}-backdrop`}
75
+ accessibilityRole="button"
76
+ accessibilityLabel="Close"
77
+ onPress={onClose}
78
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
79
+ style={[StyleSheet.absoluteFill, { backgroundColor: palette.backdrop[scheme] }]}
80
+ />
81
+ <View pointerEvents="box-none" style={styles.anchor}>
82
+ <View
83
+ testID={testID}
84
+ accessibilityViewIsModal
85
+ accessibilityLabel={title}
86
+ onAccessibilityEscape={onClose}
87
+ className="w-full self-center px-2"
88
+ style={{ maxWidth: 480, paddingBottom: insets.bottom + 8 }}
89
+ >
90
+ <View
91
+ className="overflow-hidden"
92
+ style={{ backgroundColor: tokens.color.surface[scheme], borderRadius: tokens.radius.card }}
93
+ >
94
+ <Text
95
+ accessibilityRole="header"
96
+ numberOfLines={1}
97
+ className={cn(caption, "px-4 pb-1 pt-3 font-instrument-sans-medium")}
98
+ style={[sizeStyle(caption), typeStyle("medium"), textStyle("muted", scheme)]}
99
+ >
100
+ {title}
101
+ </Text>
102
+ {actions.map((action, index) => {
103
+ const Icon = action.icon;
104
+ const color = action.destructive ? palette.danger[scheme] : tokens.color.foreground[scheme];
105
+ return (
106
+ <Pressable
107
+ key={action.key}
108
+ testID={`${testID}-action-${action.key}`}
109
+ accessibilityRole="button"
110
+ accessibilityLabel={action.label}
111
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
112
+ onPress={() => {
113
+ onClose();
114
+ action.onPress();
115
+ }}
116
+ className="flex-row items-center gap-3 px-4"
117
+ style={[
118
+ { minHeight },
119
+ index > 0 && {
120
+ borderTopWidth: StyleSheet.hairlineWidth,
121
+ borderTopColor: tokens.color.separator[scheme],
122
+ },
123
+ ]}
124
+ >
125
+ {Icon ? <Icon size={largerText ? 22 : 20} color={color} /> : null}
126
+ <Text
127
+ numberOfLines={1}
128
+ className={cn(body, "min-w-0 flex-1 font-instrument-sans-medium")}
129
+ style={[sizeStyle(body), typeStyle("medium"), { color }]}
130
+ >
131
+ {action.label}
132
+ </Text>
133
+ </Pressable>
134
+ );
135
+ })}
136
+ </View>
137
+ <Pressable
138
+ testID={`${testID}-cancel`}
139
+ accessibilityRole="button"
140
+ accessibilityLabel={cancelLabel}
141
+ onPress={onClose}
142
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
143
+ className="mt-2 items-center justify-center"
144
+ style={{
145
+ minHeight,
146
+ backgroundColor: tokens.color.surface[scheme],
147
+ borderRadius: tokens.radius.card,
148
+ }}
149
+ >
150
+ <Text
151
+ className={cn(body, "font-instrument-sans-semibold")}
152
+ style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
153
+ >
154
+ {cancelLabel}
155
+ </Text>
156
+ </Pressable>
157
+ </View>
158
+ </View>
159
+ </Modal>
160
+ );
161
+ }
162
+
163
+ const styles = StyleSheet.create({
164
+ anchor: { flex: 1, justifyContent: "flex-end" },
165
+ });
@@ -0,0 +1,98 @@
1
+ import { useContext } from "react";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
5
+ import { withAlpha } from "../palette";
6
+ import { useLargerText } from "../theme";
7
+ import { useScheme } from "../useScheme";
8
+ import { chatTextSize } from "./chat-text";
9
+ import { ChatTextColorContext } from "./response-text";
10
+
11
+ export type MessageQuoteProps = {
12
+ /** Who wrote the quoted message, such as "You" or the bot name. */
13
+ author: string;
14
+ /** The quoted text. Shown on up to two lines. */
15
+ text: string;
16
+ /** Makes the quote a button, so the app can scroll to and highlight the original. */
17
+ onPress?: () => void;
18
+ /** Names the button. Default "Go to quoted message from {author}". Used with `onPress` only. */
19
+ accessibilityLabel?: string;
20
+ className?: string;
21
+ testID?: string;
22
+ };
23
+
24
+ /** Lines of the quoted text before it is cut with an ellipsis. */
25
+ export const QUOTE_MAX_LINES = 2;
26
+
27
+ /**
28
+ * A compact quote inside a message bubble, above the message text: a left
29
+ * border, the author in the caption size and the quoted text on up to two
30
+ * lines. It takes its colour from the enclosing `MessageContent`, so it reads
31
+ * on the primary user bubble as well as on the tint and assistant ones. With
32
+ * `onPress` it is a button (44 point minimum height).
33
+ */
34
+ export function MessageQuote({
35
+ author,
36
+ text,
37
+ onPress,
38
+ accessibilityLabel,
39
+ className,
40
+ testID,
41
+ }: MessageQuoteProps) {
42
+ const scheme = useScheme();
43
+ const largerText = useLargerText();
44
+ const inherited = useContext(ChatTextColorContext);
45
+ const color = inherited ?? textStyle("foreground", scheme).color;
46
+ const caption = chatTextSize("caption", largerText);
47
+ const body = chatTextSize("small", largerText);
48
+ const content = (
49
+ <>
50
+ <Text
51
+ numberOfLines={1}
52
+ className={cn(caption, "font-instrument-sans-semibold")}
53
+ style={[sizeStyle(caption), typeStyle("semibold"), { color: withAlpha(color, 0.75) }]}
54
+ >
55
+ {author}
56
+ </Text>
57
+ <Text
58
+ numberOfLines={QUOTE_MAX_LINES}
59
+ className={cn(body, "font-instrument-sans")}
60
+ style={[sizeStyle(body), { color: withAlpha(color, 0.9) }]}
61
+ >
62
+ {text}
63
+ </Text>
64
+ </>
65
+ );
66
+ const style = {
67
+ borderLeftWidth: 3,
68
+ borderLeftColor: withAlpha(color, 0.45),
69
+ borderRadius: 4,
70
+ };
71
+ if (!onPress) {
72
+ return (
73
+ <View
74
+ testID={testID}
75
+ accessible
76
+ accessibilityLabel={`Quoted message from ${author}: ${text}`}
77
+ className={cn("min-w-0 gap-0.5 py-0.5 pl-2.5", className)}
78
+ style={style}
79
+ >
80
+ {content}
81
+ </View>
82
+ );
83
+ }
84
+ return (
85
+ <Pressable
86
+ testID={testID}
87
+ accessibilityRole="button"
88
+ accessibilityLabel={accessibilityLabel ?? `Go to quoted message from ${author}`}
89
+ accessibilityHint={text}
90
+ onPress={onPress}
91
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
92
+ className={cn("min-h-11 min-w-0 justify-center gap-0.5 py-0.5 pl-2.5", className)}
93
+ style={style}
94
+ >
95
+ {content}
96
+ </Pressable>
97
+ );
98
+ }