diff --git a/apps/mobile/src/DateTimeEditor.tsx b/apps/mobile/src/DateTimeEditor.tsx index 92e78ee7a..898389aab 100644 --- a/apps/mobile/src/DateTimeEditor.tsx +++ b/apps/mobile/src/DateTimeEditor.tsx @@ -3,6 +3,13 @@ import { Text, View } from "react-native"; import DateFields from "./DateFields"; import { isCompleteInstant, localDateTime, zonedInstant } from "./date-time"; import { colors, s } from "./ui"; + +// An unknown time zone surfaces as Intl's RangeError; the other errors are already readable. +function editorError(e: unknown) { + if (e instanceof RangeError) return "Choose a valid time zone."; + return e instanceof Error ? e.message : String(e); +} + export default function DateTimeEditor({ label, value, @@ -23,13 +30,19 @@ export default function DateTimeEditor({ try { if (allDay) { setDate(value.slice(0, 10)); + setError(""); } else if (isCompleteInstant(value)) { const local = localDateTime(value, timeZone); setDate(local.date); setTime(local.time); + setError(""); + } else if (value === `${date} ${time}`) { + // A date or time entered while the zone was invalid; convert it now the zone changed. + onChange(zonedInstant(date, time, timeZone)); + setError(""); } - } catch { - setError("Choose a valid time zone."); + } catch (e) { + setError(editorError(e)); } }, [value, timeZone, allDay]); function change(nextDate: string, nextTime: string) { @@ -44,7 +57,7 @@ export default function DateTimeEditor({ onChange(zonedInstant(nextDate, nextTime, timeZone)); } catch (e) { onChange(`${nextDate} ${nextTime}`); - setError(e instanceof Error ? e.message : String(e)); + setError(editorError(e)); } } return ( diff --git a/apps/mobile/src/api-response.ts b/apps/mobile/src/api-response.ts index d6e8b017d..d9a9a3e3b 100644 --- a/apps/mobile/src/api-response.ts +++ b/apps/mobile/src/api-response.ts @@ -22,3 +22,14 @@ export async function readApiPayload(response: Response, fallback?: string): throw new Error(`The server returned an unreadable response (${response.status}).`); return payload as T; } + +/** + * Same as readApiPayload, for the native upload and download helpers, which + * return the status and body text instead of a Response. + */ +export function readNativePayload( + result: { status: number; body: string }, + fallback?: string, +): Promise { + return readApiPayload(new Response(result.body, { status: result.status }), fallback); +} diff --git a/apps/mobile/src/details.tsx b/apps/mobile/src/details.tsx index 8751ff921..e7c9907a7 100644 --- a/apps/mobile/src/details.tsx +++ b/apps/mobile/src/details.tsx @@ -33,6 +33,7 @@ import { type ProposalInput, } from "../../../packages/domain/src"; import { DelegateSheet, NotificationsSheet, TaskDetail } from "./agent-ui"; +import { readNativePayload } from "./api-response"; import BrowserConsole from "./BrowserConsole"; import { browserAddress, browserSite } from "./browser-address"; import { ComputerSheet } from "./computer"; @@ -777,9 +778,17 @@ function FileDetail({ file: f }: { file: Artifact }) { return; } const target = `${FileSystem.cacheDirectory}${f.id}.pdf`; - await FileSystem.downloadAsync(url, target, { + const download = await FileSystem.downloadAsync(url, target, { headers: { Authorization: `Bearer ${api.token}` }, }); + if (download.status < 200 || download.status >= 300) { + // downloadAsync saves an error response to the file instead of failing, so read + // the server's message from it and never offer that file as a PDF. + const body = await FileSystem.readAsStringAsync(target).catch(() => ""); + await FileSystem.deleteAsync(target, { idempotent: true }); + // Always throws here, with the server's message when it sent one. + await readNativePayload({ status: download.status, body }, "Could not download this PDF."); + } if (await Sharing.isAvailableAsync()) await Sharing.shareAsync(target, { mimeType: "application/pdf", UTI: "com.adobe.pdf" }); else throw new Error("Sharing is not available on this device."); diff --git a/apps/mobile/src/screens.tsx b/apps/mobile/src/screens.tsx index 5f2345a84..5d56f3921 100644 --- a/apps/mobile/src/screens.tsx +++ b/apps/mobile/src/screens.tsx @@ -39,6 +39,7 @@ import type { EmailDraft, } from "../../../packages/domain/src"; import { API_URL } from "./api"; +import { readNativePayload } from "./api-response"; import { browserAddress } from "./browser-address"; import { localDateTime, startOfZonedDay, zonedInstant } from "./date-time"; import { @@ -964,10 +965,7 @@ export function FilesScreen() { mimeType: "application/pdf", headers: { Authorization: `Bearer ${api.token}` }, }); - const payload = JSON.parse(result.body); - if (result.status < 200 || result.status >= 300) - throw new Error(payload.error || "Could not import this PDF."); - artifact = payload; + artifact = await readNativePayload(result, "Could not import this PDF."); } await refresh(); open({ type: "file", file: artifact }); diff --git a/apps/mobile/test/api-response.test.ts b/apps/mobile/test/api-response.test.ts index cd5ebb24f..63e311dcd 100644 --- a/apps/mobile/test/api-response.test.ts +++ b/apps/mobile/test/api-response.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { readApiPayload } from "../src/api-response.ts"; +import { readApiPayload, readNativePayload } from "../src/api-response.ts"; test("JSON responses pass through and server errors keep their message", async () => { assert.deepEqual( @@ -52,3 +52,26 @@ test("successful responses without a readable body fail clearly", async () => { ); await assert.rejects(readApiPayload(new Response("OK", { status: 200 })), /unreadable response/); }); + +test("native upload and download results report the real failure", async () => { + assert.deepEqual( + await readNativePayload({ status: 201, body: JSON.stringify({ id: "file-1" }) }), + { id: "file-1" }, + ); + await assert.rejects( + readNativePayload( + { status: 413, body: JSON.stringify({ error: "PDF is too large" }) }, + "Could not import this PDF.", + ), + /PDF is too large/, + ); + for (const body of ["Bad Gateway", ""]) { + const error = await readNativePayload( + { status: 502, body }, + "Could not import this PDF.", + ).catch((e: unknown) => e); + assert.ok(error instanceof Error); + assert.equal(error.message, "Could not import this PDF."); + assert.equal(error instanceof SyntaxError, false); + } +});