Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions apps/mobile/src/DateTimeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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 (
Expand Down
11 changes: 11 additions & 0 deletions apps/mobile/src/api-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,14 @@ export async function readApiPayload<T>(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<T>(
result: { status: number; body: string },
fallback?: string,
): Promise<T> {
return readApiPayload<T>(new Response(result.body, { status: result.status }), fallback);
}
11 changes: 10 additions & 1 deletion apps/mobile/src/details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.");
Expand Down
6 changes: 2 additions & 4 deletions apps/mobile/src/screens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Artifact>(result, "Could not import this PDF.");
}
await refresh();
open({ type: "file", file: artifact });
Expand Down
25 changes: 24 additions & 1 deletion apps/mobile/test/api-response.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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 ["<html><body>Bad Gateway</body></html>", ""]) {
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);
}
});
Loading