Skip to content

Commit 0cc0bf5

Browse files
Ripwordsclaude
andcommitted
feat(expo): pick attachments from Photos / Files / Clipboard
Replaces the single document-picker entry point with an action-sheet style modal so users can choose: Photos library (expo-image-picker), Files (expo-document-picker, the existing path), or Paste from Clipboard (expo-clipboard + expo-file-system to write base64 to cache dir, then file:// URI through the same RN-FormData shorthand the other pickers use). Two crash fixes in the same change: - sheet.tsx was synthesising File objects via `new File([new Uint8Array(0)], …)` to satisfy validateAttachments' File[] signature. RN's BlobManager rejects ArrayBuffer/ArrayBufferView parts at runtime, so this threw "Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported". Modern TypeScript also rejects Uint8Array<ArrayBufferLike> as a BlobPart. Both paths fixed by widening validateAttachments to accept an AttachmentCandidate shape ({name, size, type, blob?}) and passing duck-typed records on the Expo side. File still satisfies the candidate type on the web. - pickFromClipboard previously did `new Blob([uint8array])` which hits the same RN issue. Now writes base64 to expo-file-system.cacheDirectory and passes the file:// URI as previewUrl, matching pickFromFiles / pickFromPhotos. The Attachment.blob field is a placeholder; the intake-client ships from the URI. Renames `pickFiles` → `pickFromFiles` for symmetry with `pickFromPhotos` and `pickFromClipboard`. Tests follow the rename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 368d63b commit 0cc0bf5

9 files changed

Lines changed: 408 additions & 36 deletions

File tree

packages/expo/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,12 @@
5959
"@react-native-async-storage/async-storage": ">=1.23.0",
6060
"@react-native-community/netinfo": ">=11.3.0",
6161
"expo": ">=52.0.0",
62+
"expo-clipboard": "*",
6263
"expo-constants": ">=16.0.0",
6364
"expo-device": ">=6.0.0",
6465
"expo-document-picker": "*",
66+
"expo-file-system": ">=18.0.0",
67+
"expo-image-picker": "*",
6568
"react": ">=18.3.0",
6669
"react-native": ">=0.74.0",
6770
"react-native-gesture-handler": ">=2.16.0",

packages/expo/src/capture/file-picker.cancel.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ mock.module("expo-document-picker", () => ({
44
getDocumentAsync: async () => ({ canceled: true }),
55
}))
66

7-
describe("pickFiles", () => {
7+
describe("pickFromFiles", () => {
88
test("returns empty array when canceled", async () => {
9-
const { pickFiles } = await import("./file-picker")
10-
const out = await pickFiles({ multiple: true })
9+
const { pickFromFiles } = await import("./file-picker")
10+
const out = await pickFromFiles({ multiple: true })
1111
expect(out).toEqual([])
1212
})
1313
})

packages/expo/src/capture/file-picker.success.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ mock.module("expo-document-picker", () => ({
1010
}),
1111
}))
1212

13-
describe("pickFiles", () => {
13+
describe("pickFromFiles", () => {
1414
test("returns Attachment[] from a successful pick", async () => {
15-
const { pickFiles } = await import("./file-picker")
16-
const out = await pickFiles({ multiple: true })
15+
const { pickFromFiles } = await import("./file-picker")
16+
const out = await pickFromFiles({ multiple: true })
1717
expect(out).toHaveLength(2)
1818
expect(out[0]?.filename).toBe("a.png")
1919
expect(out[0]?.mime).toBe("image/png")

packages/expo/src/capture/file-picker.ts

Lines changed: 133 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
11
import type { Attachment } from "@reprojs/sdk-utils"
22
import type { DocumentPickerResult } from "expo-document-picker"
3+
import type { ImagePickerResult } from "expo-image-picker"
34

45
type GetDocumentAsync = (opts?: {
56
multiple?: boolean
67
copyToCacheDirectory?: boolean
78
}) => Promise<DocumentPickerResult>
89

10+
type LaunchImageLibraryAsync = (opts?: {
11+
mediaTypes?: "All" | "Images" | "Videos" | string[]
12+
allowsMultipleSelection?: boolean
13+
quality?: number
14+
selectionLimit?: number
15+
}) => Promise<ImagePickerResult>
16+
17+
type HasImageAsync = () => Promise<boolean>
18+
type GetImageAsync = (opts?: { format?: "png" | "jpeg" }) => Promise<{
19+
data: string
20+
size: { width: number; height: number }
21+
} | null>
22+
23+
function makeAttachmentId(prefix: string, i: number): string {
24+
return `${prefix}-${Date.now()}-${i}`
25+
}
26+
927
/**
10-
* Wraps expo-document-picker.getDocumentAsync. Returns an empty array on
11-
* cancel or when the picker module is unavailable. Each asset is converted
12-
* to an Attachment — the blob field is a placeholder Blob; the intake-client
13-
* uses the previewUrl (file:// uri) at submit time so we don't read every
14-
* file into memory the moment it's picked.
28+
* Pick from the system file picker (Documents app on iOS, system picker on
29+
* Android). Accepts any file type — same as the web SDK. Each asset is
30+
* converted to an Attachment whose blob is a placeholder; the intake client
31+
* streams from the previewUrl (file:// uri) at submit time.
1532
*/
16-
export async function pickFiles({ multiple = true }: { multiple?: boolean } = {}): Promise<
33+
export async function pickFromFiles({ multiple = true }: { multiple?: boolean } = {}): Promise<
1734
Attachment[]
1835
> {
1936
let getDocumentAsync: GetDocumentAsync | undefined
@@ -35,8 +52,8 @@ export async function pickFiles({ multiple = true }: { multiple?: boolean } = {}
3552
return result.assets.map((asset, i) => {
3653
const mime = asset.mimeType ?? "application/octet-stream"
3754
return {
38-
id: `picker-${Date.now()}-${i}`,
39-
blob: new Blob([], { type: mime }), // Placeholder — intake-client uses uri at submit time.
55+
id: makeAttachmentId("doc", i),
56+
blob: new Blob([], { type: mime }),
4057
filename: asset.name,
4158
mime,
4259
size: asset.size ?? 0,
@@ -45,3 +62,111 @@ export async function pickFiles({ multiple = true }: { multiple?: boolean } = {}
4562
} satisfies Attachment
4663
})
4764
}
65+
66+
/**
67+
* Pick from the device's photo library. Image-only by design — videos can
68+
* be added later if users ask. Filename is derived from the picker's
69+
* fileName (Android) or from the URI's tail when iOS doesn't provide one.
70+
*/
71+
export async function pickFromPhotos({ multiple = true }: { multiple?: boolean } = {}): Promise<
72+
Attachment[]
73+
> {
74+
let launch: LaunchImageLibraryAsync | undefined
75+
try {
76+
const mod = await import("expo-image-picker")
77+
launch = mod.launchImageLibraryAsync
78+
} catch {
79+
return []
80+
}
81+
if (!launch) return []
82+
83+
const result = await launch({
84+
mediaTypes: "Images",
85+
allowsMultipleSelection: multiple,
86+
quality: 1,
87+
selectionLimit: multiple ? 0 : 1,
88+
})
89+
90+
if (result.canceled || !result.assets) return []
91+
92+
return result.assets.map((asset, i) => {
93+
const mime = asset.mimeType ?? "image/jpeg"
94+
const tail = asset.uri.split("/").pop() ?? `photo-${i}`
95+
const filename = asset.fileName ?? tail
96+
return {
97+
id: makeAttachmentId("photo", i),
98+
blob: new Blob([], { type: mime }),
99+
filename,
100+
mime,
101+
size: asset.fileSize ?? 0,
102+
isImage: true,
103+
previewUrl: asset.uri,
104+
} satisfies Attachment
105+
})
106+
}
107+
108+
/**
109+
* Read a single image off the system clipboard. Returns an empty array
110+
* when the clipboard has no image, when expo-clipboard or expo-file-system
111+
* are unavailable, or when the user denied access. We write the base64
112+
* payload to expo-file-system's cache directory and pass the resulting
113+
* `file://` URI through `previewUrl`, matching the contract used by the
114+
* other pickers — RN's Blob polyfill rejects ArrayBuffer/Uint8Array
115+
* inputs, so we must NOT construct `new Blob([bytes])`.
116+
*/
117+
export async function pickFromClipboard(): Promise<Attachment[]> {
118+
let hasImage: HasImageAsync | undefined
119+
let getImage: GetImageAsync | undefined
120+
try {
121+
const mod = await import("expo-clipboard")
122+
hasImage = mod.hasImageAsync
123+
getImage = mod.getImageAsync
124+
} catch {
125+
return []
126+
}
127+
if (!hasImage || !getImage) return []
128+
129+
const present = await hasImage()
130+
if (!present) return []
131+
132+
const result = await getImage({ format: "png" })
133+
if (!result?.data) return []
134+
135+
// Clipboard data may arrive as raw base64 (iOS) or as a full data: URI
136+
// (Android). Strip any prefix so we hand the file system pure base64.
137+
const base64 = result.data.replace(/^data:[^;]+;base64,/, "")
138+
const sizeBytes = Math.floor((base64.length * 3) / 4)
139+
140+
let cacheDirectory: string | null = null
141+
let writeAsStringAsync:
142+
| ((uri: string, contents: string, opts?: { encoding?: "utf8" | "base64" }) => Promise<void>)
143+
| undefined
144+
try {
145+
const fs = await import("expo-file-system")
146+
cacheDirectory = fs.cacheDirectory
147+
writeAsStringAsync = fs.writeAsStringAsync
148+
} catch {
149+
return []
150+
}
151+
if (!cacheDirectory || !writeAsStringAsync) return []
152+
153+
const filename = `pasted-${Date.now()}.png`
154+
const uri = `${cacheDirectory}${filename}`
155+
await writeAsStringAsync(uri, base64, { encoding: "base64" })
156+
157+
const mime = "image/png"
158+
return [
159+
{
160+
id: makeAttachmentId("clip", 0),
161+
// Empty placeholder blob — same pattern as pickFromFiles /
162+
// pickFromPhotos. The real bytes live at `previewUrl` and are
163+
// streamed by RN's FormData {uri, name, type} shorthand at submit.
164+
blob: new Blob([], { type: mime }),
165+
filename,
166+
mime,
167+
size: sizeBytes,
168+
isImage: true,
169+
previewUrl: uri,
170+
},
171+
]
172+
}

packages/expo/src/vendor.d.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,60 @@ declare module "expo-document-picker" {
2121
type?: string | string[]
2222
}): Promise<DocumentPickerResult>
2323
}
24+
25+
declare module "expo-image-picker" {
26+
export interface ImagePickerAsset {
27+
uri: string
28+
fileName?: string | null
29+
mimeType?: string
30+
fileSize?: number
31+
width?: number
32+
height?: number
33+
type?: "image" | "video"
34+
}
35+
36+
export type ImagePickerResult =
37+
| { canceled: true; assets?: never }
38+
| { canceled: false; assets: ImagePickerAsset[] }
39+
40+
export const MediaTypeOptions: {
41+
All: "All"
42+
Images: "Images"
43+
Videos: "Videos"
44+
}
45+
46+
export function launchImageLibraryAsync(options?: {
47+
mediaTypes?: "All" | "Images" | "Videos" | string[]
48+
allowsMultipleSelection?: boolean
49+
quality?: number
50+
selectionLimit?: number
51+
}): Promise<ImagePickerResult>
52+
53+
export function requestMediaLibraryPermissionsAsync(): Promise<{
54+
status: "granted" | "denied" | "undetermined"
55+
granted: boolean
56+
}>
57+
}
58+
59+
declare module "expo-clipboard" {
60+
export function hasImageAsync(): Promise<boolean>
61+
export function getImageAsync(opts?: { format?: "png" | "jpeg" }): Promise<{
62+
data: string
63+
size: { width: number; height: number }
64+
} | null>
65+
}
66+
67+
declare module "expo-file-system" {
68+
export const cacheDirectory: string | null
69+
export const documentDirectory: string | null
70+
export function writeAsStringAsync(
71+
fileUri: string,
72+
contents: string,
73+
options?: { encoding?: "utf8" | "base64" },
74+
): Promise<void>
75+
export function getInfoAsync(fileUri: string): Promise<{
76+
exists: boolean
77+
size?: number
78+
uri: string
79+
}>
80+
}

packages/expo/src/wizard/sheet.tsx

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useMemo, useRef, useState } from "react"
1+
import { useEffect, useMemo, useRef, useState } from "react"
22
import {
33
KeyboardAvoidingView,
44
Modal,
@@ -17,7 +17,8 @@ import { useAnnotationShapes } from "../annotation/use-shapes"
1717
import { CloseIcon } from "../annotation/icons"
1818
import { PrimaryButton, SecondaryButton, StepIndicator } from "./controls"
1919
import { theme } from "./theme"
20-
import { pickFiles } from "../capture/file-picker"
20+
import { pickFromClipboard, pickFromFiles, pickFromPhotos } from "../capture/file-picker"
21+
import { SourcePicker, type AttachmentSource } from "./source-picker"
2122
import { DEFAULT_ATTACHMENT_LIMITS, validateAttachments, type Attachment } from "@reprojs/sdk-utils"
2223

2324
export interface WizardArgs {
@@ -53,6 +54,8 @@ export function WizardSheet({
5354
const [annotateSize, setAnnotateSize] = useState({ w: 0, h: 0 })
5455
const [attachments, setAttachments] = useState<Attachment[]>([])
5556
const [attachmentErrors, setAttachmentErrors] = useState<string[]>([])
57+
const [sourcePickerVisible, setSourcePickerVisible] = useState(false)
58+
const [hasClipboardImage, setHasClipboardImage] = useState(false)
5659
const store = useRef(createAnnotationStore()).current
5760
const flattenRef = useRef<FlattenHandle | null>(null)
5861

@@ -64,28 +67,46 @@ export function WizardSheet({
6467
}, [screenshot])
6568

6669
async function handleAttachmentsAdd() {
67-
const picked = await pickFiles({ multiple: true })
70+
// Probe the clipboard so we can disable that row in the picker when
71+
// there's no image to paste — better than letting the user tap and
72+
// get nothing.
73+
let clipboardHasImage = false
74+
try {
75+
const mod = await import("expo-clipboard")
76+
clipboardHasImage = await mod.hasImageAsync()
77+
} catch {
78+
clipboardHasImage = false
79+
}
80+
setHasClipboardImage(clipboardHasImage)
81+
setSourcePickerVisible(true)
82+
}
83+
84+
async function handleSourceSelected(source: AttachmentSource) {
85+
setSourcePickerVisible(false)
86+
let picked: Attachment[] = []
87+
if (source === "files") picked = await pickFromFiles({ multiple: true })
88+
else if (source === "photos") picked = await pickFromPhotos({ multiple: true })
89+
else if (source === "clipboard") picked = await pickFromClipboard()
6890
if (picked.length === 0) return
69-
// Convert picker output → File[] for validateAttachments. Each picked
70-
// Attachment already has size/mime/filename; we mirror them onto a stub
71-
// File so the validator's File contract type-checks. Override size since
72-
// the picker already told us the real value.
73-
const asFiles: File[] = picked.map((a) => {
74-
const f = new File([new Uint8Array(0)], a.filename, { type: a.mime })
75-
Object.defineProperty(f, "size", { value: a.size, configurable: true })
76-
return f
77-
})
78-
const result = validateAttachments(asFiles, attachments, DEFAULT_ATTACHMENT_LIMITS)
79-
// Reattach picker uri/blob to the validated Attachments (validateAttachments
80-
// wraps the File reference in `blob`, which on RN is a stub — replace with
81-
// the picker's previewUrl + blob).
82-
const accepted = result.accepted.map((a) => {
83-
const original = picked.find((p) => p.filename === a.filename)
91+
92+
// Validate using a duck-typed candidate shape — RN's Blob polyfill
93+
// rejects ArrayBuffer parts, so we cannot synthesise File objects
94+
// here. The validator only reads name/size/type from candidates.
95+
const result = validateAttachments(
96+
picked.map((a) => ({ name: a.filename, size: a.size, type: a.mime })),
97+
attachments,
98+
DEFAULT_ATTACHMENT_LIMITS,
99+
)
100+
// Reattach the real picker output onto the validated Attachments. We
101+
// pair by index because filenames can collide (two screenshots from
102+
// the camera roll often share a name).
103+
const accepted = result.accepted.map((a, i) => {
104+
const original = picked[i]
84105
if (!original) return a
85106
return { ...a, blob: original.blob, previewUrl: original.previewUrl }
86107
})
87108
setAttachments((prev) => [...prev, ...accepted])
88-
setAttachmentErrors(result.rejected.map((r) => `${r.filename}: ${r.reason.replace("-", " ")}`))
109+
setAttachmentErrors(result.rejected.map((r) => `${r.filename}: ${r.reason.replace(/-/g, " ")}`))
89110
}
90111

91112
function handleAttachmentRemove(id: string) {
@@ -158,7 +179,10 @@ export function WizardSheet({
158179
lines.push({ label: "Console, network & breadcrumbs" })
159180
lines.push({ label: "Device & environment info" })
160181
if (attachments.length > 0) {
161-
lines.push({ label: "Additional attachments", hint: String(attachments.length) })
182+
lines.push({
183+
label: "Additional attachments",
184+
hint: String(attachments.length),
185+
})
162186
}
163187
return lines
164188
}, [title, screenshot, shapes.length, attachments.length])
@@ -286,6 +310,12 @@ export function WizardSheet({
286310
</View>
287311
</KeyboardAvoidingView>
288312
</SafeAreaView>
313+
<SourcePicker
314+
visible={sourcePickerVisible}
315+
hasClipboardImage={hasClipboardImage}
316+
onSelect={handleSourceSelected}
317+
onCancel={() => setSourcePickerVisible(false)}
318+
/>
289319
</Modal>
290320
)
291321
}

0 commit comments

Comments
 (0)