Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/failed-send-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@zooid/web": patch
---

A message the server rejects (for example sending from a room you've only been invited to) now shows as "Not sent" on its own tile with the reason, a Retry button that reuses the same transaction ID, and a Delete button, instead of looking delivered with a raw error under the composer.
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EventStatus, MatrixError, MatrixEvent, type Room } from "matrix-js-sdk";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MatrixClientPeg } from "@/client/peg";
import { useTimeline } from "@/hooks/use-timeline";
import { makeFakeClient, makeRoom } from "../../../test/factories";
import { MessagePanel } from "../structures/message-panel";
import { Composer } from "./composer";

const roomId = "!r:h.example";
const me = "@me:h.example";
const TXN = "txn-1";

afterEach(() => {
cleanup();
MatrixClientPeg.reset();
});

function Harness({ thread }: { thread?: string }) {
const { events } = useTimeline(roomId);
return (
<>
<MessagePanel events={events} />
<Composer roomId={roomId} threadRootEventId={thread} />
</>
);
}

/** Mimics what client.sendEvent does: add a local echo, then reject and mark it NOT_SENT. */
function rejectingSendEvent(room: Room, error: Error) {
return vi.fn(async (_roomId: string, _thread: string | null, type: string, content: object) => {
const echo = new MatrixEvent({
type,
content,
sender: me,
room_id: roomId,
event_id: `~${roomId}:${TXN}`,
origin_server_ts: Date.now(),
});
echo.setTxnId(TXN);
echo.setStatus(EventStatus.SENDING);
room.addPendingEvent(echo, TXN);
echo.error = error as MatrixError;
room.updatePendingEvent(echo, EventStatus.NOT_SENT);
throw error;
});
}

function setup(error: Error) {
const client = makeFakeClient({ userId: me });
const room = makeRoom(roomId, { client, myUserId: me });
const cast = client as unknown as Record<string, unknown>;
cast.getRoom = () => room;
cast.sendEvent = rejectingSendEvent(room, error);
cast.resendEvent = vi.fn().mockResolvedValue({ event_id: "$sent" });
cast.cancelPendingEvent = vi.fn();
MatrixClientPeg.injectClientForTest(client);
return { cast, room };
}

const forbidden = () =>
new MatrixError(
{ errcode: "M_FORBIDDEN", error: "Auth check failed: sender's membership 'invite' is not 'join'" },
403,
);

async function sendHello() {
const user = userEvent.setup();
await user.type(screen.getByRole("textbox", { name: /message/i }), "@agent hello{Enter}");
return user;
}

describe("a send the server rejects", () => {
it("shows the failed tile with Retry and no raw MatrixError under the composer", async () => {
setup(forbidden());
render(<Harness />);
await sendHello();

const alert = await screen.findByRole("alert");
expect(within(alert).getByText("Not sent")).toBeInTheDocument();
expect(within(alert).getByText(/not a member of this room yet/i)).toBeInTheDocument();
expect(within(alert).getByRole("button", { name: "Retry" })).toBeInTheDocument();
expect(within(alert).getByRole("button", { name: "Delete" })).toBeInTheDocument();
// the message text stays on the tile, and nothing renders it as delivered
expect(screen.getByText(/hello/)).toBeInTheDocument();
expect(document.querySelector("time")).toBeNull();
// the composer cleared and carries no raw error line
expect(screen.getByRole("textbox", { name: /message/i })).toHaveValue("");
expect(screen.queryByText(/MatrixError|M_FORBIDDEN|Auth check failed/)).toBeNull();
});

it("Retry resends the same event, so the txnId is reused", async () => {
const { cast, room } = setup(forbidden());
render(<Harness />);
const user = await sendHello();

await user.click(await screen.findByRole("button", { name: "Retry" }));

const echo = room.getLiveTimeline().getEvents()[0];
expect(echo.getAssociatedStatus() ?? echo.status).toBe(EventStatus.NOT_SENT);
expect(cast.resendEvent).toHaveBeenCalledTimes(1);
const [event, inRoom] = (cast.resendEvent as ReturnType<typeof vi.fn>).mock.calls[0];
expect(event).toBe(echo);
expect(event.getTxnId()).toBe(TXN);
expect(inRoom).toBe(room);
expect(cast.sendEvent).toHaveBeenCalledTimes(1);
});

it("Delete cancels the pending event", async () => {
const { cast, room } = setup(forbidden());
render(<Harness />);
const user = await sendHello();

await user.click(await screen.findByRole("button", { name: "Delete" }));
expect(cast.cancelPendingEvent).toHaveBeenCalledWith(room.getLiveTimeline().getEvents()[0]);
});

it("repaints the tile when the echo goes back to sending", async () => {
const { room } = setup(forbidden());
render(<Harness />);
await sendHello();
await screen.findByRole("alert");

room.updatePendingEvent(room.getLiveTimeline().getEvents()[0], EventStatus.SENDING);
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
expect(screen.getByText("Sending…")).toBeInTheDocument();
});

it("keeps the composer error for a custom event, which has no tile to show it", async () => {
const { cast } = setup(forbidden());
render(<Harness thread="$root" />);
const user = userEvent.setup();
await user.type(screen.getByRole("textbox", { name: /message/i }), "/clear{Enter}");

expect(await screen.findByText(/MatrixError: \[403\]/)).toBeInTheDocument();
expect(screen.queryByText("Not sent")).toBeNull();
expect(cast.cancelPendingEvent).toHaveBeenCalledTimes(1);
});
});
60 changes: 34 additions & 26 deletions packages/web/src/components/rooms/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
import { EventStatus, type MatrixEvent, type Room } from "matrix-js-sdk";
import { parseSlashCommand } from "@/lib/slash-commands";
import { buildQuoteContent } from "@/lib/matrix/quote";
import { setQuoteDraft, useQuoteDraft } from "@/lib/quote-draft-store";
import { QuoteChip } from "./quote-chip";
import { MessageInput, type MessageInputSubmit } from "./message-input";
import { useMatrixClient } from "../../hooks/use-matrix-client";
import { useThreadPreview } from "../../hooks/use-timeline";
import { allRoomEvents, useThreadPreview } from "../../hooks/use-timeline";
import { useTyping } from "../../hooks/use-typing";
import { useMediaUpload } from "../../hooks/use-media-upload";

