@10play/expo-air 0.12.1 → 0.12.2

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.
@@ -0,0 +1,320 @@
1
+ import React from "react";
2
+ import { View, Text, StyleSheet, Platform, Linking } from "react-native";
3
+ import { SPACING, COLORS, TYPOGRAPHY } from "../constants/design";
4
+
5
+ // Renders formatted text with markdown-style lists, code, and emphasis
6
+ export function FormattedText({ content, isStreaming }: { content: string; isStreaming?: boolean }) {
7
+ const lines = content.split('\n');
8
+ const elements: React.ReactNode[] = [];
9
+ let i = 0;
10
+
11
+ while (i < lines.length) {
12
+ const line = lines[i];
13
+ const trimmed = line.trim();
14
+
15
+ // Code block start (check first to avoid matching content inside code blocks)
16
+ if (trimmed.startsWith('```')) {
17
+ const codeLines: string[] = [];
18
+ const lang = trimmed.slice(3).trim();
19
+ i++;
20
+ while (i < lines.length && !lines[i].trim().startsWith('```')) {
21
+ codeLines.push(lines[i]);
22
+ i++;
23
+ }
24
+ elements.push(
25
+ <View key={`code-${i}`} style={styles.codeBlock}>
26
+ {lang && <Text style={styles.codeLang}>{lang}</Text>}
27
+ <Text style={styles.codeText} selectable>{codeLines.join('\n')}</Text>
28
+ </View>
29
+ );
30
+ i++; // skip closing ```
31
+ continue;
32
+ }
33
+
34
+ // Heading (# ## ### etc.)
35
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
36
+ if (headingMatch) {
37
+ const level = headingMatch[1].length;
38
+ const headingStyle = level <= 2 ? styles.heading1 : level <= 4 ? styles.heading2 : styles.heading3;
39
+ elements.push(
40
+ <Text key={i} style={headingStyle} selectable>{formatInlineText(headingMatch[2])}</Text>
41
+ );
42
+ i++;
43
+ continue;
44
+ }
45
+
46
+ // Blockquote (> text) - collect consecutive > lines
47
+ if (trimmed.startsWith('> ') || trimmed === '>') {
48
+ const quoteLines: string[] = [];
49
+ while (i < lines.length && (lines[i].trim().startsWith('> ') || lines[i].trim() === '>')) {
50
+ const qContent = lines[i].trim().startsWith('> ') ? lines[i].trim().slice(2) : '';
51
+ quoteLines.push(qContent);
52
+ i++;
53
+ }
54
+ elements.push(
55
+ <View key={`quote-${i}`} style={styles.blockquote}>
56
+ <Text style={styles.blockquoteText} selectable>{formatInlineText(quoteLines.join('\n'))}</Text>
57
+ </View>
58
+ );
59
+ continue;
60
+ }
61
+
62
+ // Task list item (- [ ] or - [x])
63
+ const taskMatch = trimmed.match(/^[-*]\s+\[([ xX])\]\s+(.+)$/);
64
+ if (taskMatch) {
65
+ const checked = taskMatch[1] !== ' ';
66
+ elements.push(
67
+ <View key={i} style={styles.listItem}>
68
+ <Text style={styles.listBullet}>{checked ? '☑' : '☐'}</Text>
69
+ <Text style={styles.listText} selectable>{formatInlineText(taskMatch[2])}</Text>
70
+ </View>
71
+ );
72
+ i++;
73
+ continue;
74
+ }
75
+
76
+ // Numbered list item (1. 2. 3.) - with nesting via indentation
77
+ const numberedMatch = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
78
+ if (numberedMatch) {
79
+ const indent = Math.floor(numberedMatch[1].length / 2);
80
+ const [, , num, text] = numberedMatch;
81
+ elements.push(
82
+ <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
83
+ <Text style={styles.listNumber}>{num}.</Text>
84
+ <Text style={styles.listText} selectable>{formatInlineText(text)}</Text>
85
+ </View>
86
+ );
87
+ i++;
88
+ continue;
89
+ }
90
+
91
+ // Bullet list item (- or *) - with nesting via indentation
92
+ const bulletMatch = line.match(/^(\s*)[-*]\s+(.+)$/);
93
+ if (bulletMatch) {
94
+ const indent = Math.floor(bulletMatch[1].length / 2);
95
+ const bulletChar = indent === 0 ? '•' : indent === 1 ? '◦' : '▪';
96
+ elements.push(
97
+ <View key={i} style={[styles.listItem, indent > 0 && { paddingLeft: SPACING.SM + indent * SPACING.LG }]}>
98
+ <Text style={styles.listBullet}>{bulletChar}</Text>
99
+ <Text style={styles.listText} selectable>{formatInlineText(bulletMatch[2])}</Text>
100
+ </View>
101
+ );
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Horizontal rule (---, ***, ___)
107
+ if (/^[-*_]{3,}$/.test(trimmed)) {
108
+ elements.push(<View key={i} style={styles.horizontalRule} />);
109
+ i++;
110
+ continue;
111
+ }
112
+
113
+ // Empty line = paragraph break
114
+ if (!trimmed) {
115
+ elements.push(<View key={i} style={styles.paragraphBreak} />);
116
+ i++;
117
+ continue;
118
+ }
119
+
120
+ // Regular text
121
+ elements.push(
122
+ <Text key={i} style={styles.responseText} selectable>{formatInlineText(line)}</Text>
123
+ );
124
+ i++;
125
+ }
126
+
127
+ return (
128
+ <View style={styles.formattedContainer}>
129
+ {elements}
130
+ {isStreaming && <View style={styles.cursor} />}
131
+ </View>
132
+ );
133
+ }
134
+
135
+ // Format inline elements: code spans first, then links, then emphasis
136
+ function formatInlineText(text: string): React.ReactNode {
137
+ // Split on inline code first to avoid processing markdown inside code spans
138
+ const codeParts = text.split(/(`[^`]+`)/g);
139
+ if (codeParts.length === 1) {
140
+ return formatLinks(text);
141
+ }
142
+
143
+ return codeParts.map((part, i) => {
144
+ if (part.startsWith('`') && part.endsWith('`')) {
145
+ return <Text key={i} style={styles.inlineCode}>{part.slice(1, -1)}</Text>;
146
+ }
147
+ return <React.Fragment key={i}>{formatLinks(part)}</React.Fragment>;
148
+ });
149
+ }
150
+
151
+ // Format markdown links [text](url)
152
+ function formatLinks(text: string): React.ReactNode {
153
+ const parts = text.split(/(\[[^\]]+\]\([^)]+\))/g);
154
+ if (parts.length === 1) return formatEmphasis(text);
155
+
156
+ return parts.map((part, i) => {
157
+ const linkMatch = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/);
158
+ if (linkMatch) {
159
+ return (
160
+ <Text
161
+ key={i}
162
+ style={styles.linkText}
163
+ onPress={() => Linking.openURL(linkMatch[2])}
164
+ >
165
+ {linkMatch[1]}
166
+ </Text>
167
+ );
168
+ }
169
+ return <React.Fragment key={i}>{formatEmphasis(part)}</React.Fragment>;
170
+ });
171
+ }
172
+
173
+ // Format bold, italic, and strikethrough emphasis
174
+ function formatEmphasis(text: string): React.ReactNode {
175
+ // Match **bold**, ~~strikethrough~~, *italic*, and plain text segments
176
+ const parts = text.split(/(\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*)/g);
177
+ if (parts.length === 1) return text;
178
+
179
+ return parts.map((part, i) => {
180
+ if (part.startsWith('**') && part.endsWith('**')) {
181
+ return <Text key={i} style={styles.boldText}>{part.slice(2, -2)}</Text>;
182
+ }
183
+ if (part.startsWith('~~') && part.endsWith('~~')) {
184
+ return <Text key={i} style={styles.strikethroughText}>{part.slice(2, -2)}</Text>;
185
+ }
186
+ if (part.startsWith('*') && part.endsWith('*')) {
187
+ return <Text key={i} style={styles.italicText}>{part.slice(1, -1)}</Text>;
188
+ }
189
+ return part;
190
+ });
191
+ }
192
+
193
+ const styles = StyleSheet.create({
194
+ formattedContainer: {
195
+ flexDirection: "column",
196
+ },
197
+ responseText: {
198
+ color: "rgba(255,255,255,0.95)",
199
+ fontSize: TYPOGRAPHY.SIZE_LG,
200
+ lineHeight: 22,
201
+ },
202
+ cursor: {
203
+ width: 2,
204
+ height: 18,
205
+ backgroundColor: COLORS.TEXT_PRIMARY,
206
+ marginLeft: 2,
207
+ opacity: 0.7,
208
+ },
209
+ listItem: {
210
+ flexDirection: "row",
211
+ marginVertical: SPACING.XS / 2,
212
+ paddingLeft: SPACING.SM,
213
+ },
214
+ listNumber: {
215
+ color: COLORS.TEXT_MUTED,
216
+ fontSize: TYPOGRAPHY.SIZE_LG,
217
+ lineHeight: 22,
218
+ width: 24,
219
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
220
+ },
221
+ listBullet: {
222
+ color: COLORS.TEXT_MUTED,
223
+ fontSize: TYPOGRAPHY.SIZE_LG,
224
+ lineHeight: 22,
225
+ width: 18,
226
+ },
227
+ listText: {
228
+ flex: 1,
229
+ color: "rgba(255,255,255,0.95)",
230
+ fontSize: TYPOGRAPHY.SIZE_LG,
231
+ lineHeight: 22,
232
+ },
233
+ codeBlock: {
234
+ backgroundColor: "rgba(255,255,255,0.06)",
235
+ borderRadius: SPACING.SM,
236
+ padding: SPACING.MD,
237
+ marginVertical: SPACING.SM,
238
+ },
239
+ codeLang: {
240
+ color: COLORS.TEXT_MUTED,
241
+ fontSize: TYPOGRAPHY.SIZE_XS,
242
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
243
+ marginBottom: SPACING.XS,
244
+ textTransform: "uppercase",
245
+ },
246
+ codeText: {
247
+ color: "rgba(255,255,255,0.85)",
248
+ fontSize: TYPOGRAPHY.SIZE_SM,
249
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
250
+ lineHeight: 18,
251
+ },
252
+ inlineCode: {
253
+ backgroundColor: "rgba(255,255,255,0.1)",
254
+ color: "rgba(255,255,255,0.9)",
255
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
256
+ fontSize: TYPOGRAPHY.SIZE_MD,
257
+ paddingHorizontal: 4,
258
+ borderRadius: 3,
259
+ },
260
+ heading1: {
261
+ color: COLORS.TEXT_PRIMARY,
262
+ fontSize: TYPOGRAPHY.SIZE_XL,
263
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
264
+ lineHeight: 24,
265
+ marginTop: SPACING.MD,
266
+ marginBottom: SPACING.XS,
267
+ },
268
+ heading2: {
269
+ color: "rgba(255,255,255,0.9)",
270
+ fontSize: TYPOGRAPHY.SIZE_LG,
271
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
272
+ lineHeight: 22,
273
+ marginTop: SPACING.SM,
274
+ marginBottom: SPACING.XS,
275
+ },
276
+ heading3: {
277
+ color: "rgba(255,255,255,0.85)",
278
+ fontSize: TYPOGRAPHY.SIZE_LG,
279
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
280
+ lineHeight: 22,
281
+ marginTop: SPACING.SM,
282
+ marginBottom: SPACING.XS,
283
+ },
284
+ boldText: {
285
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
286
+ color: COLORS.TEXT_PRIMARY,
287
+ },
288
+ italicText: {
289
+ fontStyle: "italic",
290
+ color: "rgba(255,255,255,0.85)",
291
+ },
292
+ strikethroughText: {
293
+ textDecorationLine: "line-through",
294
+ color: COLORS.TEXT_MUTED,
295
+ },
296
+ linkText: {
297
+ color: COLORS.STATUS_INFO,
298
+ textDecorationLine: "underline",
299
+ },
300
+ blockquote: {
301
+ borderLeftWidth: 3,
302
+ borderLeftColor: "rgba(255,255,255,0.15)",
303
+ paddingLeft: SPACING.MD,
304
+ marginVertical: SPACING.XS,
305
+ },
306
+ blockquoteText: {
307
+ color: COLORS.TEXT_SECONDARY,
308
+ fontSize: TYPOGRAPHY.SIZE_LG,
309
+ lineHeight: 22,
310
+ fontStyle: "italic",
311
+ },
312
+ horizontalRule: {
313
+ height: 1,
314
+ backgroundColor: COLORS.BORDER,
315
+ marginVertical: SPACING.MD,
316
+ },
317
+ paragraphBreak: {
318
+ height: SPACING.SM,
319
+ },
320
+ });
@@ -0,0 +1,225 @@
1
+ import React, { useRef, useEffect } from "react";
2
+ import { View, Text, StyleSheet, NativeModules, TouchableOpacity, Animated, Easing, type TextProps, type ViewProps } from "react-native";
3
+ import type { ConnectionStatus } from "../services/websocket";
4
+ import { SPACING, LAYOUT, COLORS, TYPOGRAPHY, SIZES } from "../constants/design";
5
+
6
+ // Typed animated components for React 19 compatibility
7
+ const AnimatedView = Animated.View as React.ComponentClass<Animated.AnimatedProps<ViewProps>>;
8
+
9
+ // WidgetBridge is a simple native module available in the widget runtime
10
+ // ExpoAir is the main app's module (fallback)
11
+ const { WidgetBridge, ExpoAir } = NativeModules;
12
+
13
+ function handleCollapse() {
14
+ try {
15
+ // Try WidgetBridge first (widget runtime), then ExpoAir (main app)
16
+ if (WidgetBridge?.collapse) {
17
+ WidgetBridge.collapse();
18
+ } else if (ExpoAir?.collapse) {
19
+ ExpoAir.collapse();
20
+ } else {
21
+ console.warn("[expo-air] No collapse method available");
22
+ }
23
+ } catch (e) {
24
+ console.warn("[expo-air] Failed to collapse:", e);
25
+ }
26
+ }
27
+
28
+ interface HeaderProps {
29
+ status: ConnectionStatus;
30
+ branchName: string;
31
+ onBranchPress: () => void;
32
+ }
33
+
34
+ export function Header({ status, branchName, onBranchPress }: HeaderProps) {
35
+ const statusColors = {
36
+ disconnected: COLORS.STATUS_ERROR,
37
+ connecting: COLORS.STATUS_INFO,
38
+ connected: COLORS.STATUS_SUCCESS,
39
+ processing: COLORS.STATUS_INFO,
40
+ };
41
+
42
+ return (
43
+ <View style={styles.header}>
44
+ <TouchableOpacity onPress={handleCollapse} style={styles.closeButton}>
45
+ <Text style={styles.closeButtonText}>✕</Text>
46
+ </TouchableOpacity>
47
+
48
+ <TouchableOpacity style={styles.branchButton} onPress={onBranchPress} disabled={!branchName}>
49
+ {branchName ? (
50
+ <>
51
+ <Text style={styles.branchName} numberOfLines={1}>
52
+ {branchName}
53
+ </Text>
54
+ <Text style={styles.branchChevron}>▾</Text>
55
+ </>
56
+ ) : (
57
+ <View style={styles.branchLoadingBar} />
58
+ )}
59
+ </TouchableOpacity>
60
+
61
+ <View style={[styles.statusDot, { backgroundColor: statusColors[status] }]} />
62
+ </View>
63
+ );
64
+ }
65
+
66
+ export function PulsingIndicator({ status }: { status: ConnectionStatus }) {
67
+ const colors = {
68
+ disconnected: COLORS.STATUS_ERROR,
69
+ connecting: COLORS.STATUS_INFO,
70
+ connected: COLORS.STATUS_SUCCESS,
71
+ processing: COLORS.STATUS_INFO,
72
+ };
73
+
74
+ const isAnimating = status === "processing" || status === "connecting";
75
+
76
+ // Animated values for the pulsing ring
77
+ const scaleAnim = useRef(new Animated.Value(1)).current;
78
+ const opacityAnim = useRef(new Animated.Value(0.4)).current;
79
+
80
+ useEffect(() => {
81
+ if (isAnimating) {
82
+ // Create a soft pulsing animation
83
+ const pulseAnimation = Animated.loop(
84
+ Animated.sequence([
85
+ Animated.parallel([
86
+ Animated.timing(scaleAnim, {
87
+ toValue: 1.3,
88
+ duration: 1200,
89
+ easing: Easing.inOut(Easing.ease),
90
+ useNativeDriver: true,
91
+ }),
92
+ Animated.timing(opacityAnim, {
93
+ toValue: 0,
94
+ duration: 1200,
95
+ easing: Easing.inOut(Easing.ease),
96
+ useNativeDriver: true,
97
+ }),
98
+ ]),
99
+ Animated.parallel([
100
+ Animated.timing(scaleAnim, {
101
+ toValue: 1,
102
+ duration: 0,
103
+ useNativeDriver: true,
104
+ }),
105
+ Animated.timing(opacityAnim, {
106
+ toValue: 0.4,
107
+ duration: 0,
108
+ useNativeDriver: true,
109
+ }),
110
+ ]),
111
+ ])
112
+ );
113
+ pulseAnimation.start();
114
+ return () => pulseAnimation.stop();
115
+ } else {
116
+ // Reset when not animating
117
+ scaleAnim.setValue(1);
118
+ opacityAnim.setValue(0.4);
119
+ }
120
+ }, [isAnimating, scaleAnim, opacityAnim]);
121
+
122
+ return (
123
+ <View style={styles.indicatorContainer}>
124
+ <View
125
+ style={[
126
+ styles.indicator,
127
+ { backgroundColor: colors[status] },
128
+ ]}
129
+ />
130
+ {isAnimating && (
131
+ <AnimatedView
132
+ style={[
133
+ styles.indicatorRing,
134
+ {
135
+ borderColor: colors[status],
136
+ transform: [{ scale: scaleAnim }],
137
+ opacity: opacityAnim,
138
+ },
139
+ ]}
140
+ />
141
+ )}
142
+ </View>
143
+ );
144
+ }
145
+
146
+ const styles = StyleSheet.create({
147
+ header: {
148
+ flexDirection: "row",
149
+ alignItems: "center",
150
+ paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
151
+ paddingVertical: SPACING.MD + 2, // 14px for comfortable header height
152
+ borderBottomWidth: 1,
153
+ borderBottomColor: COLORS.BORDER,
154
+ },
155
+ closeButton: {
156
+ width: SIZES.CLOSE_BUTTON,
157
+ height: SIZES.CLOSE_BUTTON,
158
+ borderRadius: SIZES.CLOSE_BUTTON / 2,
159
+ // Make invisible - native close button handles the tap
160
+ backgroundColor: "transparent",
161
+ alignItems: "center",
162
+ justifyContent: "center",
163
+ marginRight: SPACING.MD,
164
+ },
165
+ closeButtonText: {
166
+ // Hide the text - native button shows the X
167
+ color: "transparent",
168
+ fontSize: TYPOGRAPHY.SIZE_MD,
169
+ fontWeight: TYPOGRAPHY.WEIGHT_SEMIBOLD,
170
+ },
171
+ branchButton: {
172
+ flex: 1,
173
+ flexDirection: "row",
174
+ alignItems: "center",
175
+ },
176
+ branchName: {
177
+ flexShrink: 1,
178
+ color: COLORS.TEXT_SECONDARY,
179
+ fontSize: TYPOGRAPHY.SIZE_MD,
180
+ fontWeight: TYPOGRAPHY.WEIGHT_MEDIUM,
181
+ },
182
+ branchChevron: {
183
+ color: COLORS.TEXT_MUTED,
184
+ fontSize: TYPOGRAPHY.SIZE_SM,
185
+ marginLeft: SPACING.XS,
186
+ },
187
+ branchLoadingBar: {
188
+ width: 80,
189
+ height: 12,
190
+ borderRadius: 6,
191
+ backgroundColor: "rgba(255,255,255,0.08)",
192
+ },
193
+ statusDot: {
194
+ width: SIZES.STATUS_DOT,
195
+ height: SIZES.STATUS_DOT,
196
+ borderRadius: SIZES.STATUS_DOT / 2,
197
+ marginLeft: SPACING.MD, // Match the closeButton marginRight for visual balance
198
+ },
199
+ collapsedPill: {
200
+ width: 100,
201
+ height: 32,
202
+ backgroundColor: "transparent",
203
+ alignItems: "center",
204
+ justifyContent: "center",
205
+ },
206
+ indicatorContainer: {
207
+ width: 20,
208
+ height: 20,
209
+ alignItems: "center",
210
+ justifyContent: "center",
211
+ },
212
+ indicator: {
213
+ width: SIZES.STATUS_DOT,
214
+ height: SIZES.STATUS_DOT,
215
+ borderRadius: SIZES.STATUS_DOT / 2,
216
+ },
217
+ indicatorRing: {
218
+ position: "absolute",
219
+ width: 16,
220
+ height: 16,
221
+ borderRadius: 8,
222
+ borderWidth: 1.5,
223
+ opacity: 0.4,
224
+ },
225
+ });