clawdex-mobile 1.3.2 → 2.0.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/.github/workflows/ci.yml +1 -1
- package/.github/workflows/npm-release.yml +18 -0
- package/AGENTS.md +3 -3
- package/README.md +101 -541
- package/apps/mobile/.env.example +1 -2
- package/apps/mobile/App.tsx +261 -68
- package/apps/mobile/app.json +31 -5
- package/apps/mobile/assets/brand/splash-icon-white.png +0 -0
- package/apps/mobile/eas.json +30 -0
- package/apps/mobile/package.json +22 -21
- package/apps/mobile/plugins/withAndroidCleartextTraffic.js +14 -0
- package/apps/mobile/src/api/__tests__/ws.test.ts +44 -6
- package/apps/mobile/src/api/chatMapping.ts +48 -8
- package/apps/mobile/src/api/client.ts +6 -0
- package/apps/mobile/src/api/types.ts +11 -0
- package/apps/mobile/src/api/ws.ts +52 -10
- package/apps/mobile/src/bridgeUrl.ts +105 -0
- package/apps/mobile/src/components/ActivityBar.tsx +32 -13
- package/apps/mobile/src/components/ChatHeader.tsx +3 -2
- package/apps/mobile/src/components/ChatInput.tsx +246 -91
- package/apps/mobile/src/components/ChatMessage.tsx +108 -4
- package/apps/mobile/src/config.ts +11 -29
- package/apps/mobile/src/hooks/useVoiceRecorder.ts +264 -0
- package/apps/mobile/src/navigation/DrawerContent.tsx +18 -8
- package/apps/mobile/src/screens/GitScreen.tsx +1 -1
- package/apps/mobile/src/screens/MainScreen.tsx +906 -268
- package/apps/mobile/src/screens/OnboardingScreen.tsx +1132 -0
- package/apps/mobile/src/screens/PrivacyScreen.tsx +1 -1
- package/apps/mobile/src/screens/SettingsScreen.tsx +65 -1
- package/apps/mobile/src/screens/TerminalScreen.tsx +1 -1
- package/apps/mobile/src/screens/TermsScreen.tsx +1 -1
- package/docs/app-review-notes.md +7 -2
- package/docs/eas-builds.md +91 -0
- package/docs/realtime-streaming-limitations.md +84 -0
- package/docs/setup-and-operations.md +239 -0
- package/docs/troubleshooting.md +121 -0
- package/docs/voice-transcription.md +87 -0
- package/package.json +8 -16
- package/scripts/setup-secure-dev.sh +122 -8
- package/scripts/setup-wizard.sh +342 -122
- package/scripts/start-bridge-secure.sh +7 -1
- package/scripts/sync-versions.js +63 -0
- package/services/rust-bridge/.env.example +1 -1
- package/services/rust-bridge/Cargo.lock +1104 -23
- package/services/rust-bridge/Cargo.toml +3 -1
- package/services/rust-bridge/package.json +1 -1
- package/services/rust-bridge/src/main.rs +587 -12
- package/apps/mobile/metro.config.js +0 -3
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
+
import { BlurView } from 'expo-blur';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
2
4
|
import {
|
|
3
5
|
ActivityIndicator,
|
|
4
6
|
type NativeSyntheticEvent,
|
|
@@ -8,10 +10,12 @@ import {
|
|
|
8
10
|
StyleSheet,
|
|
9
11
|
Text,
|
|
10
12
|
TextInput,
|
|
13
|
+
type TextLayoutEventData,
|
|
11
14
|
type TextInputKeyPressEventData,
|
|
12
15
|
View,
|
|
13
16
|
} from 'react-native';
|
|
14
17
|
|
|
18
|
+
import type { VoiceState } from '../hooks/useVoiceRecorder';
|
|
15
19
|
import { colors, radius, spacing } from '../theme';
|
|
16
20
|
|
|
17
21
|
interface ChatInputProps {
|
|
@@ -27,6 +31,10 @@ interface ChatInputProps {
|
|
|
27
31
|
showStopButton?: boolean;
|
|
28
32
|
isStopping?: boolean;
|
|
29
33
|
placeholder?: string;
|
|
34
|
+
onVoiceToggle?: () => void;
|
|
35
|
+
voiceState?: VoiceState;
|
|
36
|
+
safeAreaBottomInset?: number;
|
|
37
|
+
keyboardVisible?: boolean;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export function ChatInput({
|
|
@@ -42,98 +50,226 @@ export function ChatInput({
|
|
|
42
50
|
showStopButton = false,
|
|
43
51
|
isStopping = false,
|
|
44
52
|
placeholder = 'Message Codex...',
|
|
53
|
+
onVoiceToggle,
|
|
54
|
+
voiceState = 'idle',
|
|
55
|
+
safeAreaBottomInset = 0,
|
|
56
|
+
keyboardVisible = false,
|
|
45
57
|
}: ChatInputProps) {
|
|
46
|
-
const
|
|
58
|
+
const INPUT_TEXT_LINE_HEIGHT = 20;
|
|
59
|
+
const INPUT_TEXT_VERTICAL_PADDING = Platform.OS === 'ios' ? 2 : 0;
|
|
60
|
+
const INPUT_TEXT_MIN_HEIGHT = 20;
|
|
61
|
+
const INPUT_TEXT_MAX_HEIGHT = 96;
|
|
62
|
+
const [inputHeight, setInputHeight] = useState(INPUT_TEXT_MIN_HEIGHT);
|
|
63
|
+
const [inputWidth, setInputWidth] = useState(0);
|
|
64
|
+
const updateInputHeight = (height: number) => {
|
|
65
|
+
const nextHeight = Math.max(
|
|
66
|
+
INPUT_TEXT_MIN_HEIGHT,
|
|
67
|
+
Math.min(INPUT_TEXT_MAX_HEIGHT, Math.ceil(height))
|
|
68
|
+
);
|
|
69
|
+
setInputHeight((previousHeight) =>
|
|
70
|
+
previousHeight === nextHeight ? previousHeight : nextHeight
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!value && inputHeight !== INPUT_TEXT_MIN_HEIGHT) {
|
|
76
|
+
setInputHeight(INPUT_TEXT_MIN_HEIGHT);
|
|
77
|
+
}
|
|
78
|
+
}, [inputHeight, value]);
|
|
79
|
+
|
|
80
|
+
const canSend = value.trim().length > 0 && voiceState === 'idle';
|
|
47
81
|
const canStop = Boolean(showStopButton && onStop);
|
|
48
|
-
const
|
|
82
|
+
const showVoiceButton = Boolean(onVoiceToggle);
|
|
83
|
+
const showSendButton = canSend || isLoading;
|
|
84
|
+
const inputScrollEnabled = inputHeight >= INPUT_TEXT_MAX_HEIGHT;
|
|
85
|
+
const shouldShowActionButton =
|
|
86
|
+
canStop || showSendButton || showVoiceButton || voiceState !== 'idle';
|
|
87
|
+
const baseBottomPadding =
|
|
88
|
+
Platform.OS === 'ios'
|
|
89
|
+
? keyboardVisible
|
|
90
|
+
? spacing.sm
|
|
91
|
+
: spacing.lg
|
|
92
|
+
: spacing.md;
|
|
93
|
+
const extraBottomInset = keyboardVisible ? 0 : safeAreaBottomInset;
|
|
49
94
|
|
|
50
95
|
return (
|
|
51
|
-
<View style={styles.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
<View style={styles.shell}>
|
|
97
|
+
<BlurView
|
|
98
|
+
intensity={26}
|
|
99
|
+
tint={Platform.OS === 'ios' ? 'systemUltraThinMaterialDark' : 'dark'}
|
|
100
|
+
blurMethod="dimezisBlurViewSdk31Plus"
|
|
101
|
+
style={StyleSheet.absoluteFill}
|
|
102
|
+
/>
|
|
103
|
+
<View
|
|
104
|
+
style={[
|
|
105
|
+
styles.container,
|
|
106
|
+
{
|
|
107
|
+
paddingBottom:
|
|
108
|
+
baseBottomPadding + extraBottomInset,
|
|
109
|
+
},
|
|
110
|
+
]}
|
|
111
|
+
>
|
|
112
|
+
{attachments.length > 0 ? (
|
|
113
|
+
<ScrollView
|
|
114
|
+
horizontal
|
|
115
|
+
showsHorizontalScrollIndicator={false}
|
|
116
|
+
contentContainerStyle={styles.attachmentListContent}
|
|
117
|
+
style={styles.attachmentList}
|
|
118
|
+
>
|
|
119
|
+
{attachments.map((attachment, index) => (
|
|
120
|
+
<Pressable
|
|
121
|
+
key={`${attachment.id}-${String(index)}`}
|
|
122
|
+
onPress={
|
|
123
|
+
onRemoveAttachment
|
|
124
|
+
? () => onRemoveAttachment(attachment.id)
|
|
125
|
+
: undefined
|
|
126
|
+
}
|
|
127
|
+
style={({ pressed }) => [
|
|
128
|
+
styles.attachmentChip,
|
|
129
|
+
pressed && styles.attachmentChipPressed,
|
|
130
|
+
]}
|
|
131
|
+
>
|
|
132
|
+
<Ionicons name="attach-outline" size={12} color={colors.textMuted} />
|
|
133
|
+
<Text style={styles.attachmentChipText} numberOfLines={1}>
|
|
134
|
+
{attachment.label}
|
|
135
|
+
</Text>
|
|
136
|
+
{onRemoveAttachment ? (
|
|
137
|
+
<Ionicons name="close-outline" size={12} color={colors.textMuted} />
|
|
138
|
+
) : null}
|
|
139
|
+
</Pressable>
|
|
140
|
+
))}
|
|
141
|
+
</ScrollView>
|
|
142
|
+
) : null}
|
|
83
143
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
144
|
+
<View style={styles.row}>
|
|
145
|
+
<Pressable
|
|
146
|
+
onPress={onAttachPress}
|
|
147
|
+
style={({ pressed }) => [styles.plusBtn, pressed && styles.plusBtnPressed]}
|
|
148
|
+
>
|
|
149
|
+
<Ionicons name="add" size={20} color={colors.textMuted} />
|
|
150
|
+
</Pressable>
|
|
91
151
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
/>
|
|
115
|
-
{shouldShowActionButton ? (
|
|
116
|
-
<Pressable
|
|
117
|
-
onPress={canStop ? onStop : canSend ? onSubmit : undefined}
|
|
118
|
-
style={styles.sendBtn}
|
|
119
|
-
disabled={canStop ? isStopping : !canSend}
|
|
152
|
+
<View style={styles.inputWrapper}>
|
|
153
|
+
<Text
|
|
154
|
+
pointerEvents="none"
|
|
155
|
+
accessibilityElementsHidden
|
|
156
|
+
importantForAccessibility="no-hide-descendants"
|
|
157
|
+
style={[
|
|
158
|
+
styles.inputMeasure,
|
|
159
|
+
{
|
|
160
|
+
width: inputWidth,
|
|
161
|
+
lineHeight: INPUT_TEXT_LINE_HEIGHT,
|
|
162
|
+
paddingVertical: INPUT_TEXT_VERTICAL_PADDING,
|
|
163
|
+
},
|
|
164
|
+
]}
|
|
165
|
+
onTextLayout={(event: NativeSyntheticEvent<TextLayoutEventData>) => {
|
|
166
|
+
if (inputWidth <= 0) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const lineCount = Math.max(1, event.nativeEvent.lines.length);
|
|
170
|
+
const measuredHeight =
|
|
171
|
+
lineCount * INPUT_TEXT_LINE_HEIGHT + INPUT_TEXT_VERTICAL_PADDING * 2;
|
|
172
|
+
updateInputHeight(measuredHeight);
|
|
173
|
+
}}
|
|
120
174
|
>
|
|
121
|
-
{
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
175
|
+
{value.length > 0 ? `${value}\u200b` : ' '}
|
|
176
|
+
</Text>
|
|
177
|
+
<TextInput
|
|
178
|
+
style={[styles.input, { height: inputHeight }]}
|
|
179
|
+
value={value}
|
|
180
|
+
onChangeText={onChangeText}
|
|
181
|
+
onLayout={(event) => {
|
|
182
|
+
const nextWidth = Math.floor(event.nativeEvent.layout.width);
|
|
183
|
+
setInputWidth((previousWidth) =>
|
|
184
|
+
previousWidth === nextWidth ? previousWidth : nextWidth
|
|
185
|
+
);
|
|
186
|
+
}}
|
|
187
|
+
onChange={(event: NativeSyntheticEvent<unknown>) => {
|
|
188
|
+
const nativeEvent = event.nativeEvent as {
|
|
189
|
+
contentSize?: { height?: number };
|
|
190
|
+
};
|
|
191
|
+
const contentHeight = nativeEvent.contentSize?.height;
|
|
192
|
+
if (typeof contentHeight === 'number' && Number.isFinite(contentHeight)) {
|
|
193
|
+
updateInputHeight(contentHeight);
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
onFocus={onFocus}
|
|
197
|
+
placeholder={placeholder}
|
|
198
|
+
placeholderTextColor={colors.textMuted}
|
|
199
|
+
multiline
|
|
200
|
+
scrollEnabled={inputScrollEnabled}
|
|
201
|
+
onContentSizeChange={(event) => {
|
|
202
|
+
updateInputHeight(event.nativeEvent.contentSize.height);
|
|
203
|
+
}}
|
|
204
|
+
onKeyPress={(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
205
|
+
const keyEvent = e.nativeEvent as TextInputKeyPressEventData & {
|
|
206
|
+
shiftKey?: boolean;
|
|
207
|
+
};
|
|
208
|
+
if (
|
|
209
|
+
Platform.OS === 'web' &&
|
|
210
|
+
keyEvent.key === 'Enter' &&
|
|
211
|
+
!keyEvent.shiftKey
|
|
212
|
+
) {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
if (canSend) onSubmit();
|
|
215
|
+
}
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
{shouldShowActionButton ? (
|
|
219
|
+
<View style={styles.actionButtons}>
|
|
220
|
+
{showVoiceButton || voiceState !== 'idle' ? (
|
|
221
|
+
voiceState === 'transcribing' ? (
|
|
222
|
+
<View style={styles.sendBtn}>
|
|
223
|
+
<ActivityIndicator size="small" color={colors.textMuted} />
|
|
224
|
+
</View>
|
|
225
|
+
) : voiceState === 'recording' ? (
|
|
226
|
+
<Pressable
|
|
227
|
+
onPress={onVoiceToggle}
|
|
228
|
+
style={[styles.sendBtn, styles.micBtnRecording]}
|
|
229
|
+
>
|
|
230
|
+
<Ionicons name="mic" size={14} color={colors.error} />
|
|
231
|
+
</Pressable>
|
|
232
|
+
) : (
|
|
233
|
+
<Pressable
|
|
234
|
+
onPress={onVoiceToggle}
|
|
235
|
+
style={styles.sendBtn}
|
|
236
|
+
>
|
|
237
|
+
<Ionicons name="mic-outline" size={14} color={colors.textMuted} />
|
|
238
|
+
</Pressable>
|
|
239
|
+
)
|
|
240
|
+
) : null}
|
|
241
|
+
{canStop ? (
|
|
242
|
+
<Pressable
|
|
243
|
+
onPress={onStop}
|
|
244
|
+
style={styles.sendBtn}
|
|
245
|
+
disabled={isStopping}
|
|
246
|
+
>
|
|
247
|
+
<View style={styles.stopButtonContent}>
|
|
248
|
+
<Ionicons name="square" size={10} color={colors.textPrimary} />
|
|
249
|
+
<ActivityIndicator
|
|
250
|
+
size="small"
|
|
251
|
+
color={colors.textMuted}
|
|
252
|
+
style={styles.stopButtonSpinner}
|
|
253
|
+
/>
|
|
254
|
+
</View>
|
|
255
|
+
</Pressable>
|
|
256
|
+
) : null}
|
|
257
|
+
{showSendButton ? (
|
|
258
|
+
<Pressable
|
|
259
|
+
onPress={canSend ? onSubmit : undefined}
|
|
260
|
+
style={styles.sendBtn}
|
|
261
|
+
disabled={!canSend}
|
|
262
|
+
>
|
|
263
|
+
{isLoading && !canSend ? (
|
|
264
|
+
<ActivityIndicator size="small" color={colors.textMuted} />
|
|
265
|
+
) : (
|
|
266
|
+
<Ionicons name="arrow-up" size={14} color={colors.textPrimary} />
|
|
267
|
+
)}
|
|
268
|
+
</Pressable>
|
|
269
|
+
) : null}
|
|
270
|
+
</View>
|
|
271
|
+
) : null}
|
|
272
|
+
</View>
|
|
137
273
|
</View>
|
|
138
274
|
</View>
|
|
139
275
|
</View>
|
|
@@ -141,14 +277,16 @@ export function ChatInput({
|
|
|
141
277
|
}
|
|
142
278
|
|
|
143
279
|
const styles = StyleSheet.create({
|
|
280
|
+
shell: {
|
|
281
|
+
overflow: 'hidden',
|
|
282
|
+
borderTopWidth: StyleSheet.hairlineWidth,
|
|
283
|
+
borderTopColor: colors.borderLight,
|
|
284
|
+
},
|
|
144
285
|
container: {
|
|
145
286
|
gap: spacing.xs,
|
|
146
287
|
paddingHorizontal: spacing.lg,
|
|
147
288
|
paddingVertical: spacing.md,
|
|
148
|
-
|
|
149
|
-
backgroundColor: colors.bgMain,
|
|
150
|
-
borderTopWidth: StyleSheet.hairlineWidth,
|
|
151
|
-
borderTopColor: colors.borderLight,
|
|
289
|
+
backgroundColor: 'rgba(6, 9, 13, 0.42)',
|
|
152
290
|
},
|
|
153
291
|
row: {
|
|
154
292
|
flexDirection: 'row',
|
|
@@ -213,7 +351,21 @@ const styles = StyleSheet.create({
|
|
|
213
351
|
fontSize: 14,
|
|
214
352
|
lineHeight: 20,
|
|
215
353
|
paddingVertical: Platform.OS === 'ios' ? 2 : 0,
|
|
216
|
-
textAlignVertical: '
|
|
354
|
+
textAlignVertical: 'top',
|
|
355
|
+
},
|
|
356
|
+
inputMeasure: {
|
|
357
|
+
position: 'absolute',
|
|
358
|
+
opacity: 0,
|
|
359
|
+
color: colors.textPrimary,
|
|
360
|
+
fontSize: 14,
|
|
361
|
+
left: spacing.md,
|
|
362
|
+
top: spacing.xs,
|
|
363
|
+
},
|
|
364
|
+
actionButtons: {
|
|
365
|
+
flexDirection: 'row',
|
|
366
|
+
alignItems: 'center',
|
|
367
|
+
marginLeft: spacing.xs,
|
|
368
|
+
gap: spacing.xs,
|
|
217
369
|
},
|
|
218
370
|
sendBtn: {
|
|
219
371
|
width: 28,
|
|
@@ -222,7 +374,10 @@ const styles = StyleSheet.create({
|
|
|
222
374
|
backgroundColor: colors.bgItem,
|
|
223
375
|
alignItems: 'center',
|
|
224
376
|
justifyContent: 'center',
|
|
225
|
-
|
|
377
|
+
},
|
|
378
|
+
micBtnRecording: {
|
|
379
|
+
borderWidth: 1.5,
|
|
380
|
+
borderColor: colors.error,
|
|
226
381
|
},
|
|
227
382
|
stopButtonContent: {
|
|
228
383
|
width: 20,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { Ionicons } from '@expo/vector-icons';
|
|
2
|
-
import {
|
|
2
|
+
import { memo } from 'react';
|
|
3
|
+
import { Image, Linking, Platform, StyleSheet, Text, View } from 'react-native';
|
|
3
4
|
import Markdown, { type RenderRules } from 'react-native-markdown-display';
|
|
4
5
|
import Animated, { FadeInUp, Layout } from 'react-native-reanimated';
|
|
5
6
|
|
|
6
|
-
import type { ChatMessage } from '../api/types';
|
|
7
|
+
import type { ChatMessage as ApiChatMessage } from '../api/types';
|
|
7
8
|
import { colors, radius, spacing, typography } from '../theme';
|
|
8
9
|
|
|
9
10
|
interface ChatMessageProps {
|
|
10
|
-
message:
|
|
11
|
+
message: ApiChatMessage;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
interface TimelineEntry {
|
|
@@ -15,7 +16,7 @@ interface TimelineEntry {
|
|
|
15
16
|
details: string[];
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
function ChatMessageComponent({ message }: ChatMessageProps) {
|
|
19
20
|
const isUser = message.role === 'user';
|
|
20
21
|
|
|
21
22
|
if (isUser) {
|
|
@@ -97,6 +98,28 @@ export function ChatMessage({ message }: ChatMessageProps) {
|
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
function areChatMessagePropsEqual(
|
|
102
|
+
prevProps: ChatMessageProps,
|
|
103
|
+
nextProps: ChatMessageProps
|
|
104
|
+
): boolean {
|
|
105
|
+
const previous = prevProps.message;
|
|
106
|
+
const next = nextProps.message;
|
|
107
|
+
|
|
108
|
+
if (previous === next) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
previous.id === next.id &&
|
|
114
|
+
previous.role === next.role &&
|
|
115
|
+
previous.content === next.content &&
|
|
116
|
+
previous.createdAt === next.createdAt
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const ChatMessage = memo(ChatMessageComponent, areChatMessagePropsEqual);
|
|
121
|
+
ChatMessage.displayName = 'ChatMessage';
|
|
122
|
+
|
|
100
123
|
const monoFont = Platform.select({ ios: 'Menlo', default: 'monospace' });
|
|
101
124
|
|
|
102
125
|
const markdownStyles = StyleSheet.create({
|
|
@@ -164,6 +187,35 @@ const markdownStyles = StyleSheet.create({
|
|
|
164
187
|
});
|
|
165
188
|
|
|
166
189
|
const markdownRules: RenderRules = {
|
|
190
|
+
link: (node, children, _parent, styles, onLinkPress) => {
|
|
191
|
+
const href = readMarkdownAttr(node.attributes.href);
|
|
192
|
+
if (!href) {
|
|
193
|
+
return (
|
|
194
|
+
<Text key={node.key} style={styles.link}>
|
|
195
|
+
{children}
|
|
196
|
+
</Text>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const localFileReference = toLocalFileReferenceLabel(href);
|
|
201
|
+
if (localFileReference) {
|
|
202
|
+
return (
|
|
203
|
+
<Text key={node.key} style={styles.code_inline}>
|
|
204
|
+
{localFileReference}
|
|
205
|
+
</Text>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<Text
|
|
211
|
+
key={node.key}
|
|
212
|
+
style={styles.link}
|
|
213
|
+
onPress={() => openMarkdownLink(href, onLinkPress)}
|
|
214
|
+
>
|
|
215
|
+
{children}
|
|
216
|
+
</Text>
|
|
217
|
+
);
|
|
218
|
+
},
|
|
167
219
|
image: (
|
|
168
220
|
node,
|
|
169
221
|
_children,
|
|
@@ -284,6 +336,58 @@ function readMarkdownAttr(value: unknown): string | null {
|
|
|
284
336
|
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
285
337
|
}
|
|
286
338
|
|
|
339
|
+
function openMarkdownLink(
|
|
340
|
+
href: string,
|
|
341
|
+
onLinkPress?: (url: string) => boolean
|
|
342
|
+
): void {
|
|
343
|
+
const shouldOpen = onLinkPress ? onLinkPress(href) !== false : true;
|
|
344
|
+
if (!shouldOpen) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
void Linking.openURL(href).catch(() => {});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function toLocalFileReferenceLabel(href: string): string | null {
|
|
351
|
+
let normalizedHref = href.trim();
|
|
352
|
+
if (!normalizedHref) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
normalizedHref = decodeURIComponent(normalizedHref);
|
|
358
|
+
} catch {
|
|
359
|
+
// Keep original href when decode fails.
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (normalizedHref.startsWith('file://')) {
|
|
363
|
+
normalizedHref = normalizedHref.replace(/^file:\/\//, '');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const isPosixPath = normalizedHref.startsWith('/');
|
|
367
|
+
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(normalizedHref);
|
|
368
|
+
if (!isPosixPath && !isWindowsPath) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const anchorLineMatch = normalizedHref.match(/#L(\d+)(?:C\d+)?$/i);
|
|
373
|
+
const suffixLineMatch = normalizedHref.match(/:(\d+)(?::\d+)?$/);
|
|
374
|
+
|
|
375
|
+
const line = anchorLineMatch?.[1] ?? suffixLineMatch?.[1] ?? null;
|
|
376
|
+
let pathOnly = normalizedHref;
|
|
377
|
+
if (anchorLineMatch) {
|
|
378
|
+
pathOnly = normalizedHref.slice(0, normalizedHref.length - anchorLineMatch[0].length);
|
|
379
|
+
} else if (suffixLineMatch) {
|
|
380
|
+
pathOnly = normalizedHref.slice(0, normalizedHref.length - suffixLineMatch[0].length);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const basename = pathOnly.split(/[\\/]/).filter(Boolean).pop();
|
|
384
|
+
if (!basename) {
|
|
385
|
+
return line ? `line ${line}` : null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return line ? `${basename}:${line}` : basename;
|
|
389
|
+
}
|
|
390
|
+
|
|
287
391
|
function parseTimelineEntries(content: string): TimelineEntry[] | null {
|
|
288
392
|
if (!content.includes('•')) {
|
|
289
393
|
return null;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { isInsecureRemoteUrl, normalizeBridgeUrlInput } from './bridgeUrl';
|
|
2
|
+
|
|
3
|
+
const legacyHostBridgeUrl = normalizeBridgeUrlInput(
|
|
4
|
+
process.env.EXPO_PUBLIC_HOST_BRIDGE_URL ??
|
|
5
|
+
process.env.EXPO_PUBLIC_MAC_BRIDGE_URL ??
|
|
6
|
+
''
|
|
7
|
+
);
|
|
5
8
|
const hostBridgeToken =
|
|
6
9
|
process.env.EXPO_PUBLIC_HOST_BRIDGE_TOKEN?.trim() ||
|
|
7
10
|
process.env.EXPO_PUBLIC_MAC_BRIDGE_TOKEN?.trim() ||
|
|
@@ -19,16 +22,17 @@ const externalStatusFullSyncDebounceMs = parseNonNegativeIntEnv(
|
|
|
19
22
|
450
|
|
20
23
|
);
|
|
21
24
|
|
|
22
|
-
if (isInsecureRemoteUrl(
|
|
25
|
+
if (legacyHostBridgeUrl && isInsecureRemoteUrl(legacyHostBridgeUrl) && !allowInsecureRemoteBridge) {
|
|
23
26
|
console.warn(
|
|
24
|
-
'
|
|
27
|
+
'Using build-time bridge URL fallback from env. Configure bridge URL in-app from onboarding/settings when possible.'
|
|
25
28
|
);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export const env = {
|
|
29
|
-
|
|
32
|
+
legacyHostBridgeUrl,
|
|
30
33
|
hostBridgeToken,
|
|
31
34
|
allowWsQueryTokenAuth,
|
|
35
|
+
allowInsecureRemoteBridge,
|
|
32
36
|
externalStatusFullSyncDebounceMs,
|
|
33
37
|
privacyPolicyUrl,
|
|
34
38
|
termsOfServiceUrl
|
|
@@ -51,25 +55,3 @@ function parseNonNegativeIntEnv(value: string | undefined, fallback: number): nu
|
|
|
51
55
|
|
|
52
56
|
return parsed;
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
function isInsecureRemoteUrl(url: string): boolean {
|
|
56
|
-
try {
|
|
57
|
-
const parsed = new URL(url);
|
|
58
|
-
if (parsed.protocol !== 'http:') {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return !isLocalHost(parsed.hostname);
|
|
63
|
-
} catch {
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function isLocalHost(hostname: string): boolean {
|
|
69
|
-
const normalized = hostname.trim().toLowerCase();
|
|
70
|
-
return (
|
|
71
|
-
normalized === 'localhost' ||
|
|
72
|
-
normalized === '127.0.0.1' ||
|
|
73
|
-
normalized === '::1'
|
|
74
|
-
);
|
|
75
|
-
}
|