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
3 changes: 2 additions & 1 deletion src/features/direct-messages/NewMessage.recovery.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,8 @@ it("waits for uploads before opening a DM and reports failure only on the chip",
),
).toHaveAttribute("role", "status");
expect(screen.getAllByRole("alert")).toHaveLength(1);
expect(send()).toBeDisabled();
// Focusable while blocked, so its tooltip can say why.
expect(send()).toHaveAttribute("aria-disabled", "true");
expect(t.openDirectMessage).not.toHaveBeenCalled();
expect(t.publish).not.toHaveBeenCalled();
await t.user.click(screen.getByRole("button", { name: "Retry photo.png" }));
Expand Down
13 changes: 10 additions & 3 deletions src/features/messages/ComposerAttachments.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -168,24 +168,31 @@ html[data-keyboard-navigation] .preview:focus-visible {
}

.item[data-upload-error] {
/* Fit the strip when possible, but never squeeze the recovery controls. */
/* The error and its actions each take a full row from the tile's inner
edge, and the icon gap stays the resting tile's. Fit the strip when
possible, but never squeeze the recovery controls. */
min-width: min-content;
flex-wrap: wrap;
column-gap: var(--space-2);
row-gap: var(--space-1);
padding-inline-end: calc(var(--space-5) / 2);
}
.item[data-upload-error] .error {
/* With the row gap, 12px below the file row, as the actions sit 12px
below the error. */
flex-basis: 100%;
margin-block-start: var(--space-2);
}
.item[data-upload-error] .details {
/* Ellipsized filenames must not determine the card's minimum width. */
contain: inline-size;
}
.item[data-upload-error] .actions {
/* As in a toast: 12px from the message, 8px between actions. The actions
never wrap; the tile's min-content width keeps room for both. */
position: static;
flex-basis: 100%;
flex-wrap: wrap;
gap: var(--space-2);
margin-block-start: var(--space-2);
min-width: min-content;
}

