@10play/expo-air 0.10.0 → 0.10.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.
@@ -1,23 +1,32 @@
1
- import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
1
+ import React, { useState, useRef, useImperativeHandle, forwardRef, useEffect } from "react";
2
2
  import {
3
3
  View,
4
4
  TextInput,
5
5
  TouchableOpacity,
6
6
  StyleSheet,
7
+ Image,
8
+ ScrollView,
9
+ Alert,
10
+ NativeModules,
11
+ NativeEventEmitter,
7
12
  } from "react-native";
13
+ import * as ImagePicker from "expo-image-picker";
8
14
  import { SPACING, LAYOUT, COLORS, TYPOGRAPHY, SIZES } from "../constants/design";
15
+ import type { ImageAttachment } from "../services/websocket";
9
16
 
10
17
  export interface PromptInputHandle {
11
18
  focus: () => void;
12
19
  }
13
20
 
14
21
  interface PromptInputProps {
15
- onSubmit: (prompt: string) => void;
22
+ onSubmit: (prompt: string, images?: ImageAttachment[]) => void;
16
23
  onStop?: () => void;
17
24
  disabled?: boolean;
18
25
  isProcessing?: boolean;
19
26
  }
20
27
 
28
+ const MAX_IMAGES = 4;
29
+
21
30
  export const PromptInput = forwardRef<PromptInputHandle, PromptInputProps>(({
22
31
  onSubmit,
23
32
  onStop,
@@ -25,61 +34,149 @@ export const PromptInput = forwardRef<PromptInputHandle, PromptInputProps>(({
25
34
  isProcessing = false,
26
35
  }, ref) => {
27
36
  const [text, setText] = useState("");
37
+ const [images, setImages] = useState<ImageAttachment[]>([]);
28
38
  const inputRef = useRef<TextInput>(null);
29
39
 
30
40
  useImperativeHandle(ref, () => ({
31
41
  focus: () => inputRef.current?.focus(),
32
42
  }));
33
43
 
44
+ // Listen for native image paste events (UITextView paste: swizzle)
45
+ useEffect(() => {
46
+ const emitter = new NativeEventEmitter(NativeModules.WidgetBridge);
47
+ const subscription = emitter.addListener('onClipboardImagePaste', (image: ImageAttachment) => {
48
+ setImages((prev) => {
49
+ if (prev.length >= MAX_IMAGES) return prev;
50
+ return [...prev, image];
51
+ });
52
+ });
53
+ return () => subscription.remove();
54
+ }, []);
55
+
34
56
  const handleSubmit = () => {
35
57
  const trimmed = text.trim();
36
- if (trimmed && !disabled && !isProcessing) {
37
- onSubmit(trimmed);
58
+ const hasContent = trimmed.length > 0 || images.length > 0;
59
+ if (hasContent && !disabled && !isProcessing) {
60
+ onSubmit(trimmed, images.length > 0 ? images : undefined);
38
61
  setText("");
62
+ setImages([]);
39
63
  }
40
64
  };
41
65
 
42
- // Input stays editable, but submit button disabled when disconnected
43
- const canSubmit = text.trim().length > 0 && !disabled && !isProcessing;
66
+ const handlePickImages = async () => {
67
+ if (images.length >= MAX_IMAGES) {
68
+ Alert.alert("Limit reached", `Maximum ${MAX_IMAGES} images per message.`);
69
+ return;
70
+ }
71
+
72
+ try {
73
+ const result = await ImagePicker.launchImageLibraryAsync({
74
+ mediaTypes: ["images"],
75
+ allowsMultipleSelection: true,
76
+ selectionLimit: MAX_IMAGES - images.length,
77
+ quality: 0.7,
78
+ });
79
+
80
+ if (!result.canceled && result.assets.length > 0) {
81
+ const picked: ImageAttachment[] = result.assets.map((asset) => ({
82
+ uri: asset.uri,
83
+ width: asset.width,
84
+ height: asset.height,
85
+ }));
86
+ setImages((prev) => [...prev, ...picked].slice(0, MAX_IMAGES));
87
+ }
88
+ } catch (e) {
89
+ console.warn("[expo-air] Image picker error:", e);
90
+ }
91
+ };
92
+
93
+ const removeImage = (index: number) => {
94
+ setImages((prev) => prev.filter((_, i) => i !== index));
95
+ };
96
+
97
+ const canSubmit = (text.trim().length > 0 || images.length > 0) && !disabled && !isProcessing;
44
98
 
45
99
  return (
46
- <View style={styles.container}>
47
- <TextInput
48
- ref={inputRef}
49
- style={styles.input}
50
- placeholder="Ask Claude..."
51
- placeholderTextColor={COLORS.TEXT_TERTIARY}
52
- value={text}
53
- onChangeText={setText}
54
- onSubmitEditing={handleSubmit}
55
- editable={!isProcessing}
56
- multiline
57
- maxLength={2000}
58
- returnKeyType="send"
59
- blurOnSubmit
60
- />
61
- {isProcessing ? (
62
- <TouchableOpacity
63
- style={[styles.submitButton, styles.stopButton]}
64
- onPress={onStop}
65
- activeOpacity={0.7}
100
+ <View style={styles.outerContainer}>
101
+ {images.length > 0 && (
102
+ <ScrollView
103
+ horizontal
104
+ showsHorizontalScrollIndicator={false}
105
+ style={styles.previewStrip}
106
+ contentContainerStyle={styles.previewContent}
66
107
  >
67
- <StopIcon />
68
- </TouchableOpacity>
69
- ) : (
108
+ {images.map((img, index) => (
109
+ <View key={index} style={styles.previewItem}>
110
+ <Image source={{ uri: img.uri }} style={styles.previewImage} />
111
+ <TouchableOpacity
112
+ style={styles.removeButton}
113
+ onPress={() => removeImage(index)}
114
+ hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
115
+ >
116
+ <View style={styles.removeIcon}>
117
+ <View style={[styles.removeLine, styles.removeLineA]} />
118
+ <View style={[styles.removeLine, styles.removeLineB]} />
119
+ </View>
120
+ </TouchableOpacity>
121
+ </View>
122
+ ))}
123
+ </ScrollView>
124
+ )}
125
+ <View style={styles.container}>
70
126
  <TouchableOpacity
71
- style={[styles.submitButton, !canSubmit && styles.submitButtonDisabled]}
72
- onPress={handleSubmit}
73
- disabled={!canSubmit}
74
- activeOpacity={0.7}
127
+ style={styles.iconButton}
128
+ onPress={handlePickImages}
129
+ disabled={isProcessing}
130
+ activeOpacity={0.6}
75
131
  >
76
- <ArrowIcon />
132
+ <PlusIcon />
77
133
  </TouchableOpacity>
78
- )}
134
+ <TextInput
135
+ ref={inputRef}
136
+ style={styles.input}
137
+ placeholder="Ask Claude..."
138
+ placeholderTextColor={COLORS.TEXT_TERTIARY}
139
+ value={text}
140
+ onChangeText={setText}
141
+ onSubmitEditing={handleSubmit}
142
+ editable={!isProcessing}
143
+ multiline
144
+ maxLength={2000}
145
+ returnKeyType="send"
146
+ blurOnSubmit
147
+ />
148
+ {isProcessing ? (
149
+ <TouchableOpacity
150
+ style={[styles.submitButton, styles.stopButton]}
151
+ onPress={onStop}
152
+ activeOpacity={0.7}
153
+ >
154
+ <StopIcon />
155
+ </TouchableOpacity>
156
+ ) : (
157
+ <TouchableOpacity
158
+ style={[styles.submitButton, !canSubmit && styles.submitButtonDisabled]}
159
+ onPress={handleSubmit}
160
+ disabled={!canSubmit}
161
+ activeOpacity={0.7}
162
+ >
163
+ <ArrowIcon />
164
+ </TouchableOpacity>
165
+ )}
166
+ </View>
79
167
  </View>
80
168
  );
81
169
  });
82
170
 
171
+ function PlusIcon() {
172
+ return (
173
+ <View style={styles.plusIcon}>
174
+ <View style={styles.plusH} />
175
+ <View style={styles.plusV} />
176
+ </View>
177
+ );
178
+ }
179
+
83
180
  function ArrowIcon() {
84
181
  return (
85
182
  <View style={styles.arrowIcon}>
@@ -94,14 +191,71 @@ function StopIcon() {
94
191
  }
95
192
 
96
193
  const styles = StyleSheet.create({
194
+ outerContainer: {
195
+ borderTopWidth: 1,
196
+ borderTopColor: COLORS.BORDER,
197
+ backgroundColor: COLORS.BACKGROUND,
198
+ },
199
+ previewStrip: {
200
+ maxHeight: 72,
201
+ paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
202
+ paddingTop: SPACING.SM,
203
+ },
204
+ previewContent: {
205
+ gap: SPACING.SM,
206
+ alignItems: "center",
207
+ },
208
+ previewItem: {
209
+ position: "relative",
210
+ },
211
+ previewImage: {
212
+ width: 56,
213
+ height: 56,
214
+ borderRadius: SPACING.SM,
215
+ backgroundColor: "rgba(255,255,255,0.1)",
216
+ },
217
+ removeButton: {
218
+ position: "absolute",
219
+ top: -3,
220
+ right: -3,
221
+ width: 18,
222
+ height: 18,
223
+ borderRadius: 9,
224
+ backgroundColor: "#1C1C1E",
225
+ alignItems: "center",
226
+ justifyContent: "center",
227
+ },
228
+ removeIcon: {
229
+ width: 8,
230
+ height: 8,
231
+ alignItems: "center",
232
+ justifyContent: "center",
233
+ },
234
+ removeLine: {
235
+ position: "absolute",
236
+ width: 9,
237
+ height: 1.2,
238
+ backgroundColor: "rgba(255,255,255,0.9)",
239
+ borderRadius: 1,
240
+ },
241
+ removeLineA: {
242
+ transform: [{ rotate: "45deg" }],
243
+ },
244
+ removeLineB: {
245
+ transform: [{ rotate: "-45deg" }],
246
+ },
97
247
  container: {
98
248
  flexDirection: "row",
99
249
  alignItems: "flex-end",
100
250
  paddingHorizontal: LAYOUT.CONTENT_PADDING_H,
101
251
  paddingVertical: SPACING.MD,
102
- borderTopWidth: 1,
103
- borderTopColor: COLORS.BORDER,
104
- backgroundColor: COLORS.BACKGROUND,
252
+ },
253
+ iconButton: {
254
+ width: 36,
255
+ height: SIZES.SUBMIT_BUTTON,
256
+ alignItems: "center",
257
+ justifyContent: "center",
258
+ marginRight: 4,
105
259
  },
106
260
  input: {
107
261
  flex: 1,
@@ -120,7 +274,7 @@ const styles = StyleSheet.create({
120
274
  backgroundColor: COLORS.BACKGROUND_BUTTON,
121
275
  alignItems: "center",
122
276
  justifyContent: "center",
123
- marginLeft: SPACING.SM + 2, // 10px
277
+ marginLeft: SPACING.SM + 2,
124
278
  },
125
279
  submitButtonDisabled: {
126
280
  opacity: 0.4,
@@ -158,4 +312,25 @@ const styles = StyleSheet.create({
158
312
  borderRightColor: "transparent",
159
313
  borderBottomColor: COLORS.TEXT_PRIMARY,
160
314
  },
315
+ // Plus icon
316
+ plusIcon: {
317
+ width: 18,
318
+ height: 18,
319
+ alignItems: "center",
320
+ justifyContent: "center",
321
+ },
322
+ plusH: {
323
+ position: "absolute",
324
+ width: 16,
325
+ height: 2,
326
+ borderRadius: 1,
327
+ backgroundColor: COLORS.TEXT_MUTED,
328
+ },
329
+ plusV: {
330
+ position: "absolute",
331
+ width: 2,
332
+ height: 16,
333
+ borderRadius: 1,
334
+ backgroundColor: COLORS.TEXT_MUTED,
335
+ },
161
336
  });
@@ -1,5 +1,5 @@
1
1
  import React, { useRef, useEffect, useState, useCallback } from "react";
2
- import { View, Text, ScrollView, StyleSheet, TouchableOpacity, NativeScrollEvent, Keyboard, Platform, Linking } from "react-native";
2
+ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, NativeScrollEvent, Keyboard, Platform, Image, Linking } from "react-native";
3
3
  import type { ServerMessage, ToolMessage, ResultMessage, UserPromptMessage, HistoryResultMessage, SystemDisplayMessage, AssistantPart, AssistantPartsMessage } from "../services/websocket";
4
4
  import { SPACING, LAYOUT, COLORS, TYPOGRAPHY } from "../constants/design";
5
5
 
@@ -152,7 +152,16 @@ function MessageItem({ message }: { key?: React.Key; message: ServerMessage }) {
152
152
  function UserPromptItem({ message }: { message: UserPromptMessage }) {
153
153
  return (
154
154
  <View style={styles.userPromptContainer}>
155
- <Text style={styles.userPromptText} selectable>{message.content}</Text>
155
+ {message.images && message.images.length > 0 && (
156
+ <View style={styles.userImages}>
157
+ {message.images.map((img, i) => (
158
+ <Image key={i} source={{ uri: img.uri }} style={styles.userImageThumb} />
159
+ ))}
160
+ </View>
161
+ )}
162
+ {message.content ? (
163
+ <Text style={styles.userPromptText} selectable>{message.content}</Text>
164
+ ) : null}
156
165
  </View>
157
166
  );
158
167
  }
@@ -716,6 +725,18 @@ const styles = StyleSheet.create({
716
725
  fontSize: TYPOGRAPHY.SIZE_LG,
717
726
  lineHeight: 20,
718
727
  },
728
+ userImages: {
729
+ flexDirection: "row",
730
+ flexWrap: "wrap",
731
+ gap: SPACING.SM,
732
+ marginBottom: SPACING.SM,
733
+ },
734
+ userImageThumb: {
735
+ width: 80,
736
+ height: 80,
737
+ borderRadius: SPACING.SM,
738
+ backgroundColor: "rgba(255,255,255,0.1)",
739
+ },
719
740
  scrollButton: {
720
741
  position: "absolute",
721
742
  bottom: LAYOUT.CONTENT_PADDING_H,
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "dependencies": {
10
10
  "expo": "~54.0.32",
11
+ "expo-image-picker": "~16.1.4",
11
12
  "react": "19.1.0",
12
13
  "react-native": "0.81.5"
13
14
  },
@@ -92,10 +92,17 @@ export interface HistoryMessage {
92
92
  timestamp: number;
93
93
  }
94
94
 
95
+ export interface ImageAttachment {
96
+ uri: string;
97
+ width?: number;
98
+ height?: number;
99
+ }
100
+
95
101
  // Local display-only message for showing user prompts in the UI
96
102
  export interface UserPromptMessage {
97
103
  type: "user_prompt";
98
104
  content: string;
105
+ images?: ImageAttachment[];
99
106
  timestamp: number;
100
107
  }
101
108
 
@@ -341,18 +348,22 @@ export class WebSocketClient {
341
348
  this.setStatus("disconnected");
342
349
  }
343
350
 
344
- sendPrompt(content: string): void {
351
+ sendPrompt(content: string, imagePaths?: string[]): void {
345
352
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
346
353
  this.options.onError(new Error("Not connected"));
347
354
  return;
348
355
  }
349
356
 
350
- const message = {
357
+ const message: Record<string, unknown> = {
351
358
  type: "prompt",
352
359
  content,
353
360
  id: generateId(),
354
361
  };
355
362
 
363
+ if (imagePaths && imagePaths.length > 0) {
364
+ message.imagePaths = imagePaths;
365
+ }
366
+
356
367
  this.ws.send(JSON.stringify(message));
357
368
  this.setStatus("processing");
358
369
  }