From 15e40563cf8934bf31747a9ccdde94652d070b09 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Fri, 11 Sep 2026 22:19:09 +0500 Subject: [PATCH] Add attachments from camera, photo library and files Closes #9. Uploads bypass the generated client: Orval emits a Blob body, and React Native has no real Blob to give it -- a picked file is a file:// URI the native layer streams itself, which is what `{uri, name, type}` in FormData expresses. That also gives a progress callback, which reading the file into memory first would not. The server's allowed list is mirrored client-side so a phone refuses a file before spending a minute uploading something that was never going to land, and the SVG message says *why* it is refused -- it is a document that can carry script, so a generic "not allowed" would read as an oversight rather than a decision. Attachment bytes are authenticated, so images are fetched with the bearer token in a header rather than the query string, where it would end up in whatever logs the instance keeps. A plain image source would simply 401 -- there is a test that pins exactly that. Two corrections from checking rather than assuming: a disallowed extension is 415, and a file whose bytes do not match its name is 422 with the server naming the format. Both now assert what the server actually does. Co-Authored-By: Claude Opus 5 --- app.json | 8 + package-lock.json | 74 ++++- package.json | 4 + src/api/__tests__/live-attachments.test.ts | 139 ++++++++++ src/attachments/__tests__/rules.test.ts | 58 ++++ src/attachments/attachments-section.tsx | 304 +++++++++++++++++++++ src/attachments/rules.ts | 81 ++++++ src/attachments/upload.ts | 67 +++++ src/issues/issue-detail-screen.tsx | 3 + 9 files changed, 728 insertions(+), 10 deletions(-) create mode 100644 src/api/__tests__/live-attachments.test.ts create mode 100644 src/attachments/__tests__/rules.test.ts create mode 100644 src/attachments/attachments-section.tsx create mode 100644 src/attachments/rules.ts create mode 100644 src/attachments/upload.ts diff --git a/app.json b/app.json index 200a10a..ba0b969 100644 --- a/app.json +++ b/app.json @@ -44,6 +44,14 @@ "backgroundColor": "#0c0b15" } } + ], + "expo-image", + [ + "expo-image-picker", + { + "photosPermission": "SoftTrack uses your photo library to attach screenshots to issues.", + "cameraPermission": "SoftTrack uses the camera to attach a photo to an issue." + } ] ], "experiments": { diff --git a/package-lock.json b/package-lock.json index c902d9e..1db268b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,10 @@ "expo": "~57.0.22", "expo-blur": "~57.0.3", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", @@ -8103,6 +8107,25 @@ "react-native": "*" } }, + "node_modules/expo-document-picker": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.2.tgz", + "integrity": "sha512-vVBbBNg0piMJUIxjB+DR6JaZZpcSl90ZUoywLA/EgNHTfRkZW+9axP97UxM2a0Zm0W6WfH3cLzzGAEfP9OKGwg==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-font": { "version": "57.0.4", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", @@ -8128,6 +8151,47 @@ "react-native": "*" } }, + "node_modules/expo-image": { + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-57.0.5.tgz", + "integrity": "sha512-jOm5lofaIKXhQ7i0Hz3I5JuNULw/aghmpIPZXddwQcEtw8+wUhlozq3M7LbsOrcsBEapcG4jFs85DsfuFoz1tQ==", + "license": "MIT", + "dependencies": { + "sf-symbols-typescript": "^2.2.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, + "node_modules/expo-image-loader": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-57.0.1.tgz", + "integrity": "sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-57.0.17.tgz", + "integrity": "sha512-qPcCkGSoqDLpmdXCLiLe09Je01gzBrwEgLd4SBI03Od/5t531vDTkzUmGemjm7vcsCTG2iqZMh2VZXBscAPO3g==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~57.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-57.0.2.tgz", @@ -8813,16 +8877,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "57.0.7", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", - "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", diff --git a/package.json b/package.json index 5e91c7d..760153d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,10 @@ "expo": "~57.0.22", "expo-blur": "~57.0.3", "expo-constants": "~57.0.18", + "expo-document-picker": "~57.0.2", + "expo-file-system": "~57.0.7", + "expo-image": "~57.0.5", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", "expo-router": "~57.0.21", diff --git a/src/api/__tests__/live-attachments.test.ts b/src/api/__tests__/live-attachments.test.ts new file mode 100644 index 0000000..54ef82b --- /dev/null +++ b/src/api/__tests__/live-attachments.test.ts @@ -0,0 +1,139 @@ +/** + * @jest-environment node + */ +import Axios from 'axios'; + +import { AXIOS_INSTANCE } from '@/api/client'; +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + listIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, +} from '@/api/generated/endpoints/issues/issues'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { AttachmentRead, TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken, getAccessToken } from '@/auth/session'; + +/** + * Attachments against a real instance. + * + * The acceptance criterion for #9 is that a photo taken on the phone lands on + * the issue and renders. A jest run has no camera, so a real PNG is built in + * memory and posted the same way the app posts one -- what is being checked is + * that multipart upload, the server's type rules, and authenticated reads of + * the bytes all line up. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); + +/** The smallest valid PNG: 1x1, and it really does start with the signature. */ +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64', +); + +async function postFile(issueId: number, filename: string, bytes: Buffer, type: string) { + const form = new FormData(); + form.append('file', new Blob([new Uint8Array(bytes)], { type }), filename); + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + ); + return data; +} + +describeLive('attachments against a live instance', () => { + let team: TeamRead; + let issueId: number; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await loginAuthLoginPost({ + username: process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev', + password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', + }); + await persistToken(token.access_token); + team = (await listMyTeamsTeamsGet())[0]; + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Attachments ${RUN}`, + }); + issueId = issue.id; + }); + + afterAll(async () => { + await deleteIssueIssuesIssueIdDelete(issueId).catch(() => undefined); + }); + + it('accepts an image and marks it as one', async () => { + const attachment = await postFile(issueId, `shot-${RUN}.png`, PNG_1X1, 'image/png'); + + expect(attachment.is_image).toBe(true); + expect(attachment.content_type).toBe('image/png'); + expect(attachment.size_bytes).toBe(PNG_1X1.length); + // is_image is what decides between rendering inline and offering to open. + expect(attachment.url).toContain(String(attachment.id)); + + const listed = await listIssueAttachmentsIssuesIssueIdAttachmentsGet(issueId); + expect(listed.map((a) => a.id)).toContain(attachment.id); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('serves the bytes only with the bearer token', async () => { + const attachment = await postFile(issueId, `auth-${RUN}.png`, PNG_1X1, 'image/png'); + const base = LIVE_URL!; + const path = `/attachments/${attachment.id}/content`; + + // With the header the app sends, which is why an image source carries it + // rather than relying on a plain URL. + const ok = await AXIOS_INSTANCE.get(path, { responseType: 'arraybuffer' }); + expect(ok.status).toBe(200); + expect(Buffer.from(ok.data).subarray(0, 8)).toEqual(PNG_1X1.subarray(0, 8)); + + // And without it: a bare client, the way a plain would ask. + expect(getAccessToken()).not.toBeNull(); + const anonymous = Axios.create({ baseURL: base }); + await expect(anonymous.get(path)).rejects.toMatchObject({ + response: { status: 401 }, + }); + + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); + + it('refuses an SVG, which is why the client refuses it first', async () => { + // 415 for a type that is not on the list at all. + await expect( + postFile(issueId, `diagram-${RUN}.svg`, Buffer.from(''), 'image/svg+xml'), + ).rejects.toMatchObject({ response: { status: 415 } }); + }); + + it('refuses a file whose bytes are not the image its name claims', async () => { + // "Screenshot of the bug" is the whole point, so a mislabelled file fails + // now with a clear error rather than later as a broken image. + // 422 rather than 415: the extension is allowed, the bytes are not what it + // claims, and the server says exactly that. + await expect( + postFile(issueId, `fake-${RUN}.png`, Buffer.from('not a png at all'), 'image/png'), + ).rejects.toMatchObject({ + response: { status: 422, data: { detail: expect.stringContaining('not a valid PNG') } }, + }); + }); + + it('takes a non-image and does not mark it as one', async () => { + const attachment = await postFile( + issueId, + `notes-${RUN}.txt`, + Buffer.from('a log line'), + 'text/plain', + ); + expect(attachment.is_image).toBe(false); + expect(attachment.content_type).toBe('text/plain'); + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + }); +}); diff --git a/src/attachments/__tests__/rules.test.ts b/src/attachments/__tests__/rules.test.ts new file mode 100644 index 0000000..39a561e --- /dev/null +++ b/src/attachments/__tests__/rules.test.ts @@ -0,0 +1,58 @@ +import { contentTypeFor, formatBytes, MAX_BYTES, rejectionFor } from '@/attachments/rules'; +import { filenameFrom } from '@/attachments/upload'; + +describe('contentTypeFor', () => { + it('accepts what the server accepts', () => { + expect(contentTypeFor('shot.PNG')).toBe('image/png'); + expect(contentTypeFor('notes.md')).toBe('text/markdown'); + expect(contentTypeFor('fix.patch')).toBe('text/plain'); + expect(contentTypeFor('clip.mov')).toBe('video/quicktime'); + }); + + it('refuses what it does not', () => { + expect(contentTypeFor('page.html')).toBeNull(); + expect(contentTypeFor('noextension')).toBeNull(); + }); +}); + +describe('rejectionFor', () => { + it('says why an SVG is refused rather than just refusing it', () => { + // The server leaves SVG out deliberately: it is a document that can carry + // script. A generic "not allowed" would read as an oversight. + const rejection = rejectionFor('diagram.svg', 1000); + expect(rejection?.reason).toBe('type'); + expect(rejection?.message).toContain('script'); + }); + + it('names the file and the limit when it is too big', () => { + const rejection = rejectionFor('huge.zip', MAX_BYTES + 1); + expect(rejection?.reason).toBe('size'); + expect(rejection?.message).toContain('huge.zip'); + expect(rejection?.message).toContain('25.0 MB'); + }); + + it('passes a file the server would take', () => { + expect(rejectionFor('shot.png', 500_000)).toBeNull(); + // Unknown size is not a reason to refuse; the server has the final say. + expect(rejectionFor('shot.png', undefined)).toBeNull(); + }); +}); + +describe('filenameFrom', () => { + it('keeps the extension the server validates on', () => { + expect(filenameFrom('file:///var/tmp/ABC123.jpg')).toBe('ABC123.jpg'); + expect(filenameFrom('file:///tmp/my%20shot.png')).toBe('my shot.png'); + }); + + it('invents a name with an extension when the camera gives none', () => { + expect(filenameFrom('file:///var/tmp/ABC123')).toMatch(/^upload-\d+\.jpg$/); + }); +}); + +describe('formatBytes', () => { + it('reads the way a person would say it', () => { + expect(formatBytes(512)).toBe('512 B'); + expect(formatBytes(2048)).toBe('2 KB'); + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); diff --git a/src/attachments/attachments-section.tsx b/src/attachments/attachments-section.tsx new file mode 100644 index 0000000..06391f7 --- /dev/null +++ b/src/attachments/attachments-section.tsx @@ -0,0 +1,304 @@ +import { useState } from 'react'; +import { Linking, Modal, Pressable, View } from 'react-native'; +import { Image } from 'expo-image'; +import * as DocumentPicker from 'expo-document-picker'; +import * as ImagePicker from 'expo-image-picker'; +import { useQueryClient } from '@tanstack/react-query'; + +import { getInstanceUrl } from '@/api/instance'; +import { + deleteAttachmentAttachmentsAttachmentIdDelete, + useListIssueAttachmentsIssuesIssueIdAttachmentsGet, +} from '@/api/generated/endpoints/attachments/attachments'; +import type { AttachmentRead, IssueRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { getAccessToken } from '@/auth/session'; +import { formatBytes, rejectionFor } from '@/attachments/rules'; +import { filenameFrom, uploadAttachment } from '@/attachments/upload'; +import { Section } from '@/issues/detail/sections'; +import { Icon } from '@/ui/icon'; +import { Alert, AppText, Card, Loading } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; +import { useTokens } from '@/ui/theme'; + +/** + * The URL an attachment's bytes come from, with the bearer token attached. + * + * Attachment content is authenticated, so a plain image source would 401. The + * token goes in a header rather than the query string -- it would otherwise end + * up in any log the instance keeps. + */ +function imageSource(attachment: AttachmentRead) { + const base = getInstanceUrl() ?? ''; + const token = getAccessToken(); + return { + uri: `${base}/attachments/${attachment.id}/content`, + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + }; +} + +export function AttachmentsSection({ issue }: { issue: IssueRead }) { + const t = useTokens(); + const queryClient = useQueryClient(); + const attachments = useListIssueAttachmentsIssuesIssueIdAttachmentsGet(issue.id); + + const [picking, setPicking] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(null); + const [retry, setRetry] = useState<{ uri: string; filename: string } | null>(null); + const [viewing, setViewing] = useState(null); + + const items = attachments.data ?? []; + + async function upload(uri: string, filename: string) { + setError(null); + setRetry(null); + + const rejection = rejectionFor(filename, undefined); + if (rejection) { + // Refused before spending a minute uploading something that was never + // going to land. + setError(rejection.message); + return; + } + + setProgress(0); + try { + await uploadAttachment({ + issueId: issue.id, + uri, + filename, + onProgress: setProgress, + }); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, `Could not upload ${filename}.`)); + setRetry({ uri, filename }); + } finally { + setProgress(null); + } + } + + async function fromCamera() { + setPicking(false); + const permission = await ImagePicker.requestCameraPermissionsAsync(); + if (!permission.granted) { + setError('SoftTrack needs camera access to attach a photo.'); + return; + } + const result = await ImagePicker.launchCameraAsync({ quality: 0.8 }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromLibrary() { + setPicking(false); + const result = await ImagePicker.launchImageLibraryAsync({ + quality: 0.8, + mediaTypes: ['images', 'videos'], + }); + const asset = result.assets?.[0]; + if (!asset) return; + await upload(asset.uri, asset.fileName ?? filenameFrom(asset.uri)); + } + + async function fromFiles() { + setPicking(false); + const result = await DocumentPicker.getDocumentAsync({ copyToCacheDirectory: true }); + const asset = result.assets?.[0]; + if (!asset) return; + + // The file picker knows the size, so an oversized file is caught here + // rather than by the server after the whole upload. + const rejection = rejectionFor(asset.name, asset.size); + if (rejection) { + setError(rejection.message); + return; + } + await upload(asset.uri, asset.name); + } + + return ( +
setPicking(true)} + > + + + } + > + {error ? {error} : null} + + {retry ? ( + void upload(retry.uri, retry.filename)} + style={{ + paddingVertical: 10, + alignItems: 'center', + borderRadius: t.radius.control, + backgroundColor: t.line.well, + }} + > + + Retry {retry.filename} + + + ) : null} + + {progress !== null ? ( + + Uploading… {Math.round(progress * 100)}% + + + + + ) : null} + + {attachments.isPending ? : null} + + {items.length > 0 ? ( + + {items.map((attachment) => ( + + {attachment.is_image ? ( + setViewing(attachment)} + > + + + ) : null} + + + + + {attachment.filename} + + + {formatBytes(attachment.size_bytes)} · {attachment.uploaded_by.full_name} + + + + {!attachment.is_image ? ( + { + const base = getInstanceUrl() ?? ''; + void Linking.openURL(`${base}/attachments/${attachment.id}/content`); + }} + > + + Open + + + ) : null} + + { + setError(null); + try { + await deleteAttachmentAttachmentsAttachmentIdDelete(attachment.id); + await queryClient.invalidateQueries({ + queryKey: [`/issues/${issue.id}/attachments`], + }); + } catch (err) { + setError(errorDetail(err, 'Could not delete that attachment.')); + } + }} + > + + + + + ))} + + ) : !attachments.isPending ? ( + + Nothing attached yet. + + ) : null} + + setPicking(false)} title="Attach"> + + {[ + { label: 'Take a photo', run: fromCamera }, + { label: 'Photo library', run: fromLibrary }, + { label: 'Files', run: fromFiles }, + ].map((option) => ( + void option.run()} + style={{ + paddingVertical: 14, + paddingHorizontal: 10, + borderRadius: t.radius.control, + }} + > + {option.label} + + ))} + + + + setViewing(null)} + supportedOrientations={['portrait', 'landscape']} + > + setViewing(null)} + style={{ + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + alignItems: 'center', + justifyContent: 'center', + }} + > + {viewing ? ( + + ) : null} + + +
+ ); +} diff --git a/src/attachments/rules.ts b/src/attachments/rules.ts new file mode 100644 index 0000000..f0c53b8 --- /dev/null +++ b/src/attachments/rules.ts @@ -0,0 +1,81 @@ +/** + * What the server will accept, mirrored so a phone can say no before spending + * a minute uploading something that was never going to land. + * + * Kept in sync by hand with `backend/lib_softtrack/attachments.py` -- the + * allowed list is not in the OpenAPI schema, so there is nothing to generate + * it from. + */ + +/** `.svg` is deliberately absent: it is a document that can carry script. */ +export const ALLOWED_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.log': 'text/plain', + '.md': 'text/markdown', + '.csv': 'text/csv', + '.json': 'application/json', + '.patch': 'text/plain', + '.diff': 'text/plain', + '.zip': 'application/zip', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mov': 'video/quicktime', +}; + +/** The default cap; an instance can lower it, and the server has final say. */ +export const MAX_BYTES = 25 * 1024 * 1024; + +export function extensionOf(filename: string): string { + const dot = filename.lastIndexOf('.'); + return dot === -1 ? '' : filename.slice(dot).toLowerCase(); +} + +export function contentTypeFor(filename: string): string | null { + return ALLOWED_TYPES[extensionOf(filename)] ?? null; +} + +export type Rejection = { reason: 'type' | 'size'; message: string }; + +/** + * Why this file cannot be uploaded, or `null` if it can. + * + * Names the file rather than stating a rule in the abstract, because the person + * picking it is looking at a gallery of a hundred things. + */ +export function rejectionFor( + filename: string, + sizeBytes: number | undefined, + maxBytes = MAX_BYTES, +): Rejection | null { + if (contentTypeFor(filename) === null) { + const extension = extensionOf(filename); + return { + reason: 'type', + message: + extension === '.svg' + ? 'SVG files are not accepted, because they can carry script.' + : `${extension || 'That file type'} is not accepted. Try an image, PDF, text, CSV, JSON, zip or video file.`, + }; + } + + if (sizeBytes !== undefined && sizeBytes > maxBytes) { + return { + reason: 'size', + message: `${filename} is ${formatBytes(sizeBytes)}, over the ${formatBytes(maxBytes)} limit.`, + }; + } + + return null; +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/attachments/upload.ts b/src/attachments/upload.ts new file mode 100644 index 0000000..7520fa2 --- /dev/null +++ b/src/attachments/upload.ts @@ -0,0 +1,67 @@ +import { AXIOS_INSTANCE } from '@/api/client'; +import type { AttachmentRead } from '@/api/generated/models'; +import { contentTypeFor } from '@/attachments/rules'; + +/** + * Uploading a file the API expects as multipart. + * + * Not routed through the generated client: Orval emits a `Blob` body for the + * upload, and React Native has no real Blob to give it -- a file is a `file://` + * URI that the native networking layer streams itself. Handing axios a + * `FormData` with `{uri, name, type}` is how that is expressed, and it also + * gives a progress callback, which reading the whole file into memory first + * would not. + */ +export type UploadProgress = (fraction: number) => void; + +export async function uploadAttachment({ + issueId, + uri, + filename, + onProgress, + signal, +}: { + issueId: number; + uri: string; + filename: string; + onProgress?: UploadProgress; + signal?: AbortSignal; +}): Promise { + const form = new FormData(); + form.append('file', { + uri, + name: filename, + // The server derives the type from the extension anyway, but sending the + // right one avoids a generic octet-stream round trip. + type: contentTypeFor(filename) ?? 'application/octet-stream', + } as unknown as Blob); + + const { data } = await AXIOS_INSTANCE.post( + `/issues/${issueId}/attachments`, + form, + { + signal, + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: (event) => { + if (!onProgress) return; + // `total` is absent on some platforms; an indeterminate bar is better + // than a bar that jumps to 100% and sits there. + if (event.total) onProgress(Math.min(1, event.loaded / event.total)); + }, + }, + ); + return data; +} + +/** + * A filename for something the picker gave no name to. + * + * The camera hands back a `file://.../ABC123.jpg` with no display name, and the + * extension is what the server validates on, so it has to survive. + */ +export function filenameFrom(uri: string, fallbackExtension = '.jpg'): string { + const withoutQuery = uri.split('?')[0]; + const last = withoutQuery.split('/').pop() ?? ''; + if (last.includes('.')) return decodeURIComponent(last); + return `upload-${Date.now()}${fallbackExtension}`; +} diff --git a/src/issues/issue-detail-screen.tsx b/src/issues/issue-detail-screen.tsx index 241dc16..df5e508 100644 --- a/src/issues/issue-detail-screen.tsx +++ b/src/issues/issue-detail-screen.tsx @@ -24,6 +24,7 @@ import { WatchToggle, } from '@/issues/detail/sections'; import { labelFor, useIssueProperties } from '@/issues/use-issue-properties'; +import { AttachmentsSection } from '@/attachments/attachments-section'; import { CommentsSection, mentionablesFrom, openIssueByIdentifier } from '@/comments/comments-section'; import { MarkdownBody } from '@/markdown/markdown'; import { MarkdownEditor } from '@/markdown/markdown-editor'; @@ -312,6 +313,8 @@ export function IssueDetailScreen() { + +