Expand Down
9 changes: 7 additions & 2 deletions src/features/messages/ComposerAttachments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,11 @@ it.each([
const removeButton = screen.getByRole("button", { name: `Remove ${name}` });
expect(retryButton.textContent).toBe("Retry");
expect(removeButton.textContent).toBe("Remove");
// Retry first, then Remove.
expect([...(retryButton.parentElement?.children ?? [])]).toEqual([
retryButton,
removeButton,
]);
act(() => retryButton.focus());
expect(retryButton.getAttribute("title")).toBeNull();
fireEvent.click(retryButton);
Expand Down Expand Up @@ -399,7 +404,7 @@ it.each([
expect(screen.getByText(error, { selector: "p" })).toBe(alert);
expect(alert.classList.contains("sr-only")).toBe(false);
expect(
screen.queryByText("Upload failed. Retry or remove it to send."),
screen.queryByText("Upload failed. Retry or remove this file."),
).toBeNull();
});

Expand All @@ -414,7 +419,7 @@ it("explains errors without service details and disables both recovery actions",
<ComposerAttachments {...props} items={[item]} disabled />,
);
expect(screen.getByRole("alert").textContent).toBe(
"photo.png: Upload failed. Retry or remove it to send.",
"photo.png: Upload failed. Retry or remove this file.",
);
for (const action of ["Retry", "Remove"]) {
const button = screen.getByRole("button", { name: `${action} photo.png` });
Expand Down
4 changes: 2 additions & 2 deletions src/features/messages/ComposerAttachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
useDraftAttachmentSource,
} from "./use-draft-attachment-source";
import { XIcon } from "../../shared/design-system/icons";
import type { DraftAttachment } from "./attachment-draft";
import { UPLOAD_FAILED_TEXT, type DraftAttachment } from "./attachment-draft";
import styles from "./ComposerAttachments.module.css";
import { useMediaElementSource } from "./use-media-element-source";

Expand Down Expand Up @@ -417,7 +417,7 @@ function AttachmentItem({
{uploadFailed && (
<p className={styles.error} role="alert">
<span className="sr-only">{item.file.name}: </span>
{item.error || "Upload failed. Retry or remove it to send."}
{item.error || UPLOAD_FAILED_TEXT}
</p>
)}
<div
Expand Down
54 changes: 48 additions & 6 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { createAgentChoices } from "../agents/choices";
import type { LocalAgents } from "../agents/local";
import { localAgentsFixture } from "../agents/local-testing";
import { UploadError, UPLOAD_FAILURES } from "../relay/attachments";
import { UPLOAD_FAILED_TEXT } from "./attachment-draft";
import { ToastProvider } from "../../shared/design-system/ui/Toast";
import type { Outbox, OutgoingEvent } from "../relay/outbox";
import { createMessages } from "../relay/messages";
Expand Down Expand Up @@ -2145,6 +2146,46 @@ it("does not focus after a picker upload fails or is later retried", async () =>
expect(trigger).toHaveFocus();
});

it("words a failed upload and explains the disabled Send, until the file is removed", async () => {
const h = await mountUploadComposer();
pickAttachments(h.container, [attachmentFile()]);
await waitFor(() => expect(h.uploadCalls).toHaveLength(1));
await act(() =>
h.uploadCalls[0]?.result.reject(new TypeError("Failed to fetch")),
);
expect(screen.getByRole("alert")).toHaveTextContent(
"Upload didn’t finish. Check your connection, then retry.",
);
expect(screen.queryByText(/Failed to fetch/)).toBeNull();
// A disabled button gets no hover from the browser, so Send stays focusable
// and its tooltip says what to do.
const send = screen.getByRole("button", { name: "Send message" });
expect(send).toHaveAttribute("aria-disabled", "true");
await userEvent.hover(send);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Retry or remove the failed file to send.",
);
await userEvent.unhover(send);
// Focusable is not enabled: neither Send nor Enter sends or uploads again.
fireEvent.change(h.input(), { target: { value: "With the file" } });
fireEvent.click(send);
fireEvent.keyDown(h.input(), { key: "Enter" });
await act(async () => {});
expect(h.sign).not.toHaveBeenCalled();
expect(h.publish).not.toHaveBeenCalled();
expect(h.uploadCalls).toHaveLength(1);
expect(h.input()).toHaveValue("With the file");
expect(screen.getByRole("alert")).toHaveTextContent(
"Upload didn’t finish. Check your connection, then retry.",
);
fireEvent.click(screen.getByRole("button", { name: "Remove notes.txt" }));
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Send message" }),
).not.toHaveAttribute("aria-disabled", "true"),
);
});

