diff --git a/apps/mobile/.env b/apps/mobile/.env index 11c9f66e1..588275031 100644 --- a/apps/mobile/.env +++ b/apps/mobile/.env @@ -5,3 +5,5 @@ CLOUD_AGENT_WS_URL=wss://cloud-agent-next.kilosessions.ai SESSION_INGEST_WS_URL=wss://ingest.kilosessions.ai APPSFLYER_DEV_KEY=jnoVs6KzXanpbKrqXckPu9 APPSFLYER_APP_ID=6761193135 +EXPO_PUBLIC_KILO_CHAT_URL=https://kilo-chat.kilosessions.ai +EXPO_PUBLIC_EVENT_SERVICE_URL=wss://event-service.kilosessions.ai diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a59df0c47..c210d8796 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -32,6 +32,7 @@ "@rn-primitives/portal": "^1.3.0", "@rn-primitives/slot": "^1.2.0", "@sentry/react-native": "~7.11.0", + "@shopify/flash-list": "2.0.2", "@tailwindcss/postcss": "^4.2.2", "@tanstack/react-query": "catalog:", "@trpc/client": "catalog:", diff --git a/apps/mobile/src/components/kilo-chat/conversation-header.tsx b/apps/mobile/src/components/kilo-chat/conversation-header.tsx new file mode 100644 index 000000000..bd69c1f47 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-header.tsx @@ -0,0 +1,15 @@ +import { ScreenHeader } from '@/components/screen-header'; +import { Text } from '@/components/ui/text'; + +type Props = { title: string; subtitle?: string }; + +export function ConversationHeader({ title, subtitle }: Props) { + return ( + {subtitle} : undefined + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx new file mode 100644 index 000000000..029e6c5e6 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-list-screen.tsx @@ -0,0 +1,126 @@ +import { FlashList } from '@shopify/flash-list'; +import { type Href, useRouter } from 'expo-router'; +import { Pressable, View } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; + +import { ScreenHeader } from '@/components/screen-header'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { timeAgo } from '@/lib/utils'; + +import { EmptyConversationList } from './empty-conversation-list'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useConversations, useCreateConversation } from './hooks/use-conversations'; +import { useInstanceEventSubscription } from './hooks/use-instance-event-subscription'; +import { useInstancePresence } from './hooks/use-instance-presence'; + +type Props = { + sandboxId: string; + sandboxLabel: string; +}; + +type ConversationItem = { + conversationId: string; + title: string | null; + lastActivityAt: number | null; + lastReadAt: number | null; + joinedAt: number; +}; + +type ConversationRowProps = { + item: ConversationItem; + onPress: (id: string) => void; +}; + +function ConversationRow({ item, onPress }: ConversationRowProps) { + const hasUnread = + item.lastActivityAt !== null && + (item.lastReadAt === null || item.lastReadAt < item.lastActivityAt); + + return ( + { + onPress(item.conversationId); + }} + > + + + {item.title ?? 'Untitled conversation'} + + {item.lastActivityAt !== null ? ( + + {timeAgo(new Date(item.lastActivityAt))} + + ) : null} + + {hasUnread ? ( + + ) : ( + // Reserve space so rows stay the same width whether the dot is shown or not + + )} + + ); +} + +export function ConversationListScreen({ sandboxId, sandboxLabel }: Props) { + const router = useRouter(); + const client = useKiloChatClient(); + const listQuery = useConversations(client, sandboxId); + const createConversation = useCreateConversation(client); + + const conversations = listQuery.data?.conversations ?? []; + const isFetchingNextPage = listQuery.isFetchingNextPage; + const fetchNextPage = listQuery.fetchNextPage; + + useInstanceEventSubscription(sandboxId); + useInstancePresence(sandboxId); + + function handleRowPress(conversationId: string) { + // Route lands in PR 5d (Task 47) + router.push(`/(app)/chat/${sandboxId}/${conversationId}` as Href); + } + + function handleCreateAndNavigate() { + createConversation.mutate( + { sandboxId }, + { + onSuccess: result => { + // Route lands in PR 5d (Task 47) + router.push(`/(app)/chat/${sandboxId}/${result.conversationId}` as Href); + }, + } + ); + } + + return ( + + + + c.conversationId} + renderItem={({ item }) => } + ListEmptyComponent={ + + } + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + onEndReached={() => { + void fetchNextPage(); + }} + onEndReachedThreshold={0.5} + /> + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/conversation-screen.tsx b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx new file mode 100644 index 000000000..ac42c770a --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/conversation-screen.tsx @@ -0,0 +1,72 @@ +import * as Crypto from 'expo-crypto'; +import { useCallback } from 'react'; +import { KeyboardAvoidingView, Platform, View } from 'react-native'; +import { useFocusEffect } from 'expo-router'; + +import { ConversationHeader } from './conversation-header'; +import { MessageInput } from './message-input'; +import { MessageList } from './message-list'; +import { TypingIndicator } from './typing-indicator'; +import { useConversationPresence } from './hooks/use-conversation-presence'; +import { useKiloChatClient } from './hooks/use-kilo-chat-client'; +import { useMarkRead } from './hooks/use-mark-read'; +import { useMessages, useSendMessage } from './hooks/use-messages'; +import { useCurrentUserId } from './hooks/use-current-user-id'; + +type Props = { sandboxId: string; conversationId: string; conversationTitle: string }; + +export function ConversationScreen({ sandboxId, conversationId, conversationTitle }: Props) { + const client = useKiloChatClient(); + const currentUserId = useCurrentUserId(); + + const messagesQuery = useMessages(client, conversationId); + const messages = messagesQuery.data?.messages ?? []; + const hasOlder = messagesQuery.hasNextPage; + const fetchOlder = useCallback(() => { + if (messagesQuery.hasNextPage && !messagesQuery.isFetchingNextPage) { + void messagesQuery.fetchNextPage(); + } + }, [messagesQuery]); + + const sendMutation = useSendMessage(client, conversationId, currentUserId ?? ''); + const handleSend = useCallback( + (text: string) => { + sendMutation.mutate({ + conversationId, + content: [{ type: 'text', text }], + clientId: Crypto.randomUUID(), + }); + }, + [sendMutation, conversationId] + ); + + useConversationPresence(sandboxId, conversationId); + + const markRead = useMarkRead(); + useFocusEffect( + useCallback(() => { + markRead(sandboxId, conversationId); + // Active-conversation suppression wiring added in PR 5d (Task 50). + }, [sandboxId, conversationId, markRead]) + ); + + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx new file mode 100644 index 000000000..332fee2c3 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/empty-conversation-list.tsx @@ -0,0 +1,28 @@ +import { MessageSquarePlus } from 'lucide-react-native'; +import { View } from 'react-native'; + +import { EmptyState } from '@/components/empty-state'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; + +type Props = { + onStart: () => void; + isStarting: boolean; +}; + +export function EmptyConversationList({ onStart, isStarting }: Props) { + return ( + + + {isStarting ? 'Starting…' : 'Start a conversation'} + + } + /> + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-bubble.tsx b/apps/mobile/src/components/kilo-chat/message-bubble.tsx new file mode 100644 index 000000000..211264261 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-bubble.tsx @@ -0,0 +1,191 @@ +import { useAddReaction, useExecuteAction, useRemoveReaction } from '@kilocode/kilo-chat-hooks'; +import { type ExecApprovalDecision, type Message } from '@kilocode/kilo-chat'; +import { Pressable, View } from 'react-native'; + +import { useCurrentUserId } from '@/components/kilo-chat/hooks/use-current-user-id'; +import { useKiloChatClient } from '@/components/kilo-chat/hooks/use-kilo-chat-client'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +type Props = { + message: Message; + conversationId: string; + isFromMe: boolean; + showAuthor: boolean; + onLongPress?: (m: Message) => void; +}; + +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); +} + +function actionStyleToVariant( + style: 'primary' | 'danger' | 'secondary' +): 'default' | 'destructive' | 'secondary' { + if (style === 'danger') { + return 'destructive'; + } + if (style === 'secondary') { + return 'secondary'; + } + return 'default'; +} + +export function MessageBubble({ + message, + conversationId, + isFromMe, + showAuthor, + onLongPress, +}: Props) { + const client = useKiloChatClient(); + const currentUserId = useCurrentUserId(); + + const executeAction = useExecuteAction(client, conversationId, currentUserId ?? ''); + const addReaction = useAddReaction(client, conversationId, currentUserId ?? ''); + const removeReaction = useRemoveReaction(client, conversationId, currentUserId ?? ''); + + const isPending = message.id.startsWith('pending-'); + const timestamp = message.clientUpdatedAt ?? message.updatedAt; + + function handleReactionPress(emoji: string) { + if (!currentUserId) { + return; + } + const hasReacted = message.reactions + .find(r => r.emoji === emoji) + ?.memberIds.includes(currentUserId); + if (hasReacted) { + removeReaction.mutate({ messageId: message.id, emoji }); + } else { + addReaction.mutate({ messageId: message.id, emoji }); + } + } + + function handleExecuteAction(groupId: string, value: ExecApprovalDecision) { + executeAction.mutate({ messageId: message.id, groupId, value }); + } + + const textColor = isFromMe ? 'text-primary-foreground' : 'text-foreground'; + + return ( + { + onLongPress(message); + } + : undefined + } + className={cn('px-4 py-1', isFromMe ? 'items-end' : 'items-start', isPending && 'opacity-50')} + > + {showAuthor && ( + + {message.senderId} + {timestamp !== null && ( + {formatTimestamp(timestamp)} + )} + + )} + + + {message.deleted ? ( + [deleted message] + ) : ( + <> + {message.content.map((block, index) => { + if (block.type === 'text') { + return ( + + {block.text} + + ); + } + + // block.type === 'actions' + if (block.resolved) { + const resolvedAction = block.actions.find(a => a.value === block.resolved?.value); + const label = resolvedAction?.label ?? block.resolved.value; + return ( + + {label} + + ); + } + + return ( + + {block.actions.map(action => ( + + ))} + + ); + })} + + )} + + {!showAuthor && timestamp !== null && ( + + {formatTimestamp(timestamp)} + + )} + + + {message.reactions.length > 0 && ( + + {message.reactions.map(reaction => { + const hasReacted = currentUserId ? reaction.memberIds.includes(currentUserId) : false; + return ( + { + handleReactionPress(reaction.emoji); + }} + className={cn( + 'flex-row items-center gap-0.5 rounded-full px-2 py-0.5', + hasReacted ? 'bg-primary' : 'bg-neutral-200 dark:bg-neutral-700' + )} + > + {reaction.emoji} + + {reaction.count} + + + ); + })} + + )} + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-input.tsx b/apps/mobile/src/components/kilo-chat/message-input.tsx new file mode 100644 index 000000000..9e6d0bf5e --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-input.tsx @@ -0,0 +1,56 @@ +import { Send } from 'lucide-react-native'; +import { useRef, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; + +import { cn } from '@/lib/utils'; +import { useThemeColors } from '@/lib/hooks/use-theme-colors'; + +type Props = { + onSend: (text: string) => void; + disabled?: boolean; +}; + +export function MessageInput({ onSend, disabled }: Props) { + const colors = useThemeColors(); + const valueRef = useRef(''); + const [canSend, setCanSend] = useState(false); + const inputRef = useRef(null); + + const submit = () => { + const text = valueRef.current.trim(); + if (!text) { + return; + } + onSend(text); + valueRef.current = ''; + inputRef.current?.clear(); + setCanSend(false); + }; + + return ( + + { + valueRef.current = t; + setCanSend(t.trim().length > 0); + }} + onSubmitEditing={submit} + /> + + + + + ); +} diff --git a/apps/mobile/src/components/kilo-chat/message-list.tsx b/apps/mobile/src/components/kilo-chat/message-list.tsx new file mode 100644 index 000000000..b418e7696 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/message-list.tsx @@ -0,0 +1,67 @@ +import { FlashList } from '@shopify/flash-list'; +import { type Message } from '@kilocode/kilo-chat'; +import { View } from 'react-native'; + +import { MessageBubble } from '@/components/kilo-chat/message-bubble'; +import { Skeleton } from '@/components/ui/skeleton'; + +type Props = { + messages: Message[]; + conversationId: string; + currentUserId: string | null; + fetchOlder?: () => void; + hasOlder?: boolean; + onLongPressMessage?: (m: Message) => void; +}; + +export function MessageList({ + messages, + conversationId, + currentUserId, + fetchOlder, + hasOlder, + onLongPressMessage, +}: Props) { + // useMessages returns messages newest-first (result of .reverse() in the hook). + // FlashList v2 does not support `inverted`; instead we use maintainVisibleContentPosition + // with startRenderingFromBottom. That requires data in chronological order (oldest first), + // so we reverse once to get oldest→newest. + const chronological = messages.toReversed(); + + return ( + { + // In chronological order, the previous message in time is data[index - 1]. + // showAuthor is true when the sender changes relative to the prior message, + // or when this is the oldest message (index 0). + const previousItem = chronological[index - 1]; + const showAuthor = previousItem === undefined || previousItem.senderId !== item.senderId; + + return ( + + ); + }} + keyExtractor={item => item.id} + onStartReached={fetchOlder} + onStartReachedThreshold={0.5} + maintainVisibleContentPosition={{ + // Start rendering from the bottom so the newest message is visible on first render. + startRenderingFromBottom: true, + }} + ListHeaderComponent={ + hasOlder ? ( + + + + ) : null + } + /> + ); +} diff --git a/apps/mobile/src/components/kilo-chat/typing-indicator.tsx b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx new file mode 100644 index 000000000..c909788c1 --- /dev/null +++ b/apps/mobile/src/components/kilo-chat/typing-indicator.tsx @@ -0,0 +1,15 @@ +import { View } from 'react-native'; +import { Text } from '@/components/ui/text'; + +type Props = { isTyping: boolean; name?: string }; + +export function TypingIndicator({ isTyping, name }: Props) { + if (!isTyping) { + return null; + } + return ( + + {name ?? 'Bot'} is typing… + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b252a8a27..fde8db827 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ importers: '@sentry/react-native': specifier: ~7.11.0 version: 7.11.0(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) + '@shopify/flash-list': + specifier: 2.0.2 + version: 2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) '@tailwindcss/postcss': specifier: ^4.2.2 version: 4.2.2 @@ -328,7 +331,7 @@ importers: version: 9.38.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) stream-chat-expo: specifier: ^8.13.7 - version: 8.13.7(f3af0588b693ec71c0fc67ae0290618e) + version: 8.13.7(e673e8bffb1896cc06607271df6a38dc) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -1601,7 +1604,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -6814,6 +6817,13 @@ packages: peerDependencies: webpack: '>=5.0.0' + '@shopify/flash-list@2.0.2': + resolution: {integrity: sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==} + peerDependencies: + '@babel/runtime': '*' + react: '*' + react-native: '*' + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -20523,6 +20533,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@shopify/flash-list@2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)': + dependencies: + '@babel/runtime': 7.29.2 + react: 19.2.0 + react-native: 0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6) + tslib: 2.8.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -25729,6 +25746,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -26380,6 +26416,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -29668,12 +29717,12 @@ snapshots: stream-buffers@2.2.0: {} - stream-chat-expo@8.13.7(f3af0588b693ec71c0fc67ae0290618e): + stream-chat-expo@8.13.7(e673e8bffb1896cc06607271df6a38dc): dependencies: expo: 55.0.12(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.9)(bufferutil@4.1.0)(expo-router@55.0.11)(react-dom@19.2.4(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3)(utf-8-validate@6.0.6) expo-image-manipulator: 55.0.14(expo@55.0.12) mime: 4.1.0 - stream-chat-react-native-core: 8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(ab8a9f6f835746e18b11cebeae560e08) + stream-chat-react-native-core: 8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8) optionalDependencies: expo-audio: 55.0.12(expo-asset@55.0.13(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0)(typescript@5.9.3))(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) expo-clipboard: 55.0.12(expo@55.0.12)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -29702,7 +29751,7 @@ snapshots: - typescript - utf-8-validate - stream-chat-react-native-core@8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(ab8a9f6f835746e18b11cebeae560e08): + stream-chat-react-native-core@8.13.7(patch_hash=6fa2fe7a3ddb0ab3312ee1adc4e07d3fc9c46f7bc5bb861675bef4078808b8c0)(f6393708cf819e255e90d2b806f318a8): dependencies: '@gorhom/bottom-sheet': 5.1.8(patch_hash=c5eaae9a28f5662f32d66e0129609680309eddc44720d6a2b1e02bbc2b5dd11f)(@types/react@19.2.14)(react-native-gesture-handler@2.30.0(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native-reanimated@4.2.1(react-native-worklets@0.7.2(@babel/core@7.29.0)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0))(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) '@react-native-community/netinfo': 11.5.2(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) @@ -29726,6 +29775,7 @@ snapshots: use-sync-external-store: 1.6.0(react@19.2.0) optionalDependencies: '@emoji-mart/data': 1.2.1 + '@shopify/flash-list': 2.0.2(@babel/runtime@7.29.2)(react-native@0.83.4(@babel/core@7.29.0)(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.0)(utf-8-validate@6.0.6))(react@19.2.0) emoji-mart: 5.6.0 transitivePeerDependencies: - '@types/react'