vibefast-cli 0.1.1
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/FINAL-STATUS.md +144 -0
- package/HOW-IT-WORKS.md +559 -0
- package/PLAN.md +453 -0
- package/README.md +129 -0
- package/RECIPES-READY.md +172 -0
- package/STATUS.md +199 -0
- package/SUCCESS.md +259 -0
- package/TESTING-CHECKLIST.md +450 -0
- package/cloudflare-worker/.wrangler/state/v3/kv/64907821e2634080acce34618d2f3d4c/blobs/11f2769953c717e188062bc644da97c1fd1e4d6d0813a226ce7567dba759afab0000019a736fb8d4 +1 -0
- package/cloudflare-worker/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/0b03767237c0408301af51ca35d4b09470cbc479c7e5f23cc9de774749d23c59.sqlite +0 -0
- package/cloudflare-worker/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/0b03767237c0408301af51ca35d4b09470cbc479c7e5f23cc9de774749d23c59.sqlite-shm +0 -0
- package/cloudflare-worker/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/0b03767237c0408301af51ca35d4b09470cbc479c7e5f23cc9de774749d23c59.sqlite-wal +0 -0
- package/cloudflare-worker/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite +0 -0
- package/cloudflare-worker/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite-shm +0 -0
- package/cloudflare-worker/.wrangler/state/v3/r2/miniflare-R2BucketObject/d1cc388a1a0ef44dd5669fd1a165d168b61362136c8b5fa50aefd96c72688e54.sqlite-wal +0 -0
- package/cloudflare-worker/.wrangler/state/v3/r2/vibefast-recipes/blobs/620e8cf7c35d9806da25dee237e1d7e8b2432bd98f755b60e2c7f08a48d2c7b90000019a73736484 +0 -0
- package/cloudflare-worker/MIGRATION.md +160 -0
- package/cloudflare-worker/QUICKSTART.md +200 -0
- package/cloudflare-worker/README.md +242 -0
- package/cloudflare-worker/generate-token.js +32 -0
- package/cloudflare-worker/mini-native@latest.zip +0 -0
- package/cloudflare-worker/setup.sh +143 -0
- package/cloudflare-worker/test-recipe/apps/native/src/app/mini/index.tsx +15 -0
- package/cloudflare-worker/test-recipe/recipe.json +16 -0
- package/cloudflare-worker/worker.js +308 -0
- package/cloudflare-worker/wrangler.toml +13 -0
- package/dist/commands/add.d.ts +3 -0
- package/dist/commands/add.d.ts.map +1 -0
- package/dist/commands/add.js +149 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/devices.d.ts +3 -0
- package/dist/commands/devices.d.ts.map +1 -0
- package/dist/commands/devices.js +35 -0
- package/dist/commands/devices.js.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +67 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +40 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +23 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +3 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +16 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/remove.d.ts +3 -0
- package/dist/commands/remove.d.ts.map +1 -0
- package/dist/commands/remove.js +67 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/core/__tests__/journal.test.d.ts +2 -0
- package/dist/core/__tests__/journal.test.d.ts.map +1 -0
- package/dist/core/__tests__/journal.test.js +101 -0
- package/dist/core/__tests__/journal.test.js.map +1 -0
- package/dist/core/__tests__/validate.test.d.ts +2 -0
- package/dist/core/__tests__/validate.test.d.ts.map +1 -0
- package/dist/core/__tests__/validate.test.js +53 -0
- package/dist/core/__tests__/validate.test.js.map +1 -0
- package/dist/core/archive.d.ts +2 -0
- package/dist/core/archive.d.ts.map +1 -0
- package/dist/core/archive.js +59 -0
- package/dist/core/archive.js.map +1 -0
- package/dist/core/auth.d.ts +15 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +76 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/codemod.d.ts +20 -0
- package/dist/core/codemod.d.ts.map +1 -0
- package/dist/core/codemod.js +150 -0
- package/dist/core/codemod.js.map +1 -0
- package/dist/core/fsx.d.ts +12 -0
- package/dist/core/fsx.d.ts.map +1 -0
- package/dist/core/fsx.js +70 -0
- package/dist/core/fsx.js.map +1 -0
- package/dist/core/http.d.ts +30 -0
- package/dist/core/http.d.ts.map +1 -0
- package/dist/core/http.js +95 -0
- package/dist/core/http.js.map +1 -0
- package/dist/core/journal.d.ts +18 -0
- package/dist/core/journal.d.ts.map +1 -0
- package/dist/core/journal.js +34 -0
- package/dist/core/journal.js.map +1 -0
- package/dist/core/log.d.ts +8 -0
- package/dist/core/log.d.ts.map +1 -0
- package/dist/core/log.js +9 -0
- package/dist/core/log.js.map +1 -0
- package/dist/core/pathGuard.d.ts +3 -0
- package/dist/core/pathGuard.d.ts.map +1 -0
- package/dist/core/pathGuard.js +18 -0
- package/dist/core/pathGuard.js.map +1 -0
- package/dist/core/paths.d.ts +11 -0
- package/dist/core/paths.d.ts.map +1 -0
- package/dist/core/paths.js +22 -0
- package/dist/core/paths.js.map +1 -0
- package/dist/core/validate.d.ts +8 -0
- package/dist/core/validate.d.ts.map +1 -0
- package/dist/core/validate.js +27 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/docs/decisions.md +55 -0
- package/package.json +39 -0
- package/recipes/audio-recorder/apps/native/src/app/audio-recorder/index.tsx +5 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/components/audio-player.tsx +301 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/components/audio-recorder.tsx +373 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/components/audio-waveform.tsx +270 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/components/index.ts +4 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/components/recording-list.tsx +89 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/audio-player-demo.tsx +66 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/audio-recorder-cloud.tsx +68 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/audio-recorder-interview.tsx +102 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/basic.tsx +27 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/index.ts +5 -0
- package/recipes/audio-recorder/apps/native/src/features/audio-recorder/demo/with-recording-list-demo.tsx +82 -0
- package/recipes/audio-recorder/recipe.json +22 -0
- package/recipes/audio-recorder@latest.zip +0 -0
- package/recipes/charts/apps/native/src/app/charts/index.tsx +3 -0
- package/recipes/charts/apps/native/src/features/charts/README.md +185 -0
- package/recipes/charts/apps/native/src/features/charts/app/preview.tsx +223 -0
- package/recipes/charts/apps/native/src/features/charts/components/area-chart.tsx +40 -0
- package/recipes/charts/apps/native/src/features/charts/components/bar-chart.tsx +143 -0
- package/recipes/charts/apps/native/src/features/charts/components/candlestick-chart.tsx +196 -0
- package/recipes/charts/apps/native/src/features/charts/components/chart-card.tsx +65 -0
- package/recipes/charts/apps/native/src/features/charts/components/column-chart.tsx +143 -0
- package/recipes/charts/apps/native/src/features/charts/components/doughnut-chart.tsx +246 -0
- package/recipes/charts/apps/native/src/features/charts/components/index.ts +10 -0
- package/recipes/charts/apps/native/src/features/charts/components/line-chart.tsx +308 -0
- package/recipes/charts/apps/native/src/features/charts/components/radar-chart.tsx +180 -0
- package/recipes/charts/apps/native/src/features/charts/components/radial-bar-chart.tsx +188 -0
- package/recipes/charts/apps/native/src/features/charts/components/stacked-area-chart.tsx +265 -0
- package/recipes/charts/apps/native/src/features/charts/components/stacked-bar-chart.tsx +322 -0
- package/recipes/charts/apps/native/src/features/charts/data/mock-data.ts +183 -0
- package/recipes/charts/apps/native/src/features/charts/types/index.ts +66 -0
- package/recipes/charts/recipe.json +22 -0
- package/recipes/charts@latest.zip +0 -0
- package/recipes/chatbot/apps/native/src/app/chatbot/index.tsx +1 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/app/index.tsx +302 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/chat-header-buttons.tsx +59 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/chat-input-bar.tsx +469 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/chat-markdown.tsx +575 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/chat-message-bubble.tsx +246 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/chat-settings-modal.tsx +161 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/image-preview-list.tsx +115 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/markdown/code-block.tsx +165 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/markdown/index.ts +10 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/markdown/table-renderer.tsx +129 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/message-error-boundary.tsx +78 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/message-list.tsx +173 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/model-selector.tsx +283 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/report-content-modal.tsx +188 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/components/suggested-messages.tsx +67 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/constants/models.ts +20 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/constants/report-reasons.ts +9 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-attachment-cache.ts +143 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-chat-config.ts +664 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-chat-handlers.ts +359 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-chatbot-settings.ts +89 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-conversation.ts +79 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-image-picker.ts +122 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-keyboard-coordinator.ts +161 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/hooks/use-smart-scroll-manager.ts +207 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/models/index.ts +86 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/models/models.ts +162 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/models/providers.ts +62 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/models/types.ts +40 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/services/file-uploader.ts +238 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/services/message-handler-service.ts +180 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/types/index.ts +60 -0
- package/recipes/chatbot/apps/native/src/features/chatbot/utils/chat-telemetry.ts +91 -0
- package/recipes/chatbot/recipe.json +22 -0
- package/recipes/chatbot@latest.zip +0 -0
- package/recipes/image-generator/apps/native/src/app/image-generator/gallery.tsx +3 -0
- package/recipes/image-generator/apps/native/src/app/image-generator/index.tsx +3 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/app/_layout.tsx +25 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/app/gallery.tsx +217 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/app/index.tsx +237 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/components/gallery-image.tsx +26 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/components/image-detail-modal.tsx +215 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/components/image-model-selector.tsx +210 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/components/image-placeholder.tsx +26 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/hooks/use-image-gallery.ts +71 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/hooks/use-image-generator-settings.ts +152 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/hooks/use-image-generator.ts +93 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/models/models.ts +66 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/services/image-gallery-service.ts +98 -0
- package/recipes/image-generator/apps/native/src/features/image-generator/services/image-save-service.ts +121 -0
- package/recipes/image-generator/recipe.json +22 -0
- package/recipes/image-generator@latest.zip +0 -0
- package/recipes/quiz/apps/native/src/app/quiz/index.tsx +47 -0
- package/recipes/quiz/apps/native/src/features/quiz/components/question.tsx +67 -0
- package/recipes/quiz/apps/native/src/features/quiz/config.ts +11 -0
- package/recipes/quiz/apps/native/src/features/quiz/index.tsx +133 -0
- package/recipes/quiz/recipe.json +22 -0
- package/recipes/quiz@latest.zip +0 -0
- package/recipes/tracker-app/apps/native/src/app/tracker-app/index.tsx +1 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/app/index.tsx +108 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/animated-number.tsx +102 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/calorie-card.tsx +66 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/circular-progress.tsx +97 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/floating-add-button.tsx +27 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/macro-card.tsx +80 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/promo-banner.tsx +98 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/recently-logged.tsx +64 -0
- package/recipes/tracker-app/apps/native/src/features/tracker-app/components/week-calendar.tsx +68 -0
- package/recipes/tracker-app/recipe.json +22 -0
- package/recipes/tracker-app@latest.zip +0 -0
- package/recipes/upload-all.sh +32 -0
- package/recipes/voice-bot/apps/native/src/app/voice-bot/index.tsx +27 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/README.md +185 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/components/conversation-status.tsx +76 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/components/index.ts +4 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/components/message-input.tsx +98 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/components/voice-bot-screen.tsx +173 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/components/voice-controls.tsx +73 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/index.ts +3 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/services/index.ts +1 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/services/use-voice-bot.ts +161 -0
- package/recipes/voice-bot/apps/native/src/features/voice-bot/types.ts +29 -0
- package/recipes/voice-bot/recipe.json +22 -0
- package/recipes/voice-bot@latest.zip +0 -0
- package/scripts/create-recipes.mjs +189 -0
- package/src/commands/add.ts +183 -0
- package/src/commands/devices.ts +38 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/list.ts +45 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/logout.ts +15 -0
- package/src/commands/remove.ts +78 -0
- package/src/core/__tests__/journal.test.ts +119 -0
- package/src/core/__tests__/validate.test.ts +64 -0
- package/src/core/archive.ts +69 -0
- package/src/core/auth.ts +103 -0
- package/src/core/codemod.ts +211 -0
- package/src/core/fsx.ts +80 -0
- package/src/core/http.ts +136 -0
- package/src/core/journal.ts +64 -0
- package/src/core/log.ts +9 -0
- package/src/core/pathGuard.ts +22 -0
- package/src/core/paths.ts +33 -0
- package/src/core/validate.ts +44 -0
- package/src/index.ts +27 -0
- package/test-critical-cases.mjs +258 -0
- package/tsconfig.json +21 -0
- package/vitest.config.mts +12 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Feather, Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import type { Id } from '@vibefast/backend/_generated/dataModel';
|
|
3
|
+
import type { Message } from 'ai'; // Base Message type
|
|
4
|
+
import * as Clipboard from 'expo-clipboard';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
import { View } from 'react-native';
|
|
7
|
+
|
|
8
|
+
import { Image, Pressable, Text } from '@/components/ui';
|
|
9
|
+
import { useToast } from '@/components/ui/utils';
|
|
10
|
+
import { ChatMarkdown } from '@/features/chatbot/components/chat-markdown';
|
|
11
|
+
import { translate } from '@/lib';
|
|
12
|
+
import { useThemeConfig } from '@/lib/use-theme-config';
|
|
13
|
+
|
|
14
|
+
// Local definition for MessageContentPart compatible with Vercel AI SDK structure
|
|
15
|
+
// This should be consistent with how `useChat` hook and AI SDK handle content.
|
|
16
|
+
export type LocalMessageContentPart =
|
|
17
|
+
| { type: 'text'; text: string }
|
|
18
|
+
| { type: 'image'; image: string | URL }; // Allow string for input from client, server can convert to URL
|
|
19
|
+
|
|
20
|
+
type ConvexAttachment = {
|
|
21
|
+
type: 'image';
|
|
22
|
+
storageId: Id<'_storage'>;
|
|
23
|
+
url?: string; // This is now pre-fetched from the parent component
|
|
24
|
+
fileName?: string;
|
|
25
|
+
mimeType?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// The message prop for ChatMessageBubble
|
|
29
|
+
export type AppMessage = Omit<Message, 'content'> & {
|
|
30
|
+
content: string | LocalMessageContentPart[]; // Content matches AI SDK
|
|
31
|
+
attachments?: ConvexAttachment[]; // Custom prop for Convex-stored attachments
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type ChatMessageBubbleProps = {
|
|
35
|
+
message: AppMessage;
|
|
36
|
+
onReportMessage?: (message: AppMessage) => void; // AppMessage type for handler
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const ConvexImage: React.FC<{
|
|
40
|
+
storageId: Id<'_storage'>;
|
|
41
|
+
isUser: boolean;
|
|
42
|
+
fileName?: string;
|
|
43
|
+
preloadedUrl?: string | null; // Pre-fetched URL from parent
|
|
44
|
+
}> = React.memo(({ storageId, isUser, fileName, preloadedUrl }) => {
|
|
45
|
+
// Use pre-loaded URL if available, otherwise show loading
|
|
46
|
+
const imageUrl = preloadedUrl;
|
|
47
|
+
const theme = useThemeConfig();
|
|
48
|
+
|
|
49
|
+
// Fixed aspect ratio container to prevent layout shift when image loads
|
|
50
|
+
return (
|
|
51
|
+
<View
|
|
52
|
+
className="my-1 w-48 overflow-hidden rounded-lg"
|
|
53
|
+
style={{ aspectRatio: 1.5, minHeight: 128 }}
|
|
54
|
+
testID={`convex-image-container-${storageId}`}
|
|
55
|
+
>
|
|
56
|
+
{!imageUrl ? (
|
|
57
|
+
<View
|
|
58
|
+
className="flex-1 items-center justify-center"
|
|
59
|
+
style={{ backgroundColor: theme.colors.muted }}
|
|
60
|
+
>
|
|
61
|
+
<Text
|
|
62
|
+
className={`text-sm ${isUser ? 'text-white' : 'text-neutral-500 dark:text-neutral-400'}`}
|
|
63
|
+
>
|
|
64
|
+
Loading{fileName ? ` ${fileName}` : ''}...
|
|
65
|
+
</Text>
|
|
66
|
+
</View>
|
|
67
|
+
) : (
|
|
68
|
+
<Image
|
|
69
|
+
source={{ uri: imageUrl }}
|
|
70
|
+
className="flex-1"
|
|
71
|
+
accessibilityRole="image"
|
|
72
|
+
accessibilityLabel={
|
|
73
|
+
fileName || 'Message image attachment from storage'
|
|
74
|
+
}
|
|
75
|
+
testID={`convex-image-loaded-${storageId}`}
|
|
76
|
+
contentFit="cover"
|
|
77
|
+
transition={0}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const ChatMessageBubble: React.FC<ChatMessageBubbleProps> = ({
|
|
85
|
+
message,
|
|
86
|
+
onReportMessage,
|
|
87
|
+
}) => {
|
|
88
|
+
const theme = useThemeConfig();
|
|
89
|
+
const isUser = message.role === 'user';
|
|
90
|
+
const isBotMessage = message.role === 'assistant';
|
|
91
|
+
const toast = useToast();
|
|
92
|
+
|
|
93
|
+
const handleCopyMessage = async () => {
|
|
94
|
+
try {
|
|
95
|
+
let textToCopy = '';
|
|
96
|
+
if (typeof message.content === 'string') {
|
|
97
|
+
textToCopy = message.content;
|
|
98
|
+
} else if (Array.isArray(message.content)) {
|
|
99
|
+
textToCopy = message.content
|
|
100
|
+
.filter((part) => part.type === 'text')
|
|
101
|
+
.map((part) => part.text)
|
|
102
|
+
.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (textToCopy) {
|
|
106
|
+
await Clipboard.setStringAsync(textToCopy);
|
|
107
|
+
} else {
|
|
108
|
+
toast.warning(translate('chatbot.empty_message'), { duration: 2000 });
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error('[CHATBOT_FRONTEND] Copy failed:', {
|
|
112
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
113
|
+
});
|
|
114
|
+
toast.error(translate('chatbot.copy_failed'), { duration: 3000 });
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Separate text content from images
|
|
119
|
+
const textContent: string[] = [];
|
|
120
|
+
const imageContent: React.ReactNode[] = [];
|
|
121
|
+
|
|
122
|
+
// Handle message.content (string or LocalMessageContentPart[])
|
|
123
|
+
if (typeof message.content === 'string') {
|
|
124
|
+
if (message.content.trim()) {
|
|
125
|
+
textContent.push(message.content);
|
|
126
|
+
}
|
|
127
|
+
} else if (Array.isArray(message.content)) {
|
|
128
|
+
message.content.forEach((part, index) => {
|
|
129
|
+
if (part.type === 'text' && part.text) {
|
|
130
|
+
textContent.push(part.text);
|
|
131
|
+
} else if (part.type === 'image' && part.image) {
|
|
132
|
+
const uri =
|
|
133
|
+
typeof part.image === 'string' ? part.image : part.image.toString();
|
|
134
|
+
|
|
135
|
+
imageContent.push(
|
|
136
|
+
<Image
|
|
137
|
+
key={`content-image-${index}`}
|
|
138
|
+
source={{ uri }}
|
|
139
|
+
className="mb-2 h-32 w-48 rounded-2xl"
|
|
140
|
+
accessibilityRole="image"
|
|
141
|
+
accessibilityLabel="Image from message content"
|
|
142
|
+
testID={`content-image-${index}`}
|
|
143
|
+
contentFit="cover"
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle message.attachments (from Convex) - now with pre-fetched URLs
|
|
151
|
+
if (message.attachments && message.attachments.length > 0) {
|
|
152
|
+
message.attachments.forEach((attachment, index) => {
|
|
153
|
+
if (attachment.type === 'image' && attachment.storageId) {
|
|
154
|
+
imageContent.push(
|
|
155
|
+
<ConvexImage
|
|
156
|
+
key={`attachment-image-${attachment.storageId}-${index}`}
|
|
157
|
+
storageId={attachment.storageId}
|
|
158
|
+
isUser={isUser}
|
|
159
|
+
fileName={attachment.fileName}
|
|
160
|
+
preloadedUrl={attachment.url}
|
|
161
|
+
/>,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<View
|
|
169
|
+
className={`mb-1 ${isUser ? 'items-end' : 'items-start'}`}
|
|
170
|
+
accessibilityLabel={`${isUser ? 'Your' : 'Assistant'} message`}
|
|
171
|
+
>
|
|
172
|
+
{/* Images displayed outside bubble */}
|
|
173
|
+
{imageContent.length > 0 && (
|
|
174
|
+
<View className={`mb-2 ${isUser ? 'items-end' : 'items-start'}`}>
|
|
175
|
+
{imageContent}
|
|
176
|
+
</View>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{/* Text bubble */}
|
|
180
|
+
{textContent.length > 0 && (
|
|
181
|
+
<View className={`w-full ${isUser ? 'items-end' : 'items-start'}`}>
|
|
182
|
+
<View
|
|
183
|
+
className={`${isUser ? 'max-w-[80%] rounded-3xl bg-neutral-200 px-5 py-4 dark:bg-neutral-800' : 'w-full py-2'}`}
|
|
184
|
+
>
|
|
185
|
+
{isBotMessage ? (
|
|
186
|
+
<ChatMarkdown content={textContent.join('\n\n')} />
|
|
187
|
+
) : (
|
|
188
|
+
textContent.map((text, index) => (
|
|
189
|
+
<Text
|
|
190
|
+
key={`text-${index}`}
|
|
191
|
+
className={`text-[15.8px] ${isUser ? 'text-neutral-900 dark:text-white' : 'text-black dark:text-white'}`}
|
|
192
|
+
selectable
|
|
193
|
+
selectionColor={theme.colors.primary}
|
|
194
|
+
>
|
|
195
|
+
{text}
|
|
196
|
+
</Text>
|
|
197
|
+
))
|
|
198
|
+
)}
|
|
199
|
+
</View>
|
|
200
|
+
|
|
201
|
+
{isBotMessage && (textContent.length > 0 || onReportMessage) && (
|
|
202
|
+
<View className="flex-row gap-x-2">
|
|
203
|
+
{textContent.length > 0 && (
|
|
204
|
+
<Pressable
|
|
205
|
+
onPress={handleCopyMessage}
|
|
206
|
+
className="rounded p-1 opacity-60 hover:opacity-100 active:opacity-40"
|
|
207
|
+
accessibilityRole="button"
|
|
208
|
+
accessibilityLabel={translate('chatbot.copy_message')}
|
|
209
|
+
accessibilityHint={translate('chatbot.copy_hint')}
|
|
210
|
+
testID="copy-message-button"
|
|
211
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
212
|
+
>
|
|
213
|
+
<Feather
|
|
214
|
+
name="copy"
|
|
215
|
+
size={20}
|
|
216
|
+
color={theme.colors.mutedForeground}
|
|
217
|
+
/>
|
|
218
|
+
</Pressable>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{onReportMessage && (
|
|
222
|
+
<Pressable
|
|
223
|
+
onPress={() => onReportMessage(message)}
|
|
224
|
+
className="rounded p-1 opacity-60 hover:opacity-100 active:opacity-40"
|
|
225
|
+
accessibilityRole="button"
|
|
226
|
+
accessibilityLabel={translate('chatbot.report_message')}
|
|
227
|
+
accessibilityHint={translate('chatbot.report_hint')}
|
|
228
|
+
testID="report-message-button"
|
|
229
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
230
|
+
>
|
|
231
|
+
<Ionicons
|
|
232
|
+
name="flag-outline"
|
|
233
|
+
size={20}
|
|
234
|
+
color={theme.colors.mutedForeground}
|
|
235
|
+
/>
|
|
236
|
+
</Pressable>
|
|
237
|
+
)}
|
|
238
|
+
</View>
|
|
239
|
+
)}
|
|
240
|
+
</View>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{/* If both text and images are empty (e.g. streaming just started), omit the placeholder bubble */}
|
|
244
|
+
</View>
|
|
245
|
+
);
|
|
246
|
+
};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { Modal, Select, Text, useModal } from '@/components/ui';
|
|
5
|
+
import type { OptionType } from '@/components/ui/core/select';
|
|
6
|
+
import { useEntitlement } from '@/features/payments/hooks/use-entitlement';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getModelsByProvider,
|
|
10
|
+
PREMIUM_MODELS,
|
|
11
|
+
type Provider,
|
|
12
|
+
} from '../constants/models';
|
|
13
|
+
import type { SearchMode } from '../hooks/use-chatbot-settings';
|
|
14
|
+
|
|
15
|
+
const PROVIDER_OPTIONS: OptionType[] = [
|
|
16
|
+
{ label: 'OpenAI', value: 'openai' },
|
|
17
|
+
{ label: 'Gemini', value: 'gemini' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const SEARCH_MODE_OPTIONS: OptionType[] = [
|
|
21
|
+
{ label: 'Provider-Optimized', value: 'provider' },
|
|
22
|
+
{ label: 'Tavily Search', value: 'tavily' },
|
|
23
|
+
{ label: 'None', value: 'none' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
type ChatSettingsModalProps = {
|
|
27
|
+
selectedProvider: Provider;
|
|
28
|
+
selectedModel: string;
|
|
29
|
+
selectedSearchMode: SearchMode;
|
|
30
|
+
onProviderChange: (provider: Provider) => void;
|
|
31
|
+
onModelChange: (model: string) => void;
|
|
32
|
+
onSearchModeChange: (searchMode: SearchMode) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ChatSettingsModalRef = {
|
|
36
|
+
present: () => void;
|
|
37
|
+
dismiss: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const ChatSettingsModal = React.forwardRef<
|
|
41
|
+
ChatSettingsModalRef,
|
|
42
|
+
ChatSettingsModalProps
|
|
43
|
+
>(
|
|
44
|
+
(
|
|
45
|
+
{
|
|
46
|
+
selectedProvider,
|
|
47
|
+
selectedModel,
|
|
48
|
+
selectedSearchMode,
|
|
49
|
+
onProviderChange,
|
|
50
|
+
onModelChange,
|
|
51
|
+
onSearchModeChange,
|
|
52
|
+
},
|
|
53
|
+
ref,
|
|
54
|
+
) => {
|
|
55
|
+
const modal = useModal();
|
|
56
|
+
const { isEntitled, isLoading: isEntitlementLoading } =
|
|
57
|
+
useEntitlement('premium_access');
|
|
58
|
+
|
|
59
|
+
const premiumModelIds = React.useMemo(() => new Set(PREMIUM_MODELS()), []);
|
|
60
|
+
|
|
61
|
+
const baseModelOptions = React.useMemo<OptionType[]>(
|
|
62
|
+
() =>
|
|
63
|
+
getModelsByProvider(selectedProvider).map((model) => ({
|
|
64
|
+
label: model.name,
|
|
65
|
+
value: model.id,
|
|
66
|
+
})),
|
|
67
|
+
[selectedProvider],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Filter model options based on entitlement
|
|
71
|
+
const modelOptions = React.useMemo(
|
|
72
|
+
() =>
|
|
73
|
+
baseModelOptions
|
|
74
|
+
.filter((option) => {
|
|
75
|
+
const isPremium = premiumModelIds.has(option.value as string);
|
|
76
|
+
return isPremium ? isEntitled : true;
|
|
77
|
+
})
|
|
78
|
+
.map((option) => {
|
|
79
|
+
const isPremium = premiumModelIds.has(option.value as string);
|
|
80
|
+
if (isPremium && isEntitled) {
|
|
81
|
+
return {
|
|
82
|
+
...option,
|
|
83
|
+
label: `${option.label} ⭐`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return option;
|
|
87
|
+
}),
|
|
88
|
+
[baseModelOptions, isEntitled, premiumModelIds],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const handleProviderChange = (value: string | number) => {
|
|
92
|
+
const provider = value as Provider;
|
|
93
|
+
onProviderChange(provider);
|
|
94
|
+
|
|
95
|
+
// Reset to first model of new provider if current model doesn't exist
|
|
96
|
+
const newModelOptions = getModelsByProvider(provider);
|
|
97
|
+
const modelExists = newModelOptions.some(
|
|
98
|
+
(model) => model.id === selectedModel,
|
|
99
|
+
);
|
|
100
|
+
if (!modelExists && newModelOptions.length > 0) {
|
|
101
|
+
onModelChange(newModelOptions[0].id);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const handleModelChange = (value: string | number) => {
|
|
106
|
+
onModelChange(value as string);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const handleSearchModeChange = (value: string | number) => {
|
|
110
|
+
onSearchModeChange(value as SearchMode);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Expose modal controls via imperative handle
|
|
114
|
+
React.useImperativeHandle(ref, () => ({
|
|
115
|
+
present: modal.present,
|
|
116
|
+
dismiss: modal.dismiss,
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Modal ref={modal.ref} title="Chat Settings">
|
|
121
|
+
<View className="gap-6 p-4">
|
|
122
|
+
<View className="gap-2">
|
|
123
|
+
<Text className="text-base font-medium">AI Provider</Text>
|
|
124
|
+
<Select
|
|
125
|
+
options={PROVIDER_OPTIONS}
|
|
126
|
+
value={selectedProvider}
|
|
127
|
+
onSelect={handleProviderChange}
|
|
128
|
+
placeholder="Select AI Provider"
|
|
129
|
+
testID="provider-select"
|
|
130
|
+
/>
|
|
131
|
+
</View>
|
|
132
|
+
|
|
133
|
+
<View className="gap-2">
|
|
134
|
+
<Text className="text-base font-medium">Model</Text>
|
|
135
|
+
<Select
|
|
136
|
+
options={modelOptions}
|
|
137
|
+
value={selectedModel}
|
|
138
|
+
onSelect={handleModelChange}
|
|
139
|
+
placeholder="Select Model"
|
|
140
|
+
testID="model-select"
|
|
141
|
+
disabled={isEntitlementLoading}
|
|
142
|
+
/>
|
|
143
|
+
</View>
|
|
144
|
+
|
|
145
|
+
<View className="gap-2">
|
|
146
|
+
<Text className="text-base font-medium">Search Mode</Text>
|
|
147
|
+
<Select
|
|
148
|
+
options={SEARCH_MODE_OPTIONS}
|
|
149
|
+
value={selectedSearchMode}
|
|
150
|
+
onSelect={handleSearchModeChange}
|
|
151
|
+
placeholder="Select Search Mode"
|
|
152
|
+
testID="search-mode-select"
|
|
153
|
+
/>
|
|
154
|
+
</View>
|
|
155
|
+
</View>
|
|
156
|
+
</Modal>
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
ChatSettingsModal.displayName = 'ChatSettingsModal';
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Feather } from '@expo/vector-icons';
|
|
2
|
+
import type React from 'react';
|
|
3
|
+
import { ActivityIndicator, ScrollView, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
import { Image, Pressable, Text } from '@/components/ui';
|
|
6
|
+
import { useThemeConfig } from '@/lib/use-theme-config';
|
|
7
|
+
|
|
8
|
+
export type ImageUploadState =
|
|
9
|
+
| { status: 'pending'; progress: 0 }
|
|
10
|
+
| { status: 'uploading'; progress: number }
|
|
11
|
+
| { status: 'completed'; storageId: string; progress: 100 }
|
|
12
|
+
| { status: 'error'; error: string; progress: 0 };
|
|
13
|
+
|
|
14
|
+
export type SelectedImage = {
|
|
15
|
+
uri: string;
|
|
16
|
+
type: string;
|
|
17
|
+
fileName?: string;
|
|
18
|
+
id: string;
|
|
19
|
+
uploadState: ImageUploadState;
|
|
20
|
+
storageId?: string;
|
|
21
|
+
mimeType?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ImagePreviewListProps = {
|
|
25
|
+
images: SelectedImage[];
|
|
26
|
+
onRemoveImage: (id: string) => void;
|
|
27
|
+
onRetryUpload?: (id: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Component for displaying and managing selected image previews with upload progress
|
|
32
|
+
*/
|
|
33
|
+
export const ImagePreviewList: React.FC<ImagePreviewListProps> = ({
|
|
34
|
+
images,
|
|
35
|
+
onRemoveImage,
|
|
36
|
+
onRetryUpload,
|
|
37
|
+
}) => {
|
|
38
|
+
const theme = useThemeConfig();
|
|
39
|
+
|
|
40
|
+
if (images.length === 0) return null;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<ScrollView
|
|
44
|
+
horizontal
|
|
45
|
+
showsHorizontalScrollIndicator={false}
|
|
46
|
+
className="mb-1"
|
|
47
|
+
contentContainerStyle={{ gap: 8 }}
|
|
48
|
+
>
|
|
49
|
+
{images.map((image, index) => {
|
|
50
|
+
const isUploading =
|
|
51
|
+
image.uploadState.status === 'uploading' ||
|
|
52
|
+
image.uploadState.status === 'pending';
|
|
53
|
+
const isError = image.uploadState.status === 'error';
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<View key={image.id} className="relative p-2">
|
|
57
|
+
<Image
|
|
58
|
+
source={{ uri: image.uri }}
|
|
59
|
+
className="size-20 rounded-xl border border-neutral-200 dark:border-neutral-800"
|
|
60
|
+
contentFit="cover"
|
|
61
|
+
accessibilityRole="image"
|
|
62
|
+
accessibilityLabel={`Selected image ${index + 1}`}
|
|
63
|
+
style={{ opacity: isUploading ? 0.5 : 1 }}
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
{/* Upload Progress Loader */}
|
|
67
|
+
{isUploading && (
|
|
68
|
+
<View className="absolute inset-2 items-center justify-center rounded-xl">
|
|
69
|
+
<ActivityIndicator
|
|
70
|
+
size="small"
|
|
71
|
+
color={theme.dark ? '#fff' : '#000'}
|
|
72
|
+
/>
|
|
73
|
+
</View>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{/* Error State Overlay */}
|
|
77
|
+
{isError && (
|
|
78
|
+
<View className="absolute inset-2 items-center justify-center rounded-xl bg-danger-500/90">
|
|
79
|
+
<Feather name="alert-circle" size={20} color="#fff" />
|
|
80
|
+
<Text className="mt-1 text-xs font-medium text-white">
|
|
81
|
+
Failed
|
|
82
|
+
</Text>
|
|
83
|
+
{onRetryUpload && (
|
|
84
|
+
<Pressable
|
|
85
|
+
onPress={() => onRetryUpload(image.id)}
|
|
86
|
+
className="mt-1 rounded bg-white/20 px-2 py-1"
|
|
87
|
+
accessibilityLabel="Retry upload"
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
>
|
|
90
|
+
<Text className="text-xs font-medium text-white">
|
|
91
|
+
Retry
|
|
92
|
+
</Text>
|
|
93
|
+
</Pressable>
|
|
94
|
+
)}
|
|
95
|
+
</View>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Remove Button */}
|
|
99
|
+
<Pressable
|
|
100
|
+
onPress={() => onRemoveImage(image.id)}
|
|
101
|
+
className="absolute right-0 top-0 z-50 size-[26px] rounded-full bg-danger-500/70 dark:bg-danger-600/70"
|
|
102
|
+
accessibilityLabel={`Remove image ${index + 1}`}
|
|
103
|
+
accessibilityRole="button"
|
|
104
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
105
|
+
>
|
|
106
|
+
<Text className="mt-1 items-center justify-center text-center text-sm text-white">
|
|
107
|
+
✕
|
|
108
|
+
</Text>
|
|
109
|
+
</Pressable>
|
|
110
|
+
</View>
|
|
111
|
+
);
|
|
112
|
+
})}
|
|
113
|
+
</ScrollView>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Feather } from '@expo/vector-icons';
|
|
2
|
+
import React, { useCallback, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
type ImageStyle,
|
|
5
|
+
Pressable,
|
|
6
|
+
type TextStyle,
|
|
7
|
+
View,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import SyntaxHighlighter from 'react-native-syntax-highlighter';
|
|
11
|
+
|
|
12
|
+
import { translate } from '@/lib';
|
|
13
|
+
|
|
14
|
+
type CodeBlockProps = {
|
|
15
|
+
containerStyle: ViewStyle | TextStyle | ImageStyle;
|
|
16
|
+
content: string;
|
|
17
|
+
language: string;
|
|
18
|
+
codeSyntaxTheme: any;
|
|
19
|
+
monospaceFontFamily: string;
|
|
20
|
+
subtleText: string;
|
|
21
|
+
codeContentStyle: ViewStyle;
|
|
22
|
+
codeActionsContainerStyle: ViewStyle;
|
|
23
|
+
codeActionButtonBaseStyle: ViewStyle;
|
|
24
|
+
codeToggleButtonSpacingStyle: ViewStyle;
|
|
25
|
+
codeContentContainerStyle: ViewStyle;
|
|
26
|
+
onCopyCode: (code: string) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* CodeBlock component for rendering syntax-highlighted code with collapse/expand functionality.
|
|
31
|
+
*
|
|
32
|
+
* Features:
|
|
33
|
+
* - Syntax highlighting using react-native-syntax-highlighter
|
|
34
|
+
* - Copy to clipboard functionality
|
|
35
|
+
* - Collapse/expand animation with FlashList preparation
|
|
36
|
+
* - Smooth 220ms easeInEaseOut animation
|
|
37
|
+
* - Independent animation for multiple code blocks
|
|
38
|
+
*
|
|
39
|
+
* Requirements addressed:
|
|
40
|
+
* - 9.1: Prepare FlashList for layout animation before collapse
|
|
41
|
+
* - 9.2: Animate height change over 220ms with easeInEaseOut
|
|
42
|
+
* - 9.3: Animate each code block independently
|
|
43
|
+
* - 9.4: Restore full content with same animation timing
|
|
44
|
+
* - 9.5: Complete animation without interruption during scroll
|
|
45
|
+
*
|
|
46
|
+
* @param props - CodeBlock component props
|
|
47
|
+
* @returns Rendered code block with syntax highlighting and controls
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```tsx
|
|
51
|
+
* <CodeBlock
|
|
52
|
+
* containerStyle={styles.code_block}
|
|
53
|
+
* content="const hello = 'world';"
|
|
54
|
+
* language="javascript"
|
|
55
|
+
* codeSyntaxTheme={atomOneDark}
|
|
56
|
+
* monospaceFontFamily="Menlo"
|
|
57
|
+
* subtleText="#888"
|
|
58
|
+
* codeContentStyle={styles.codeContent}
|
|
59
|
+
* codeActionsContainerStyle={styles.actions}
|
|
60
|
+
* codeActionButtonBaseStyle={styles.button}
|
|
61
|
+
* codeToggleButtonSpacingStyle={styles.spacing}
|
|
62
|
+
* codeContentContainerStyle={styles.container}
|
|
63
|
+
* onCopyCode={copyToClipboard}
|
|
64
|
+
* onPrepareLayoutAnimation={prepareFlashList}
|
|
65
|
+
* />
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export const CodeBlock = React.memo(function CodeBlock({
|
|
69
|
+
containerStyle,
|
|
70
|
+
content,
|
|
71
|
+
language,
|
|
72
|
+
codeSyntaxTheme,
|
|
73
|
+
monospaceFontFamily,
|
|
74
|
+
subtleText,
|
|
75
|
+
codeContentStyle,
|
|
76
|
+
codeActionsContainerStyle,
|
|
77
|
+
codeActionButtonBaseStyle,
|
|
78
|
+
codeToggleButtonSpacingStyle,
|
|
79
|
+
codeContentContainerStyle,
|
|
80
|
+
onCopyCode,
|
|
81
|
+
}: CodeBlockProps) {
|
|
82
|
+
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Toggle collapsed state without animation.
|
|
86
|
+
* LayoutAnimation causes scroll jitter, so we disable it.
|
|
87
|
+
*/
|
|
88
|
+
const toggleCollapsed = useCallback(() => {
|
|
89
|
+
setIsCollapsed((prev) => !prev);
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<View
|
|
94
|
+
style={[
|
|
95
|
+
containerStyle as ViewStyle,
|
|
96
|
+
{ position: 'relative' as ViewStyle['position'] },
|
|
97
|
+
]}
|
|
98
|
+
>
|
|
99
|
+
<View style={codeActionsContainerStyle} pointerEvents="box-none">
|
|
100
|
+
<Pressable
|
|
101
|
+
onPress={() => onCopyCode(content)}
|
|
102
|
+
style={({ pressed }) => [
|
|
103
|
+
codeActionButtonBaseStyle,
|
|
104
|
+
pressed ? { opacity: 0.6 } : null,
|
|
105
|
+
]}
|
|
106
|
+
accessibilityRole="button"
|
|
107
|
+
accessibilityLabel={translate('chatbot.copy_code')}
|
|
108
|
+
accessibilityHint={translate('chatbot.copy_code_hint')}
|
|
109
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
110
|
+
>
|
|
111
|
+
<Feather name="copy" size={16} color={subtleText} />
|
|
112
|
+
</Pressable>
|
|
113
|
+
|
|
114
|
+
<Pressable
|
|
115
|
+
onPress={toggleCollapsed}
|
|
116
|
+
style={({ pressed }) => [
|
|
117
|
+
codeActionButtonBaseStyle,
|
|
118
|
+
codeToggleButtonSpacingStyle,
|
|
119
|
+
pressed ? { opacity: 0.6 } : null,
|
|
120
|
+
]}
|
|
121
|
+
accessibilityRole="button"
|
|
122
|
+
accessibilityLabel={
|
|
123
|
+
translate(
|
|
124
|
+
isCollapsed
|
|
125
|
+
? 'chatbot.expand_code_block'
|
|
126
|
+
: 'chatbot.collapse_code_block',
|
|
127
|
+
) ?? undefined
|
|
128
|
+
}
|
|
129
|
+
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
|
130
|
+
>
|
|
131
|
+
<View
|
|
132
|
+
style={{
|
|
133
|
+
transform: [{ rotate: isCollapsed ? '180deg' : '0deg' }],
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
<Feather name="chevron-down" size={18} color={subtleText} />
|
|
137
|
+
</View>
|
|
138
|
+
</Pressable>
|
|
139
|
+
</View>
|
|
140
|
+
|
|
141
|
+
<View
|
|
142
|
+
style={[
|
|
143
|
+
codeContentContainerStyle,
|
|
144
|
+
isCollapsed ? { marginTop: 0 } : null,
|
|
145
|
+
]}
|
|
146
|
+
pointerEvents={isCollapsed ? 'none' : 'auto'}
|
|
147
|
+
>
|
|
148
|
+
{isCollapsed ? null : (
|
|
149
|
+
<SyntaxHighlighter
|
|
150
|
+
language={language.length > 0 ? language : 'plaintext'}
|
|
151
|
+
style={codeSyntaxTheme}
|
|
152
|
+
highlighter="hljs"
|
|
153
|
+
fontFamily={monospaceFontFamily}
|
|
154
|
+
fontSize={15}
|
|
155
|
+
PreTag={View}
|
|
156
|
+
CodeTag={View}
|
|
157
|
+
customStyle={codeContentStyle}
|
|
158
|
+
>
|
|
159
|
+
{content}
|
|
160
|
+
</SyntaxHighlighter>
|
|
161
|
+
)}
|
|
162
|
+
</View>
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown rendering components for chatbot messages.
|
|
3
|
+
*
|
|
4
|
+
* This module exports specialized components for rendering markdown content
|
|
5
|
+
* in chat messages, including code blocks with syntax highlighting and
|
|
6
|
+
* tables with intelligent column sizing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { CodeBlock } from './code-block';
|
|
10
|
+
export { TableRenderer } from './table-renderer';
|