Expand All @@ -27,6 +28,9 @@ type SendEvent = (
content: Record<string, unknown>,
) => Promise<{ event_id: string }>;

const notSentEchoes = (room: Room | null): MatrixEvent[] =>
room ? allRoomEvents(room).filter((ev) => ev.status === EventStatus.NOT_SENT) : [];

export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerProps) {
const client = useMatrixClient();
const [error, setError] = useState<string | null>(null);
Expand All @@ -53,6 +57,30 @@ export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerPr
return (rootEvt?.getContent() as { body?: string } | undefined)?.body ?? "";
}, [lastThreadEvent, threadRootEventId, client, roomId]);

/**
* Send one event. When the server rejects it, the SDK keeps a NOT_SENT local
* echo in the timeline. For message content that tile owns the failure (reason,
* Retry, Delete), so the error is swallowed here. Custom event types have no
* tile that shows a failure, so their echo is dropped and the error rethrown
* for the composer's error line.
*/
async function sendEvent(
type: string,
content: Record<string, unknown>,
{ tile }: { tile: boolean },
): Promise<void> {
const room = client.getRoom(roomId);
const known = new Set(notSentEchoes(room));
try {
await (client.sendEvent as unknown as SendEvent).call(client, roomId, threadId, type, content);
} catch (err) {
const echoes = notSentEchoes(room).filter((ev) => !known.has(ev));
if (tile && echoes.length > 0) return;
for (const ev of echoes) client.cancelPendingEvent(ev);
throw err;
}
}