it.each(["inactive", "hidden"])(
"cancels picker focus when the conversation becomes %s, including a warm return",
async (state) => {
Expand Down Expand Up @@ -3000,21 +3041,22 @@ it("offers message preparation retry after a failed attachment send remounts", a
});

it("identifies failed filenames through retry and removal when service errors match", async () => {
const NETWORK = "Upload didn’t finish. Check your connection, then retry.";
const h = await mountUploadComposer();
attachByPaste(h.input(), attachmentFile("one.txt"));
attachByPaste(h.input(), attachmentFile("two.txt"));
await waitFor(() => expect(h.uploadCalls).toHaveLength(1));
await act(async () => {
h.uploadCalls[0]?.result.reject(new Error("HTTP 503"));
h.uploadCalls[0]?.result.reject(new TypeError("Failed to fetch"));
});
await waitFor(() => expect(h.uploadCalls).toHaveLength(2));
await act(async () => {
h.uploadCalls[1]?.result.reject(new Error("HTTP 503"));
h.uploadCalls[1]?.result.reject(new TypeError("Failed to fetch"));
});
await waitFor(() =>
expect(
screen.getAllByRole("alert").map((node) => node.textContent),
).toEqual(["one.txt: HTTP 503", "two.txt: HTTP 503"]),
).toEqual([`one.txt: ${NETWORK}`, `two.txt: ${NETWORK}`]),
);
await userEvent.click(screen.getByRole("button", { name: "Retry one.txt" }));
await waitFor(() => expect(h.uploadCalls).toHaveLength(3));
Expand All @@ -3024,12 +3066,12 @@ it("identifies failed filenames through retry and removal when service errors ma
await waitFor(() =>
expect(
screen.getAllByRole("alert").map((node) => node.textContent),
).toEqual(["one.txt: first failed again", "two.txt: HTTP 503"]),
).toEqual([`one.txt: ${UPLOAD_FAILED_TEXT}`, `two.txt: ${NETWORK}`]),
);
await userEvent.click(screen.getByRole("button", { name: "Remove one.txt" }));
expect(h.input()).toHaveFocus();
expect(screen.getAllByRole("alert").map((node) => node.textContent)).toEqual([
"two.txt: HTTP 503",
`two.txt: ${NETWORK}`,
]);
await waitFor(() =>
expect(within(h.form()).queryByText("one.txt")).not.toBeInTheDocument(),
Expand Down Expand Up @@ -3057,7 +3099,7 @@ it("retains successful attachment uploads after a later file fails and retries o
});
await screen.findAllByRole("alert");
expect(screen.getAllByRole("alert").map((node) => node.textContent)).toEqual([
"retry.txt: relay down",
`retry.txt: ${UPLOAD_FAILED_TEXT}`,
]);
expect(
screen.getByRole("dialog", { name: "Upload to #General failed" }),
Expand Down
8 changes: 6 additions & 2 deletions src/features/messages/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3855,10 +3855,14 @@ function Composer({
? "Wait for compaction to finish, or interrupt it."
: untagged
? recipientHint
: "Send message"
: attachments.blocked
? "Retry or remove the failed file to send."
: "Send message"
}
focusableWhenDisabled={
untagged || (!editing.target && compacting) || undefined
untagged ||
(!editing.target && (compacting || attachments.blocked)) ||
undefined
}
disabled={
disabled ||
Expand Down
128 changes: 123 additions & 5 deletions src/features/messages/attachment-draft.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@ import {
import { assert, afterEach, expect, it, vi } from "vitest";
import { ComposerAttachments } from "./ComposerAttachments";
import type { RelaySession } from "../relay/session";
import type { UploadedAttachment } from "../relay/attachments";
import {
UPLOAD_FAILURES,
UploadError,
type UploadedAttachment,
} from "../relay/attachments";
import {
UPLOAD_FAILED_TEXT,
useAttachmentDraft,
attachmentDraft,
transferAttachmentDraft,
Expand Down Expand Up @@ -144,19 +149,132 @@ it("preserves real upload errors while resuming unfinished siblings on return",
first.result.current.store.add([file("failed.txt"), file("pending.txt")]),
);
await waitFor(() => expect(h.calls).toHaveLength(1));
await act(async () => h.calls[0]?.result.reject(new Error("relay down")));
await act(async () => h.calls[0]?.result.reject(new UploadError("failed")));
await waitFor(() => expect(h.calls).toHaveLength(2));
first.unmount();
const returned = renderHook(() =>
useAttachmentDraft(h.session, "one", "one"),
);
await waitFor(() => expect(h.calls).toHaveLength(3));
expect(returned.result.current.items[0]?.error).toBe("relay down");
expect(returned.result.current.items[0]?.error).toBe(UPLOAD_FAILURES.failed);
expect(returned.result.current.items[0]?.status).toBe("error");
expect(returned.result.current.blocked).toBe(true);
expect(h.calls[2]?.file.name).toBe("pending.txt");
});

it.each([
[
"a fetch network failure",
() => new TypeError("Failed to fetch"),
"Upload didn’t finish. Check your connection, then retry.",
],
[
"the upload timeout",
// jsdom's DOMException is not an Error; browsers' is, with this name.
() =>
Object.assign(new Error("signal timed out"), { name: "TimeoutError" }),
"Upload took too long. Retry, or remove this file.",
],
[
"a relay refusal the client already worded",
() => new UploadError("capacity"),
UPLOAD_FAILURES.capacity,
],
["an unrecognised error", () => new Error("relay down"), UPLOAD_FAILED_TEXT],
["a native string", () => "Something native broke", UPLOAD_FAILED_TEXT],
])(
"words %s for the tile, never with the raw text",
async (_name, make, text) => {
const h = fixture();
const draft = renderHook(() => useAttachmentDraft(h.session, "one", "one"));
act(() => draft.result.current.store.add([file()]));
await waitFor(() => expect(h.calls).toHaveLength(1));
await act(async () => h.calls[0]?.result.reject(make()));
expect(draft.result.current.items[0]).toMatchObject({
status: "error",
error: text,
});
expect(draft.result.current.blocked).toBe(true);
// Retry still starts a fresh upload.
act(() =>
draft.result.current.store.retry(draft.result.current.items[0]?.id ?? ""),
);
await waitFor(() => expect(h.calls).toHaveLength(2));
},
);

const bitmap = (width: number, height: number) => async () => ({
width,
height,
close() {},
});
it.each([
[
"the pixel limit",
bitmap(5001, 5000),
"Choose an image with at most 25 million pixels.",
],
[
"a browser without a canvas context",
bitmap(10, 10),
"Image preparation is unavailable in this browser.",
],
[
"an image the browser cannot decode",
async () => {
throw Object.assign(new Error("The source image could not be decoded."), {
name: "InvalidStateError",
});
},
UPLOAD_FAILED_TEXT,
],
])(
"words a preparation refusal from %s for the tile",
async (_name, decode, text) => {
vi.stubGlobal("createImageBitmap", decode);
const context = vi
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue(null);
try {
// The fixture supplies no `prepare`, so the draft's own preparation runs.
const h = fixture();
const draft = renderHook(() =>
useAttachmentDraft(h.session, "one", "one"),
);
const photo = new File(
[new Uint8Array([0xff, 0xd8, 0xff, 0xe0])],
"photo.jpg",
{ type: "image/jpeg" },
) as unknown as globalThis.File;
act(() => draft.result.current.store.add([photo]));
await waitFor(() =>
expect(draft.result.current.items[0]?.status).toBe("error"),
);
expect(draft.result.current.items[0]?.error).toBe(text);
expect(draft.result.current.blocked).toBe(true);
expect(h.upload).not.toHaveBeenCalled();
} finally {
context.mockRestore();
vi.unstubAllGlobals();
}
},
);

it("does not mark a cancelled upload as failed", async () => {
const h = fixture();
const draft = renderHook(() => useAttachmentDraft(h.session, "one", "one"));
act(() => draft.result.current.store.add([file()]));
await waitFor(() => expect(h.calls).toHaveLength(1));
act(() =>
draft.result.current.store.remove(draft.result.current.items[0]?.id ?? ""),
);
await act(async () =>
h.calls[0]?.result.reject(new DOMException("aborted", "AbortError")),
);
expect(draft.result.current.items).toEqual([]);
expect(draft.result.current.blocked).toBe(false);
});

it("ignores a host progress report after an upload fails", async () => {
const h = fixture();
const draft = renderHook(() => useAttachmentDraft(h.session, "one", "one"));
Expand Down Expand Up @@ -595,13 +713,13 @@ it("rejects eager upload errors until explicit Retry, including after Send hando
const draft = renderHook(() => useAttachmentDraft(h.session, "one", "one"));
act(() => draft.result.current.store.add([file()]));
await waitFor(() => expect(h.calls).toHaveLength(1));
await act(async () => h.calls[0]?.result.reject(new Error("relay down")));
await act(async () => h.calls[0]?.result.reject(new UploadError("failed")));
expect(draft.result.current.blocked).toBe(true);
const target = attachmentDraft(h.session, "send", "one");
act(() => transferAttachmentDraft(draft.result.current.store, target));
await expect(
target.prepareForSend(new AbortController().signal),
).rejects.toThrow("relay down");
).rejects.toThrow(UPLOAD_FAILURES.failed);
expect(h.calls).toHaveLength(1);
expect(target.snapshot()[0]?.status).toBe("error");
target.retry(target.snapshot()[0]?.id ?? "");
Expand Down
Loading
Loading