mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Add haptic feedback, sleep action, and fix send button styling
- Add expo-haptics medium impact feedback on all long-press interactions (terminal tabs, worktree cards, host cards) - Add "Sleep" option with Moon icon to worktree action sheet, calling worktree.sleep RPC method - Change send button from bright blue (accentBlue) to subtle dark (bgRaised) background with textSecondary icon color Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
@@ -19,12 +20,16 @@ import {
|
||||
SlidersHorizontal,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
ChevronRight
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Plus,
|
||||
Moon
|
||||
} from 'lucide-react-native'
|
||||
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
|
||||
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
|
||||
import { StatusDot } from '../../../src/components/StatusDot'
|
||||
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
|
||||
import { AgentSpinner } from '../../../src/components/AgentSpinner'
|
||||
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
||||
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
||||
@@ -236,6 +241,7 @@ export default function HostScreen() {
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [showNewWorktree, setShowNewWorktree] = useState(false)
|
||||
|
||||
// Persisted pin state
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
@@ -435,13 +441,18 @@ export default function HostScreen() {
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topChrome}>
|
||||
<View style={styles.statusBar}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.hostIdentity}>
|
||||
<StatusDot state={connState} />
|
||||
<Text style={styles.hostNameText} numberOfLines={1}>
|
||||
{hostName || 'Host'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
|
||||
{connState !== 'connected' && (
|
||||
<Text style={styles.statusText}>{STATUS_LABELS[connState]}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Filter/sort/group toolbar */}
|
||||
@@ -476,6 +487,17 @@ export default function HostScreen() {
|
||||
|
||||
<View style={styles.toolbarSpacer} />
|
||||
|
||||
<Pressable
|
||||
style={styles.newButton}
|
||||
onPress={() => setShowNewWorktree(true)}
|
||||
disabled={connState !== 'connected'}
|
||||
>
|
||||
<Plus
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable style={styles.searchToggle} onPress={() => setShowSearch((s) => !s)}>
|
||||
{showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
@@ -583,7 +605,10 @@ export default function HostScreen() {
|
||||
style={({ pressed }) => [styles.worktreeRow, pressed && styles.worktreeRowPressed]}
|
||||
disabled={isReadOnly}
|
||||
onPress={() => openWorktreeSession(item)}
|
||||
onLongPress={() => setActionTarget(item)}
|
||||
onLongPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
setActionTarget(item)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
{/* Left indicator */}
|
||||
@@ -668,6 +693,17 @@ export default function HostScreen() {
|
||||
actions={
|
||||
actionTarget
|
||||
? [
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
void client.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: pinnedIds.has(actionTarget.worktreeId) ? 'Unpin' : 'Pin',
|
||||
onPress: () => togglePin(actionTarget.worktreeId)
|
||||
@@ -723,6 +759,18 @@ export default function HostScreen() {
|
||||
]}
|
||||
onClose={() => setConfirmRemoveHost(false)}
|
||||
/>
|
||||
|
||||
<NewWorktreeModal
|
||||
visible={showNewWorktree}
|
||||
client={client}
|
||||
onCreated={(worktreeId, worktreeName, agentCommand) => {
|
||||
void fetchWorktrees()
|
||||
const params = new URLSearchParams({ name: worktreeName })
|
||||
if (agentCommand) params.set('agent', agentCommand)
|
||||
router.push(`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`)
|
||||
}}
|
||||
onClose={() => setShowNewWorktree(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
@@ -752,6 +800,13 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.xs,
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.xs
|
||||
},
|
||||
hostIdentity: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
@@ -841,6 +896,9 @@ const styles = StyleSheet.create({
|
||||
toolbarSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
newButton: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchToggle: {
|
||||
padding: spacing.xs
|
||||
},
|
||||
@@ -916,7 +974,7 @@ const styles = StyleSheet.create({
|
||||
indicatorCol: {
|
||||
width: 20,
|
||||
alignItems: 'center',
|
||||
paddingTop: 3,
|
||||
paddingTop: 6,
|
||||
marginRight: spacing.sm,
|
||||
gap: 4
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { ArrowUp, ChevronLeft, Monitor, Plus, Smartphone } from 'lucide-react-native'
|
||||
import { connect, type RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
@@ -69,11 +70,13 @@ export default function SessionScreen() {
|
||||
const {
|
||||
hostId,
|
||||
worktreeId,
|
||||
name: worktreeName
|
||||
name: worktreeName,
|
||||
agent: agentCommand
|
||||
} = useLocalSearchParams<{
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
name?: string
|
||||
agent?: string
|
||||
}>()
|
||||
const router = useRouter()
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
@@ -116,6 +119,7 @@ export default function SessionScreen() {
|
||||
// Existing desktop terminals should keep their desktop dimensions until
|
||||
// the user explicitly chooses "Fit to Phone".
|
||||
const mobileCreatedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const agentCommandConsumedRef = useRef(false)
|
||||
const termRef = useRef<TerminalWebViewHandle>(null)
|
||||
const unsubRef = useRef<(() => void) | null>(null)
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
@@ -123,14 +127,9 @@ export default function SessionScreen() {
|
||||
|
||||
const canSend = connState === 'connected' && activeHandle != null
|
||||
|
||||
const writeLines = useCallback((lines: string[] | string | undefined) => {
|
||||
const formatLines = useCallback((lines: string[] | string | undefined) => {
|
||||
const text = Array.isArray(lines) ? lines.join('\r\n') : (lines ?? '')
|
||||
if (text) {
|
||||
termRef.current?.write(text)
|
||||
if (Array.isArray(lines)) {
|
||||
termRef.current?.write('\r\n')
|
||||
}
|
||||
}
|
||||
return text && Array.isArray(lines) ? `${text}\r\n` : text
|
||||
}, [])
|
||||
|
||||
// Why: after a fit/restore resize the PTY grid changes, so we must
|
||||
@@ -160,12 +159,11 @@ export default function SessionScreen() {
|
||||
if (data.type === 'scrollback') {
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
termRef.current?.init(cols, rows)
|
||||
if (typeof data.serialized === 'string' && data.serialized.length > 0) {
|
||||
termRef.current?.write(data.serialized)
|
||||
} else {
|
||||
writeLines(data.lines as string[] | string | undefined)
|
||||
}
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
: formatLines(data.lines as string[] | string | undefined)
|
||||
termRef.current?.init(cols, rows, initialData)
|
||||
} else if (data.type === 'data') {
|
||||
termRef.current?.write(data.chunk as string)
|
||||
}
|
||||
@@ -178,7 +176,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
})()
|
||||
},
|
||||
[client, writeLines]
|
||||
[client, formatLines]
|
||||
)
|
||||
|
||||
// Why: extracted so both manual "Fit to Phone" and auto-fit-on-subscribe
|
||||
@@ -285,15 +283,25 @@ export default function SessionScreen() {
|
||||
subscribeSeqRef.current = seq
|
||||
const rpcClient = client
|
||||
|
||||
// Why: phone-fitted terminals skip terminal.focus (which would cause
|
||||
// the desktop to auto-resize back to desktop dimensions). Instead we
|
||||
// subscribe directly and compare the scrollback geometry against the
|
||||
// phone dimensions. If they match, the content is already correct and
|
||||
// we avoid a resize→SIGWINCH→redraw cycle that strips TUI colors.
|
||||
// If they don't match (desktop auto-restored while we were away),
|
||||
// we refit — but only then.
|
||||
const shouldAutoFit =
|
||||
!manuallyRestoredRef.current.has(handle) &&
|
||||
(mobileCreatedHandlesRef.current.has(handle) || fittedHandlesRef.current.has(handle))
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
// Why: hidden/new desktop panes can still be at their 80x24 spawn
|
||||
// geometry. Focusing first lets the desktop renderer fit xterm,
|
||||
// resize the PTY, and register a serializer before mobile subscribes.
|
||||
await client.sendRequest('terminal.focus', { terminal: handle })
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
} catch {
|
||||
// Continue with best-effort streaming if desktop focus is unavailable.
|
||||
if (!shouldAutoFit) {
|
||||
try {
|
||||
await client.sendRequest('terminal.focus', { terminal: handle })
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
} catch {
|
||||
// Continue with best-effort streaming if desktop focus is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
if (subscribeSeqRef.current !== seq || activeHandleRef.current !== handle) {
|
||||
@@ -308,29 +316,25 @@ export default function SessionScreen() {
|
||||
if (data.type === 'scrollback') {
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
termRef.current?.init(cols, rows)
|
||||
if (typeof data.serialized === 'string' && data.serialized.length > 0) {
|
||||
termRef.current?.write(data.serialized)
|
||||
} else {
|
||||
writeLines(data.lines as string[] | string | undefined)
|
||||
}
|
||||
// Why: auto-fit terminals that (a) were created during this
|
||||
// mobile session, or (b) the user previously fitted to phone
|
||||
// and didn't manually restore. Case (b) handles the scenario
|
||||
// where navigating away disconnects the WebSocket (triggering
|
||||
// auto-restore on desktop) and coming back should re-apply
|
||||
// the phone fit so the user doesn't lose their aspect ratio.
|
||||
if (
|
||||
!manuallyRestoredRef.current.has(handle) &&
|
||||
(mobileCreatedHandlesRef.current.has(handle) ||
|
||||
fittedHandlesRef.current.has(handle)) &&
|
||||
!fitPendingRef.current
|
||||
) {
|
||||
setTimeout(() => {
|
||||
if (activeHandleRef.current === handle && subscribeSeqRef.current === seq) {
|
||||
void fitToPhoneCore(handle, rpcClient)
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
: formatLines(data.lines as string[] | string | undefined)
|
||||
termRef.current?.init(cols, rows, initialData)
|
||||
// Why: only refit if the scrollback geometry doesn't match
|
||||
// the phone WebView dimensions. This avoids unnecessary
|
||||
// resize→SIGWINCH cycles on tab switches when the terminal
|
||||
// is already at the correct phone size.
|
||||
if (shouldAutoFit && !fitPendingRef.current) {
|
||||
void (async () => {
|
||||
const dims = await termRef.current?.measureFitDimensions()
|
||||
if (!dims) return
|
||||
if (cols !== dims.cols || rows !== dims.rows) {
|
||||
if (activeHandleRef.current === handle && subscribeSeqRef.current === seq) {
|
||||
void fitToPhoneCore(handle, rpcClient)
|
||||
}
|
||||
}
|
||||
}, 600)
|
||||
})()
|
||||
}
|
||||
} else if (data.type === 'data') {
|
||||
termRef.current?.write(data.chunk as string)
|
||||
@@ -344,7 +348,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
})()
|
||||
},
|
||||
[client, writeLines, fitToPhoneCore]
|
||||
[client, formatLines, fitToPhoneCore]
|
||||
)
|
||||
|
||||
const fetchTerminals = useCallback(
|
||||
@@ -418,6 +422,47 @@ export default function SessionScreen() {
|
||||
return () => clearInterval(interval)
|
||||
}, [connState, fetchTerminals])
|
||||
|
||||
// Why: when navigating here from the "New Worktree" modal with an agent
|
||||
// command, create the terminal from the session page (not the modal) so
|
||||
// it goes through the normal mobile-created → auto-fit-to-phone flow.
|
||||
// The modal can't do this because the terminal would start at the desktop's
|
||||
// default geometry before the mobile WebView is mounted.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!agentCommand ||
|
||||
!client ||
|
||||
connState !== 'connected' ||
|
||||
!terminalsLoaded ||
|
||||
agentCommandConsumedRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
agentCommandConsumedRef.current = true
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.create', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
command: agentCommand
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as TerminalCreateResult
|
||||
const created = result.terminal
|
||||
mobileCreatedHandlesRef.current.add(created.handle)
|
||||
activeHandleRef.current = created.handle
|
||||
setActiveHandle(created.handle)
|
||||
setTerminals((prev) => [
|
||||
...prev,
|
||||
{ handle: created.handle, title: created.title || 'Terminal', isActive: true }
|
||||
])
|
||||
subscribeToTerminal(created.handle)
|
||||
}
|
||||
} catch {
|
||||
// Agent terminal creation failed — user can create manually via +
|
||||
}
|
||||
})()
|
||||
}, [agentCommand, client, connState, terminalsLoaded, worktreeId, subscribeToTerminal])
|
||||
|
||||
const switchTab = useCallback(
|
||||
(handle: string) => {
|
||||
activeHandleRef.current = handle
|
||||
@@ -600,7 +645,10 @@ export default function SessionScreen() {
|
||||
key={t.handle}
|
||||
style={[styles.tab, t.handle === activeHandle && styles.tabActive]}
|
||||
onPress={() => switchTab(t.handle)}
|
||||
onLongPress={() => setActionTarget(t)}
|
||||
onLongPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
setActionTarget(t)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<Text
|
||||
@@ -699,7 +747,7 @@ export default function SessionScreen() {
|
||||
onPress={() => void handleSend()}
|
||||
accessibilityLabel="Send command"
|
||||
>
|
||||
<ArrowUp size={18} color="#fff" strokeWidth={2.5} />
|
||||
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -949,7 +997,7 @@ const styles = StyleSheet.create({
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: colors.accentBlue,
|
||||
backgroundColor: colors.bgRaised,
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import * as Haptics from 'expo-haptics'
|
||||
import { MoreHorizontal, QrCode, Server } from 'lucide-react-native'
|
||||
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
@@ -114,7 +115,10 @@ export default function HomeScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => setActionTarget(item)}
|
||||
onLongPress={() => {
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)
|
||||
setActionTarget(item)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.hostIcon}>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"expo-camera": "^55.0.16",
|
||||
"expo-constants": "^55.0.15",
|
||||
"expo-crypto": "^55.0.14",
|
||||
"expo-haptics": "^55.0.14",
|
||||
"expo-linking": "^55.0.14",
|
||||
"expo-router": "^55.0.13",
|
||||
"expo-status-bar": "^55.0.5",
|
||||
|
||||
Generated
+12
@@ -23,6 +23,9 @@ importers:
|
||||
expo-crypto:
|
||||
specifier: ^55.0.14
|
||||
version: 55.0.14(expo@55.0.17)
|
||||
expo-haptics:
|
||||
specifier: ^55.0.14
|
||||
version: 55.0.14(expo@55.0.17)
|
||||
expo-linking:
|
||||
specifier: ^55.0.14
|
||||
version: 55.0.14(expo@55.0.17)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)
|
||||
@@ -3121,6 +3124,11 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-haptics@55.0.14:
|
||||
resolution: {integrity: sha512-KjDItBsA9mi1f5nRwf8g1wOdfEcLHwvEdt5Jl1sMCDETR/homcGOl+F3QIiPOl/PRlbGVieQsjTtF4DGtHOj6g==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-image@55.0.9:
|
||||
resolution: {integrity: sha512-+NVgWv+tr7a6EpBEaIIVVp+XfruRA2JL5xOxvd6ajvFGdH0rOhagwX1m1piAII6w7sh6uAnBr8X+fDZsav7B2w==}
|
||||
peerDependencies:
|
||||
@@ -9355,6 +9363,10 @@ snapshots:
|
||||
react: 19.2.0
|
||||
react-native: 0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0)
|
||||
|
||||
expo-haptics@55.0.14(expo@55.0.17):
|
||||
dependencies:
|
||||
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
||||
expo-image@55.0.9(expo@55.0.17)(react-native-web@0.21.2(react-dom@19.2.5(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0):
|
||||
dependencies:
|
||||
expo: 55.0.17(@babel/core@7.29.0)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.13)(react-dom@19.2.5(react@19.2.0))(react-native-webview@13.16.1(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native-worklets@0.7.4(@babel/core@7.29.0)(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0))(react-native@0.83.6(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.0))(react@19.2.0)(typescript@5.9.3)
|
||||
|
||||
Reference in New Issue
Block a user