async function send({ body, rawBody, mentionUserIds, attachments, setAttachments }: MessageInputSubmit): Promise<void> {
// Slash commands only apply when there's no attachment and the body starts with /
if (rawBody && attachments.length === 0 && !quoteDraft) {
Expand All @@ -67,13 +95,7 @@ export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerPr
"m.relates_to": { rel_type: "m.thread", event_id: threadId },
}
: slash.content;
await (client.sendEvent as unknown as SendEvent).call(
client,
roomId,
threadId,
slash.eventType,
slashContent,
);
await sendEvent(slash.eventType, slashContent, { tile: false });
return;
}
}
Expand All @@ -96,13 +118,7 @@ export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerPr
info: { mimetype: file.type, size: file.size },
};
if (!isImage) mediaContent.filename = file.name;
await (client.sendEvent as unknown as SendEvent).call(
client,
roomId,
threadId,
"m.room.message",
mediaContent,
);
await sendEvent("m.room.message", mediaContent, { tile: true });
pending.shift();
}
} finally {
Expand All @@ -123,13 +139,7 @@ export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerPr
if (mentionUserIds.length > 0) {
content["m.mentions"] = { user_ids: mentionUserIds };
}
await (client.sendEvent as unknown as SendEvent).call(
client,
roomId,
threadId,
"m.room.message",
content,
);
await sendEvent("m.room.message", content, { tile: true });
if (quoteDraft) setQuoteDraft(roomId, threadId, null);
}
}
Expand All @@ -140,12 +150,10 @@ export function Composer({ roomId, threadRootEventId, onExitThread }: ComposerPr
if (!slash) return;
setError(null);
try {
await (client.sendEvent as unknown as SendEvent).call(
client,
roomId,
threadId,
await sendEvent(
slash.eventType,
{ ...slash.content, "m.relates_to": { rel_type: "m.thread", event_id: threadId } },
{ tile: false },
);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
Expand Down
10 changes: 8 additions & 2 deletions packages/web/src/components/timeline/event-tile.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { ElicitationEventType } from "../../events/elicitation";
import { QuestionCard } from "./question-card";
import type { MatrixEvent } from "matrix-js-sdk";
import { EventStatus, type MatrixEvent } from "matrix-js-sdk";
import { ApprovalEventType } from "../../events/approval";
import { decodeZooidEvent, isZooidLifecycle } from "../../events/zooid-events";
import { ApprovalCard } from "./approval-card";
import { ZooidEventTile } from "./zooid-event";
import { ErrorTile } from "./error-tile";
import { MediaMessage } from "./media-message";
import { MembershipEvent } from "./membership-event";
import { EventSendFailure } from "./send-state";
import { SessionDivider } from "./session-divider";
import { TextMessage } from "./text-message";

Expand All @@ -29,7 +30,12 @@ export function EventTile({
if (event.getType() === "m.room.message") {
const msgtype = (event.getContent() as { msgtype?: string }).msgtype;
if (msgtype && MEDIA_MSGTYPES.has(msgtype)) {
return <MediaMessage event={event} />;
return (
<div className={event.status === EventStatus.NOT_SENT ? "opacity-60" : undefined}>
<MediaMessage event={event} />
{event.status === EventStatus.NOT_SENT && <EventSendFailure event={event} />}
</div>
);
}
return (
<TextMessage
Expand Down
92 changes: 92 additions & 0 deletions packages/web/src/components/timeline/send-state.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { Meta } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { UserAvatar } from "@/components/user-avatar";
import { senderColor } from "@/lib/sender";
import { MessageTile } from "./message-tile";
import { MessageTimestamp } from "./message-timestamp";
import { describeSendError, SendFailure, SendingIndicator } from "./send-state";

const me = "@me:h.example";

function Row({
body,
state,
reason,
}: {
body: string;
state: "sent" | "sending" | "failed";
reason?: string;
}) {
return (
<MessageTile
className={`hover:bg-muted/30 ${state === "failed" ? "opacity-60" : ""}`}
avatar={<UserAvatar userId={me} size="sm" />}
senderName="me"
senderColor={senderColor(me)}
senderTitle={me}
timestamp={
state === "failed" ? undefined : state === "sending" ? (
<SendingIndicator />
) : (
<MessageTimestamp ts={Date.now() - 20_000} />
)
}
>
<p className="min-w-0 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">{body}</p>
{state === "failed" && <SendFailure reason={reason ?? ""} onRetry={fn()} onDelete={fn()} />}
</MessageTile>
);
}

const meta = {
title: "Timeline/Send state",
component: SendFailure,
parameters: { layout: "padded" },
} satisfies Meta<typeof SendFailure>;

export default meta;

const notJoined = describeSendError({
errcode: "M_FORBIDDEN",
data: { error: "Auth check failed: sender's membership 'invite' is not 'join'" },
});

// Custom render ignores the SendFailure args; each story builds its tile inline.
export const Sending = {
args: { reason: "", onRetry: fn(), onDelete: fn() },
render: () => (
<div className="max-w-xl">
<Row state="sending" body="@agent can you take a look at this?" />
</div>
),
};

export const FailedNotJoined = {
args: { reason: notJoined, onRetry: fn(), onDelete: fn() },
render: () => (
<div className="max-w-xl">
<Row state="failed" body="@agent can you take a look at this?" reason={notJoined} />
</div>
),
};

export const FailedOtherError = {
args: { reason: "", onRetry: fn(), onDelete: fn() },
render: () => (
<div className="max-w-xl">
<Row state="failed" body="Rate limited, hold on." reason={describeSendError({ errcode: "M_LIMIT_EXCEEDED" })} />
</div>
),
};

// The three states side by side with a delivered message for contrast.
export const InTimeline = {
args: { reason: "", onRetry: fn(), onDelete: fn() },
render: () => (
<ol className="flex max-w-xl flex-col gap-0.5">
<Row state="sent" body="Delivered normally." />
<Row state="sending" body="Still in flight." />
<Row state="failed" body="@agent can you take a look at this?" reason={notJoined} />
</ol>
),
};
Loading
Loading