@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 +47 -4
- package/package.json +1 -1
- package/src/DropdownMenu.tsx +35 -30
- package/src/FloatingSurface.tsx +2 -1
- package/src/ai/card-button.tsx +49 -0
- package/src/ai/index.ts +14 -0
- package/src/ai/message-action-sheet.tsx +165 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +180 -14
- package/src/ai/prompt-input.tsx +25 -5
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- package/src/definiteSize.ts +12 -0
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
|
-
| `
|
|
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.
|
|
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",
|
package/src/DropdownMenu.tsx
CHANGED
|
@@ -217,36 +217,41 @@ export function DropdownMenuContent({
|
|
|
217
217
|
statusBarTranslucent
|
|
218
218
|
onRequestClose={close}
|
|
219
219
|
>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
);
|
package/src/FloatingSurface.tsx
CHANGED
|
@@ -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
|
|
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
|
+
}
|